Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,003 changes: 1,003 additions & 0 deletions docs/superpowers/plans/2026-05-01-optional-micrometer-fix.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -5,43 +5,19 @@
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;

import com.digitalsanctuary.cf.turnstile.config.TurnstileConfigProperties;
import com.digitalsanctuary.cf.turnstile.config.TurnstileHealthIndicator;
import com.digitalsanctuary.cf.turnstile.config.TurnstileMetricsConfig;
import com.digitalsanctuary.cf.turnstile.config.TurnstileServiceConfig;
import com.digitalsanctuary.cf.turnstile.filter.TurnstileCaptchaFilter;
import io.micrometer.core.instrument.MeterRegistry;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;

/**
* Main auto-configuration class for the Spring Cloudflare Turnstile integration.
* <p>
* This class serves as the entry point for Spring Boot's auto-configuration mechanism to automatically set up Cloudflare Turnstile integration when
* the library is included in a project. It imports the necessary configuration components such as property management, service configuration, and
* metrics/monitoring configuration.
* </p>
* <p>
* To use this auto-configuration, include this library in your Spring Boot project and configure the required properties in your application.yml or
* application.properties file:
* </p>
*
* <pre>
* ds:
* cf:
* turnstile:
* sitekey: your-turnstile-site-key
* secret: your-turnstile-secret-key
* url: https://challenges.cloudflare.com/turnstile/v0/siteverify
* metrics:
* enabled: true
* health-check-enabled: true
* error-threshold: 10
* </pre>
* <p>
* The {@link #onStartup()} method is annotated with {@link jakarta.annotation.PostConstruct} and is executed after the bean initialization to log a
* confirmation message that the Cloudflare Turnstile Service has been loaded.
* Imports core configuration unconditionally; metrics and health configurations are
* conditional on the presence of their respective classes on the classpath.
* </p>
*
* @see com.digitalsanctuary.cf.turnstile.config.TurnstileConfigProperties
Expand All @@ -57,16 +33,23 @@
public class TurnstileConfiguration {

/**
* Metrics configuration for Turnstile. Only imported if MeterRegistry is available on the classpath.
* Metrics configuration for Turnstile.
* Only imported if Micrometer's MeterRegistry is available on the classpath.
* Uses string form of @ConditionalOnClass to avoid encoding a bytecode reference
* to MeterRegistry in this class, which would cause NoClassDefFoundError when
* Micrometer is absent.
*/
@Configuration
@ConditionalOnClass(MeterRegistry.class)
@ConditionalOnClass(name = "io.micrometer.core.instrument.MeterRegistry")
@Import(TurnstileMetricsConfig.class)
static class TurnstileMetricsConfiguration {
}

/**
* Health indicator configuration for Turnstile. Only imported if Spring Actuator health is enabled.
* Health indicator configuration for Turnstile.
* Only imported if Spring Actuator's {@code HealthIndicator} class is on the classpath
* and the turnstile health indicator has not been disabled via
* {@code management.health.turnstile.enabled=false}.
*/
@Configuration
@ConditionalOnEnabledHealthIndicator("turnstile")
Expand All @@ -76,10 +59,7 @@ static class TurnstileHealthConfiguration {
}

/**
* Method executed after the bean initialization.
* <p>
* This method logs a message indicating that the DigitalSanctuary Cloudflare Turnstile Service has been loaded.
* </p>
* Logs confirmation that the Turnstile service has been loaded.
*/
@PostConstruct
public void onStartup() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package com.digitalsanctuary.cf.turnstile.config;

import org.springframework.boot.micrometer.metrics.autoconfigure.MeterRegistryCustomizer;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import com.digitalsanctuary.cf.turnstile.metrics.MicrometerTurnstileMetrics;
import com.digitalsanctuary.cf.turnstile.metrics.TurnstileMetrics;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Tag;
import io.micrometer.core.instrument.config.MeterFilter;
Comment on lines 12 to 14

Copilot AI May 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description states that MicrometerTurnstileMetrics is “the only class in the library that references Micrometer types”, but TurnstileMetricsConfig also imports and references Micrometer types (MeterRegistry, Tag, MeterFilter). Either update the PR description to match the implementation, or move Micrometer-specific registry customization into the Micrometer-only section (so the claim remains accurate).

Copilot uses AI. Check for mistakes.
Expand All @@ -14,10 +17,7 @@

