Skip to content

fix: resolve NoClassDefFoundError when Micrometer is absent (issue #94)#95

Merged
devondragon merged 15 commits into
mainfrom
fix/issue-94-optional-micrometer
May 1, 2026
Merged

fix: resolve NoClassDefFoundError when Micrometer is absent (issue #94)#95
devondragon merged 15 commits into
mainfrom
fix/issue-94-optional-micrometer

Conversation

@devondragon
Copy link
Copy Markdown
Owner

Summary

  • Introduces a TurnstileMetrics interface with a NoOpTurnstileMetrics fallback (no Micrometer imports) and a MicrometerTurnstileMetrics implementation — the only class in the library that references Micrometer types
  • Removes all direct Micrometer type references (MeterRegistry, Counter, Timer) from classes that load unconditionally: TurnstileValidationService, TurnstileServiceConfig, and TurnstileConfiguration
  • Switches @ConditionalOnClass(MeterRegistry.class) (class literal, unsafe) to @ConditionalOnClass(name = "io.micrometer.core.instrument.MeterRegistry") (string form, safe) in TurnstileConfiguration
  • Adds @ConditionalOnMissingBean(TurnstileMetrics.class) to both metric bean providers so consumers can override the implementation

Root Cause

Since v1.1.8, classes annotated with @ConditionalOnClass(MeterRegistry.class) and classes with Optional<MeterRegistry> / Counter / Timer fields encoded direct bytecode references to Micrometer types. When Micrometer was absent from a consumer's classpath, the JVM threw NoClassDefFoundError: io/micrometer/core/instrument/MeterRegistry at class-load time before Spring could evaluate any conditional.

Test Plan

  • Run ./gradlew test — 15 tests across 3 classes, all passing
  • Verify TurnstileMetricsWiringTest (3 tests) confirms metrics beans wire correctly when Micrometer auto-configs are excluded
  • Manually verify no import io.micrometer in TurnstileConfiguration, TurnstileServiceConfig, or TurnstileValidationService
  • Confirm Micrometer metrics still work when spring-boot-starter-actuator is present
  • Confirm library starts cleanly in a consumer project without spring-boot-starter-actuator

Closes #94

devondragon added 12 commits May 1, 2026 10:11
…use TurnstileMetrics interface

Also removes the now-orphaned manual TurnstileValidationService @bean factory
from TurnstileServiceConfig (the @service annotation handles Spring registration;
TurnstileMetrics will be autowired once Task 5 publishes the bean).
…on classpath

- TurnstileServiceConfig: add noOpTurnstileMetrics() (@ConditionalOnMissingBean) and turnstileValidationService() factory method injecting TurnstileMetrics
- TurnstileMetricsConfig: add micrometerTurnstileMetrics() bean (@ConditionalOnClass(MeterRegistry.class))
- TurnstileValidationService: remove @service — bean is now registered exclusively via factory method
… prevent NoClassDefFoundError when Micrometer is absent
…ator

Verifies the library starts correctly when Micrometer auto-configurations
are excluded via spring.autoconfigure.exclude, covering context load,
metrics bean availability, validation functionality, and internal counter
tracking without throwing NoClassDefFoundError.
Copilot AI review requested due to automatic review settings May 1, 2026 17:31
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Fixes NoClassDefFoundError when Micrometer isn’t on a consumer’s classpath by isolating Micrometer-specific references behind a TurnstileMetrics abstraction and switching to safe, string-based conditional loading.

Changes:

  • Introduces TurnstileMetrics with NoOpTurnstileMetrics fallback and MicrometerTurnstileMetrics implementation.
  • Refactors TurnstileValidationService and config wiring to avoid unconditional Micrometer type references.
  • Updates auto-configuration to use @ConditionalOnClass(name=...) and adds a Spring wiring sanity test.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
src/test/java/com/digitalsanctuary/cf/test/turnstile/TurnstileMetricsWiringTest.java Adds a context wiring test around metrics exclusions/classpath conditions.
src/main/java/com/digitalsanctuary/cf/turnstile/service/TurnstileValidationService.java Replaces direct Micrometer usage with TurnstileMetrics calls and records timings in finally.
src/main/java/com/digitalsanctuary/cf/turnstile/metrics/TurnstileMetrics.java Adds the metrics abstraction used by the service layer.
src/main/java/com/digitalsanctuary/cf/turnstile/metrics/NoOpTurnstileMetrics.java Adds no-op metrics implementation for non-Micrometer environments.
src/main/java/com/digitalsanctuary/cf/turnstile/metrics/MicrometerTurnstileMetrics.java Adds Micrometer-backed implementation encapsulating counters/timer usage.
src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileServiceConfig.java Wires TurnstileMetrics into the service and provides a default no-op bean.
src/main/java/com/digitalsanctuary/cf/turnstile/config/TurnstileMetricsConfig.java Registers the Micrometer-backed bean and applies MeterRegistry customizations.
src/main/java/com/digitalsanctuary/cf/turnstile/TurnstileConfiguration.java Switches conditional import to string-based @ConditionalOnClass to prevent bytecode linkage errors.
docs/superpowers/plans/2026-05-01-optional-micrometer-fix.md Adds an implementation plan documenting the approach and steps.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 209 to 222
} catch (TurnstileValidationException e) {
// Re-throw the TurnstileValidationException
recordError(ValidationResultType.INVALID_TOKEN);
throw e;
} catch (Exception e) {
// Catch-all for any other unexpected exceptions
log.error("Unexpected error during Turnstile validation: {}", e.getMessage(), e);
recordError(ValidationResultType.NETWORK_ERROR);
throw new TurnstileNetworkException("Unexpected error: " + e.getMessage(), e);
} finally {
long elapsed = System.currentTimeMillis() - startTime;
lastResponseTime.set(elapsed);
totalResponseTime.addAndGet(elapsed);
responseCount.incrementAndGet();
metrics.recordResponseTime(elapsed);
}
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

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

The INVALID_TOKEN error is recorded twice: once in executeValidationRequest(...) before throwing TurnstileValidationException, and again in the outer catch (TurnstileValidationException e) block. This will double-increment internal counters and Micrometer counters for invalid tokens. Prefer recording the error in exactly one place (either remove the recordError(INVALID_TOKEN) in executeValidationRequest(...) or remove it from the catch (TurnstileValidationException e) and just rethrow).

Copilot uses AI. Check for mistakes.
Comment on lines 242 to 246
@@ -344,52 +246,23 @@ private ValidationResult executeValidationRequest(Map<String, String> requestBod
}
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

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

The INVALID_TOKEN error is recorded twice: once in executeValidationRequest(...) before throwing TurnstileValidationException, and again in the outer catch (TurnstileValidationException e) block. This will double-increment internal counters and Micrometer counters for invalid tokens. Prefer recording the error in exactly one place (either remove the recordError(INVALID_TOKEN) in executeValidationRequest(...) or remove it from the catch (TurnstileValidationException e) and just rethrow).

Copilot uses AI. Check for mistakes.
Comment on lines +33 to +37
@Bean
@ConditionalOnMissingBean(TurnstileMetrics.class)
public TurnstileMetrics micrometerTurnstileMetrics(MeterRegistry registry) {
return new MicrometerTurnstileMetrics(registry);
}
Copy link

Copilot AI May 1, 2026

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.
Comment on lines +42 to +49
* Because Micrometer is still on the test classpath, MicrometerTurnstileMetrics remains active.
*/
@Test
void metricsAvailableWhenMicrometerAutoConfigExcluded() {
assertNotNull(turnstileMetrics,
"A TurnstileMetrics bean should be available even with Micrometer auto-config excluded");
assertFalse(turnstileMetrics instanceof NoOpTurnstileMetrics,
"MicrometerTurnstileMetrics should be active when Micrometer is on the classpath");
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

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

This test excludes MetricsAutoConfiguration and SimpleMetricsExportAutoConfiguration, which commonly results in no MeterRegistry bean being created. With the intended wiring, that should typically select the NoOpTurnstileMetrics fallback, not the Micrometer-backed implementation. Consider updating the assertion to expect NoOpTurnstileMetrics (or, if the expectation is truly “Micrometer metrics should remain active”, ensure the test context still provides a MeterRegistry bean explicitly).

Suggested change
* Because Micrometer is still on the test classpath, MicrometerTurnstileMetrics remains active.
*/
@Test
void metricsAvailableWhenMicrometerAutoConfigExcluded() {
assertNotNull(turnstileMetrics,
"A TurnstileMetrics bean should be available even with Micrometer auto-config excluded");
assertFalse(turnstileMetrics instanceof NoOpTurnstileMetrics,
"MicrometerTurnstileMetrics should be active when Micrometer is on the classpath");
* Without a MeterRegistry bean in the test context, the NoOpTurnstileMetrics fallback should be active.
*/
@Test
void noOpMetricsActiveWhenMicrometerAutoConfigExcluded() {
assertNotNull(turnstileMetrics,
"A TurnstileMetrics bean should be available even with Micrometer auto-config excluded");
assertTrue(turnstileMetrics instanceof NoOpTurnstileMetrics,
"NoOpTurnstileMetrics should be active when MeterRegistry auto-configuration is excluded");

Copilot uses AI. Check for mistakes.
Comment on lines 11 to 13
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Tag;
import io.micrometer.core.instrument.config.MeterFilter;
Copy link

Copilot AI May 1, 2026

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.
Comment on lines +31 to +38
* This is the fallback when Micrometer is not on the classpath.
*
* @return a no-op TurnstileMetrics instance
*/
@Bean
@ConditionalOnMissingBean(TurnstileMetrics.class)
public TurnstileMetrics noOpTurnstileMetrics() {
log.info("Micrometer not available — using no-op Turnstile metrics");
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

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

The log message “Micrometer not available” can be misleading in cases where Micrometer is present but metrics are disabled by property, or where Micrometer classes are present but no MeterRegistry bean exists. Consider rewording the message to reflect “no MeterRegistry/metrics wiring available” (or similar), or lowering the log level to reduce noise on normal startup paths.

Suggested change
* This is the fallback when Micrometer is not on the classpath.
*
* @return a no-op TurnstileMetrics instance
*/
@Bean
@ConditionalOnMissingBean(TurnstileMetrics.class)
public TurnstileMetrics noOpTurnstileMetrics() {
log.info("Micrometer not available — using no-op Turnstile metrics");
* This is the fallback when no TurnstileMetrics bean or metrics wiring is available.
*
* @return a no-op TurnstileMetrics instance
*/
@Bean
@ConditionalOnMissingBean(TurnstileMetrics.class)
public TurnstileMetrics noOpTurnstileMetrics() {
log.info("No TurnstileMetrics bean or metrics wiring available — using no-op Turnstile metrics");

Copilot uses AI. Check for mistakes.
Comment on lines +18 to +21
* <p>Note: Micrometer is still on the test classpath (via spring-boot-starter-actuator), so these
* tests cannot detect a bytecode-level {@code NoClassDefFoundError} regression. They verify that
* Spring auto-configuration exclusions are respected and that the correct metrics implementation
* is active given the classpath conditions.
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

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

The core regression being fixed is a bytecode-level NoClassDefFoundError when Micrometer is absent, but the current tests explicitly run with Micrometer on the test classpath (so they cannot fail in the way consumers reported). To cover the real failure mode, add a test job/source set/submodule that runs a minimal Spring context load with Micrometer/Actuator excluded from the test runtime classpath (not just auto-config exclusions), so classloading/linkage regressions are caught in CI.

Copilot uses AI. Check for mistakes.
…vaDoc, unit tests

- Remove duplicate recordError(INVALID_TOKEN) call from executeValidationRequest;
  outer catch (TurnstileValidationException) is the single correct recording site
- Wrap metrics.recordResponseTime() in try/catch inside finally block so a metrics
  failure cannot suppress the original validation exception
- Add log.debug to TurnstileValidationException catch for symmetry with other handlers
- Improve broad catch to log exception class name for better diagnostics
- Add complete JavaDoc to TurnstileMetrics interface (recordValidation, recordSuccess,
  recordResponseTime) including call-sequence contract and timing semantics
- Add class and constructor JavaDoc to MicrometerTurnstileMetrics; add
  Objects.requireNonNull guard on registry parameter
- Fix TurnstileValidationService class-level doc (no longer says "when metrics enabled")
- Fix TurnstileHealthConfiguration comment to mention @ConditionalOnEnabledHealthIndicator
- Fix noOpTurnstileMetrics comment to say "no TurnstileMetrics bean registered"
  rather than "Micrometer not on classpath"
- Add MicrometerTurnstileMetricsTest (9 tests) covering all counter dispatch paths,
  timer recording, null-registry rejection, and aggregate/sub-counter consistency
…ssage

Add @ConditionalOnBean(MeterRegistry.class) to micrometerTurnstileMetrics so
the Micrometer-backed bean only registers when a MeterRegistry bean actually
exists, gracefully falling back to NoOpTurnstileMetrics otherwise.

Update noOpTurnstileMetrics log message to accurately describe all fallback
scenarios (no Micrometer, metrics disabled, no registry available).

Update TurnstileMetricsWiringTest to assert the correct post-fix behavior:
NoOpTurnstileMetrics is active when MetricsAutoConfiguration is excluded and
no MeterRegistry bean is present.
@devondragon devondragon merged commit 7a390a1 into main May 1, 2026
6 checks passed
@devondragon devondragon deleted the fix/issue-94-optional-micrometer branch May 1, 2026 18:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Metrics are mandatory?

2 participants