/**
* Configuration for Turnstile metrics and monitoring.
* <p>
* This class configures the metrics for Cloudflare Turnstile service. It sets up common tags and filters for all metrics related to the Turnstile
* service. The metrics are only configured if micrometer-core is on the classpath and metrics are enabled in the configuration.
* </p>
* Only loaded when Micrometer is on the classpath and metrics are enabled.
*/
@Slf4j
@Configuration
Expand All @@ -26,20 +26,28 @@
public class TurnstileMetricsConfig {

/**
* Customizes the meter registry for Turnstile metrics.
* <p>
* Adds a common tag 'component:turnstile' to all Turnstile-related metrics and configures a prefix for all Turnstile metrics.
* </p>
* Registers the Micrometer-backed TurnstileMetrics bean.
*
* @param registry the MeterRegistry to use for metrics
* @return a MicrometerTurnstileMetrics instance
*/
@Bean
@ConditionalOnBean(MeterRegistry.class)
@ConditionalOnMissingBean(TurnstileMetrics.class)
public TurnstileMetrics micrometerTurnstileMetrics(MeterRegistry registry) {
return new MicrometerTurnstileMetrics(registry);
}
Comment on lines +34 to +39

Copilot AI May 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

micrometerTurnstileMetrics(...) requires a MeterRegistry bean. If Micrometer classes are present but Boot’s metrics auto-configuration is excluded/disabled (or no registry is created), the context will fail to start due to an unsatisfied dependency. Add a bean-level condition such as @ConditionalOnBean(MeterRegistry.class) (or equivalent) so the Micrometer-backed TurnstileMetrics is only registered when a MeterRegistry bean actually exists, allowing the NoOpTurnstileMetrics fallback to take over.

Copilot uses AI. Check for mistakes.

/**
* Customizes the meter registry with Turnstile-specific tags and filters.
*
* @return a MeterRegistryCustomizer to customize the MeterRegistry
* @return a MeterRegistryCustomizer for the MeterRegistry
*/
@Bean
public MeterRegistryCustomizer<MeterRegistry> turnstileMeterRegistryCustomizer() {
log.info("Configuring Turnstile metrics");
return registry -> {
// Add a common tag to all turnstile metrics
registry.config().meterFilter(MeterFilter.acceptNameStartsWith("turnstile"))
.meterFilter(MeterFilter.commonTags(Collections.singletonList(Tag.of("component", "turnstile"))));
};
return registry -> registry.config()
.meterFilter(MeterFilter.acceptNameStartsWith("turnstile"))
.meterFilter(MeterFilter.commonTags(Collections.singletonList(Tag.of("component", "turnstile"))));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,42 +2,57 @@

import java.net.http.HttpClient;
import java.time.Duration;
import java.util.Optional;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
import com.digitalsanctuary.cf.turnstile.metrics.NoOpTurnstileMetrics;
import com.digitalsanctuary.cf.turnstile.metrics.TurnstileMetrics;
import com.digitalsanctuary.cf.turnstile.service.TurnstileValidationService;
import io.micrometer.core.instrument.MeterRegistry;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

/**
* Configuration class for setting up Turnstile related beans. This class configures the RestTemplate and RestClient used for Turnstile API
* interactions, and initializes the TurnstileValidationService.
* Configuration class for setting up Turnstile related beans.
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
public class TurnstileServiceConfig {

private final TurnstileConfigProperties properties;
private final ObjectProvider<MeterRegistry> meterRegistryProvider;

/**
* Provides a no-op {@link TurnstileMetrics} bean when no other implementation is registered.
* This fallback is active when Micrometer is absent from the classpath, when metrics are
* disabled via {@code ds.cf.turnstile.metrics.enabled=false}, or when no custom
* {@code TurnstileMetrics} bean has been supplied by the consuming application.
*
* @return a no-op TurnstileMetrics instance
*/
@Bean
@ConditionalOnMissingBean(TurnstileMetrics.class)
public TurnstileMetrics noOpTurnstileMetrics() {
log.info("No TurnstileMetrics bean available — using no-op Turnstile metrics");
return new NoOpTurnstileMetrics();
}

/**
* Creates a TurnstileValidationService bean.
*
* @param restClient the preconfigured REST client for Turnstile calls
* @param metrics the TurnstileMetrics implementation to use
* @return a configured TurnstileValidationService instance
*/
@Bean
public TurnstileValidationService turnstileValidationService(@Qualifier("turnstileRestClient") RestClient restClient) {
Optional<MeterRegistry> optionalRegistry = Optional.ofNullable(meterRegistryProvider.getIfAvailable());
return new TurnstileValidationService(restClient, properties, optionalRegistry);
public TurnstileValidationService turnstileValidationService(
@Qualifier("turnstileRestClient") RestClient restClient,
TurnstileMetrics metrics) {
return new TurnstileValidationService(restClient, properties, metrics);
}

/**
Expand All @@ -51,12 +66,10 @@ public RestClient turnstileRestClient() {
log.info("Turnstile REST client timeouts - connect: {}s, read: {}s",
properties.getConnectTimeout(), properties.getReadTimeout());

// Configure HttpClient with timeouts
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(properties.getConnectTimeout()))
.build();

// Create request factory with the configured HttpClient
JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient);
requestFactory.setReadTimeout(Duration.ofSeconds(properties.getReadTimeout()));

Expand All @@ -67,5 +80,4 @@ public RestClient turnstileRestClient() {
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.build();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package com.digitalsanctuary.cf.turnstile.metrics;

import com.digitalsanctuary.cf.turnstile.dto.ValidationResult.ValidationResultType;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import lombok.extern.slf4j.Slf4j;

/**
* Micrometer-backed implementation of {@link TurnstileMetrics}.
* <p>
* This class is only instantiated when Micrometer is present on the classpath, via
* {@code TurnstileMetricsConfig} which is guarded by {@code @ConditionalOnClass(MeterRegistry.class)}.
* </p>
* <p>
* All eight meters ({@code errorCounter} is the aggregate; the four type-specific counters are
* sub-categories whose sum equals the aggregate) are eagerly registered at construction time so
* they appear in monitoring dashboards before the first validation event occurs. A single instance
* should be registered per application context to avoid duplicate meter registration errors.
* </p>
*
* @see NoOpTurnstileMetrics
*/
@Slf4j
public class MicrometerTurnstileMetrics implements TurnstileMetrics {

private final Counter validationCounter;
private final Counter successCounter;
private final Counter errorCounter;
private final Counter networkErrorCounter;
private final Counter configErrorCounter;
private final Counter validationErrorCounter;
private final Counter inputErrorCounter;
private final Timer responseTimer;

/**
* Creates a {@code MicrometerTurnstileMetrics} instance and eagerly registers all Turnstile
* meters with the provided registry. The registered meters are:
* <ul>
* <li>{@code turnstile.validation.requests} — total attempts</li>
* <li>{@code turnstile.validation.success} — successful validations</li>
* <li>{@code turnstile.validation.errors} — all errors (aggregate)</li>
* <li>{@code turnstile.validation.errors.network} — network errors</li>
* <li>{@code turnstile.validation.errors.config} — configuration errors</li>
* <li>{@code turnstile.validation.errors.token} — invalid token errors</li>
* <li>{@code turnstile.validation.errors.input} — input validation errors</li>
* <li>{@code turnstile.validation.response.time} — response time timer</li>
* </ul>
*
* @param registry the Micrometer {@link MeterRegistry} to register meters with; must not be null
*/
public MicrometerTurnstileMetrics(MeterRegistry registry) {
Objects.requireNonNull(registry, "registry must not be null");
log.info("Initializing Turnstile metrics with MeterRegistry");
validationCounter = Counter.builder("turnstile.validation.requests")
.description("Total number of Turnstile validation requests").register(registry);
successCounter = Counter.builder("turnstile.validation.success")
.description("Number of successful Turnstile validations").register(registry);
errorCounter = Counter.builder("turnstile.validation.errors")
.description("Number of failed Turnstile validations").register(registry);
networkErrorCounter = Counter.builder("turnstile.validation.errors.network")
.description("Number of Turnstile validation network errors").register(registry);
configErrorCounter = Counter.builder("turnstile.validation.errors.config")
.description("Number of Turnstile validation configuration errors").register(registry);
validationErrorCounter = Counter.builder("turnstile.validation.errors.token")
.description("Number of Turnstile validation token errors").register(registry);
inputErrorCounter = Counter.builder("turnstile.validation.errors.input")
.description("Number of Turnstile validation input errors").register(registry);
responseTimer = Timer.builder("turnstile.validation.response.time")
.description("Response time for Turnstile validation requests").register(registry);
}

@Override
public void recordValidation() {
validationCounter.increment();
}

@Override
public void recordSuccess() {
successCounter.increment();
}

@Override
public void recordError(ValidationResultType type) {
errorCounter.increment();
switch (type) {
case NETWORK_ERROR -> networkErrorCounter.increment();
case CONFIGURATION_ERROR -> configErrorCounter.increment();
case INVALID_TOKEN -> validationErrorCounter.increment();
case INPUT_ERROR -> inputErrorCounter.increment();
default -> { }
}
}

@Override
public void recordResponseTime(long milliseconds) {
responseTimer.record(milliseconds, TimeUnit.MILLISECONDS);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.digitalsanctuary.cf.turnstile.metrics;

import com.digitalsanctuary.cf.turnstile.dto.ValidationResult.ValidationResultType;

/**
* No-op implementation of TurnstileMetrics used when Micrometer is not on the classpath.
*/
public class NoOpTurnstileMetrics implements TurnstileMetrics {

@Override
public void recordValidation() { // no-op
}

@Override
public void recordSuccess() { // no-op
}

@Override
public void recordError(ValidationResultType type) { // no-op
}

@Override
public void recordResponseTime(long milliseconds) { // no-op
}
}
Loading
Loading