diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/DebugLoggingHook.java b/dd-openfeature/src/main/java/com/datadog/openfeature/DebugLoggingHook.java new file mode 100644 index 00000000000..94f88109e80 --- /dev/null +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/DebugLoggingHook.java @@ -0,0 +1,246 @@ +package com.datadog.openfeature; + +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.Hook; +import dev.openfeature.sdk.HookContext; +import dev.openfeature.sdk.Structure; +import dev.openfeature.sdk.Value; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Logs the raw outcome of every evaluation, to help onboarding. + * + *

The evaluation context and values are logged as they are, including any personal data they + * carry (targeting keys, emails, user attributes), and they are not subject to the observe full + * evaluation data consent. The provider therefore registers this hook only when both {@code + * DD_TRACE_DEBUG} and the application logger allow DEBUG; do not enable them where application logs + * leave a trusted boundary. The error message is the one the application observes: the consent + * filter applies to the messages the provider produces. Each logged value is capped at {@value + * #MAX_VALUE_LENGTH} characters, not counting the truncation marker, and nested values are rendered + * within that budget and a depth limit, whatever their shape. + * + *

It is not telemetry, so the evaluation telemetry opt-out does not apply. It runs at the + * finally stage to report what the application observes, and a logging failure never reaches the + * evaluation. + */ +final class DebugLoggingHook implements Hook { + private static final Logger log = LoggerFactory.getLogger(DebugLoggingHook.class); + + /** The longest rendering of a context or value, so that a large object cannot flood the log. */ + static final int MAX_VALUE_LENGTH = 1024; + + /** + * Tells whether the hook can log. + * + * @return whether the application logger allows DEBUG for the hook. + */ + static boolean isLoggerEnabled() { + try { + return log.isDebugEnabled(); + } catch (final RuntimeException | LinkageError e) { + // A failing logging backend must not prevent the provider from working. + return false; + } + } + + /** + * Says that raw evaluations are logged. It goes through the logger of the hook, so that enabling + * DEBUG for the hook alone, as the provider warning suggests, also shows it. + */ + static void announce() { + try { + log.info( + "DD_TRACE_DEBUG is enabled and DEBUG is enabled for {}: raw evaluation contexts and" + + " values, including any personal data in them, are written to the application" + + " logs.", + DebugLoggingHook.class.getName()); + } catch (final RuntimeException | LinkageError e) { + // A failing logging backend must not prevent the provider from working. + } + } + + @Override + public void finallyAfter( + final HookContext ctx, + final FlagEvaluationDetails details, + final Map hints) { + // The hook is registered when the logger allows DEBUG, which an application can change later. + if (details == null || !isLoggerEnabled()) { + return; + } + try { + final EvaluationContext context = ctx.getCtx(); + log.debug( + "Feature Flagging evaluation: flag={}, context={}, defaultValue={}, value={}, variant={}, reason={}, errorCode={}, errorMessage={}, metadata={}", + raw(details.getFlagKey()), + raw(context), + raw(ctx.getDefaultValue()), + raw(details.getValue()), + raw(details.getVariant()), + raw(details.getReason()), + details.getErrorCode(), + raw(details.getErrorMessage()), + details.getFlagMetadata() == null + ? null + : raw(details.getFlagMetadata().asUnmodifiableMap())); + } catch (final RuntimeException | LinkageError e) { + // Application supplied values can fail to format, and a diagnostic must not break the + // evaluation. + try { + log.debug("Feature Flagging evaluation diagnostic unavailable: {}", raw(e.toString())); + } catch (final RuntimeException | LinkageError ignored) { + // Nothing more can be done. + } + } + } + + /** + * Renders a value for the log, within {@link #MAX_VALUE_LENGTH} characters and without trusting + * its shape: structures can be user supplied, deeply nested or even refer to themselves. + */ + private static String raw(final Object value) { + if (value instanceof Value + || value instanceof Structure + || value instanceof Map + || value instanceof Iterable) { + return new Renderer().render(value); + } + return capped(String.valueOf(value)); + } + + /** Caps a text, telling how long it was. */ + private static String capped(final String text) { + if (text.length() <= MAX_VALUE_LENGTH) { + return text; + } + // Do not cut a surrogate pair in half. + final int end = + Character.isHighSurrogate(text.charAt(MAX_VALUE_LENGTH - 1)) + ? MAX_VALUE_LENGTH - 1 + : MAX_VALUE_LENGTH; + return text.substring(0, end) + "...(truncated, " + text.length() + " characters)"; + } + + /** + * Renders values within a budget, applied while walking them: the work does not grow with the + * size of the value: it stops walking a container once the budget is spent. Nesting is limited + * and a container already being rendered is not entered again, so a cycle or a very deep value + * cannot overflow the stack of the evaluating thread. + */ + private static final class Renderer { + private static final int MAX_DEPTH = 8; + + private final StringBuilder out = new StringBuilder(); + private final Set path = Collections.newSetFromMap(new IdentityHashMap<>()); + private boolean truncated; + + String render(final Object value) { + write(value, 0); + if (this.truncated) { + final int last = this.out.length() - 1; + if (last >= 0 && Character.isHighSurrogate(this.out.charAt(last))) { + this.out.setLength(last); + } + this.out.append("...(truncated)"); + } + return this.out.toString(); + } + + private void write(final Object value, final int depth) { + if (this.truncated) { + return; + } + if (value == null) { + text("null"); + } else if (value instanceof Value + || value instanceof Structure + || value instanceof Map + || value instanceof Iterable) { + writeContainer(value, depth); + } else { + text(String.valueOf(value)); + } + } + + private void writeContainer(final Object container, final int depth) { + if (depth > MAX_DEPTH) { + text("..."); + return; + } + if (!this.path.add(container)) { + text("(cycle)"); + return; + } + try { + if (container instanceof Value) { + writeValue((Value) container, depth); + } else if (container instanceof Structure) { + // A view: the keys and values of an immutable structure are copied on each access. + writeEntries(((Structure) container).asUnmodifiableMap(), depth); + } else if (container instanceof Map) { + writeEntries((Map) container, depth); + } else { + text("["); + boolean first = true; + for (final Object element : (Iterable) container) { + if (this.truncated) { + break; + } + if (!first) { + text(", "); + } + first = false; + write(element, depth + 1); + } + text("]"); + } + } finally { + this.path.remove(container); + } + } + + private void writeEntries(final Map map, final int depth) { + text("{"); + boolean first = true; + for (final Map.Entry entry : map.entrySet()) { + if (this.truncated) { + break; + } + if (!first) { + text(", "); + } + first = false; + write(entry.getKey(), depth + 1); + text("="); + write(entry.getValue(), depth + 1); + } + text("}"); + } + + /** + * Renders what the value wraps. It does not ask {@code isList()} or {@code asList()}: they + * check every element of the list, whatever the budget, and a list holding anything else than + * values is not one for them. + */ + private void writeValue(final Value value, final int depth) { + write(value.asObject(), depth); + } + + /** Appends the text, or what fits of it, and gives up on the rest of the value. */ + private void text(final String text) { + final int remaining = MAX_VALUE_LENGTH - this.out.length(); + if (text.length() > remaining) { + this.out.append(text, 0, Math.max(remaining, 0)); + this.truncated = true; + } else { + this.out.append(text); + } + } + } +} diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/ExposureLoggingHook.java b/dd-openfeature/src/main/java/com/datadog/openfeature/ExposureLoggingHook.java index f0065abc6df..db6eba8a70d 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/ExposureLoggingHook.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/ExposureLoggingHook.java @@ -47,16 +47,22 @@ public void finallyAfter( final Map hints) { try { if (details == null || details.getErrorCode() != null) { + log.trace("Feature Flagging exposure suppressed: evaluation failed or details unavailable"); return; } final ImmutableMetadata metadata = details.getFlagMetadata(); if (metadata == null) { + log.trace("Feature Flagging exposure suppressed: metadata unavailable"); return; } // The ImmutableMetadata getters build a debug message for every absent key. final Map values = metadata.asUnmodifiableMap(); - if (!Boolean.TRUE.equals(values.get(DO_LOG)) - || !Boolean.FALSE.equals(values.get(EXPOSURE_CACHE_HIT))) { + if (!Boolean.TRUE.equals(values.get(DO_LOG))) { + log.trace("Feature Flagging exposure suppressed: allocation does not log exposures"); + return; + } + if (!Boolean.FALSE.equals(values.get(EXPOSURE_CACHE_HIT))) { + log.trace("Feature Flagging exposure suppressed: exposure already recorded"); return; } final Object allocationValue = values.get(ALLOCATION_KEY); @@ -65,11 +71,14 @@ public void finallyAfter( final String variantKey = details.getVariant(); final EvaluationContext context = ctx == null ? null : ctx.getCtx(); if (allocationKey == null || variantKey == null || context == null) { + log.trace( + "Feature Flagging exposure suppressed: allocation, variant, or context unavailable"); return; } final DDEvaluator evaluator = this.evaluatorSupplier.get(); final ExposureDeduplicationCache cache = evaluator == null ? null : evaluator.exposureCache(); if (cache == null) { + log.trace("Feature Flagging exposure suppressed: provider not initialized"); return; } final String flag = details.getFlagKey(); @@ -77,9 +86,12 @@ public void finallyAfter( final Integer serialId = serialValue instanceof Integer ? (Integer) serialValue : null; // The exposure is recorded even when it is not sent, so exposure hooks get the same // decisions whether or not exposures go to Datadog. - if (cache.recordIfChanged( - flag, context.getTargetingKey(), allocationKey, variantKey, serialId) - && evaluator.isExposureDeliveryEnabled()) { + if (!cache.recordIfChanged( + flag, context.getTargetingKey(), allocationKey, variantKey, serialId)) { + log.trace("Feature Flagging exposure suppressed: exposure already recorded"); + } else if (!evaluator.isExposureDeliveryEnabled()) { + log.trace("Feature Flagging exposure suppressed: Datadog exposure delivery is disabled"); + } else { evaluator.recordExposure( DDEvaluator.exposureEvent( flag, diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java b/dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java index e3ac093b7ad..70ac3436b8c 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/Provider.java @@ -102,7 +102,7 @@ public Provider(final Options options) { // Precompute the immutable hook list once so getProviderHooks() (called on every evaluation) // allocates nothing, including when the gate is off. - final List hooks = new ArrayList<>(4); + final List hooks = new ArrayList<>(5); if (flagEvalMetricsHook != null) { hooks.add(flagEvalMetricsHook); } @@ -117,6 +117,20 @@ public Provider(final Options options) { hooks.set(i, new EvaluationTelemetryOptOutHook(hooks.get(i))); } } + // Local debug diagnostics are not telemetry, so the opt-out does not apply to them. + if (this.settings.isTraceDebug()) { + if (DebugLoggingHook.isLoggerEnabled()) { + hooks.add(new DebugLoggingHook<>()); + DebugLoggingHook.announce(); + } else { + // DD_TRACE_DEBUG cannot raise the application's logger levels, so name the second gate. + log.warn( + "DD_TRACE_DEBUG is enabled, but DEBUG is disabled for {} in the application's logging" + + " configuration; evaluation debug diagnostics are off. Enable DEBUG for that" + + " category before constructing the Provider.", + DebugLoggingHook.class.getName()); + } + } // Exposure hook: always registered last, and independent of evaluation telemetry opt-out; no-op // when the evaluator is not a Datadog one or is // not initialized. @@ -136,21 +150,29 @@ public Provider(final Options options) { @Override public void initialize(final EvaluationContext context) throws Exception { initializationState.set(InitializationState.INITIALIZING); + debug( + "Feature Flagging provider initializing: timeout={} {}", + options.getTimeout(), + options.getUnit()); try { evaluator = buildEvaluator(); if (!evaluator.initialize(options.getTimeout(), options.getUnit(), context)) { if (markInitialConfigReceivedReady()) { + debug("Feature Flagging provider ready: configuration received during initialization"); return; } markInitializationError(); + debug("Feature Flagging provider initialization timed out waiting for configuration"); throw new ProviderNotReadyError( "Provider timed-out while waiting for initial configuration"); } if (!evaluator.hasConfiguration() || !markSuccessfulInitializationReady()) { markInitializationError(); + debug("Feature Flagging provider initialization timed out waiting for configuration"); throw new ProviderNotReadyError( "Provider timed-out while waiting for initial configuration"); } + debug("Feature Flagging provider ready"); } catch (final RemoteConfigurationUnavailableException e) { // No RC connector means no configuration can arrive in this JVM. Complete registration // without waiting or switching sources. The evaluator has no runtime/configuration and @@ -164,11 +186,25 @@ public void initialize(final EvaluationContext context) throws Exception { throw e; } catch (final Throwable e) { markInitializationError(); + // Only the type: the message of a configuration error can echo a URL with credentials. + debug("Feature Flagging provider initialization failed: {}", e.getClass().getName()); throw new FatalError( "Failed to initialize Datadog Feature Flags provider: " + e.getMessage(), e); } } + /** + * Logs a lifecycle diagnostic. A failing logging backend must not turn a state change into a + * failure, such as a successful initialization into an error. + */ + private static void debug(final String message, final Object... arguments) { + try { + log.debug(message, arguments); + } catch (final RuntimeException | LinkageError ignored) { + // Nothing more can be done. + } + } + void onConfigurationChange() { if (evaluator == null || !evaluator.hasConfiguration()) { onConfigurationUnavailable(); @@ -187,6 +223,7 @@ void onConfigurationChange() { if (state == InitializationState.ERROR && initializationState.compareAndSet( InitializationState.ERROR, InitializationState.READY)) { + debug("Feature Flagging provider recovered: configuration received after an error"); emit( ProviderEvent.PROVIDER_READY, ProviderEventDetails.builder().message("Provider ready").build()); @@ -195,6 +232,7 @@ void onConfigurationChange() { if (initializationState.get() != InitializationState.READY) { return; } + debug("Feature Flagging provider configuration changed"); emit( ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, ProviderEventDetails.builder().message("New configuration received").build()); @@ -208,6 +246,7 @@ private void onConfigurationUnavailable() { if (!initializationState.compareAndSet(InitializationState.READY, InitializationState.ERROR)) { return; } + debug("Feature Flagging provider configuration unavailable; evaluations return defaults"); emit( ProviderEvent.PROVIDER_ERROR, ProviderEventDetails.builder() @@ -273,6 +312,8 @@ public void shutdown() { if (evaluator != null) { evaluator.shutdown(); } + // Logged last: a diagnostic must not get in the way of the cleanup. + log.debug("Feature Flagging provider shut down"); } // Visible for tests: expose whether span enrichment is wired (gate-on) without leaking the impl. diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java index 4466e1a53c6..b06f5386133 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/FeatureFlagsRuntime.java @@ -100,6 +100,7 @@ static FeatureFlagsRuntime create(final AgentBridge bridge, final Settings setti throw new IllegalStateException("Feature Flags is disabled"); } final boolean remoteConfig = CONFIGURATION_SOURCE_REMOTE_CONFIG.equals(sourceName); + LOGGER.debug("Feature Flags runtime starting: configurationSource={}", sourceName); final RuntimeServices services = new RuntimeServices(bridge.healthMetrics()); final ConfigurationService source; final EventTransport transport; @@ -150,6 +151,11 @@ static FeatureFlagsRuntime create(final AgentBridge bridge, final Settings setti context, services) : null; + LOGGER.debug( + "Feature Flags event delivery: transport={}, exposures={}, evaluationCounts={}", + transport.getClass().getSimpleName(), + exposures != null ? "enabled" : "disabled", + evaluations != null ? "enabled" : "disabled"); return new FeatureFlagsRuntime(source, exposures, evaluations); } @@ -256,6 +262,8 @@ void stop() { if (this.exposures != null) { this.exposures.awaitTermination(deadline); } + // Logged last: a diagnostic must not get in the way of the cleanup. + LOGGER.debug("Feature Flags runtime stopped"); } private void onConfiguration(@Nullable final ServerConfiguration configuration) { @@ -272,6 +280,17 @@ private void onConfiguration(@Nullable final ServerConfiguration configuration) LOGGER.warn("Feature Flags configuration listener failed", e); } } + // Logged last: a diagnostic must not keep the configuration from being applied. + if (configuration == null) { + LOGGER.debug("Feature Flags configuration removed"); + } else if (LOGGER.isDebugEnabled()) { + LOGGER.debug( + "Feature Flags configuration applied: flags={}, invalidFlags={}, configurationEnv={}, createdAt={}", + configuration.flags == null ? "missing" : configuration.flags.size(), + configuration.invalidFlags == null ? 0 : configuration.invalidFlags.size(), + configuration.environment == null ? null : configuration.environment.name, + configuration.createdAt); + } } /** diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java index b9dc8a63c0d..aa43f1ab4cf 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/config/Settings.java @@ -38,6 +38,7 @@ public final class Settings { "feature.flags.exposures.datadog.logging.enabled"; static final String EVALUATION_TELEMETRY_PER_SUBJECT_OPT_OUT_ENABLED = "feature.flags.evaluation.telemetry.per.subject.opt.out.enabled"; + static final String TRACE_DEBUG = "trace.debug"; static final String API_KEY = "api-key"; static final String SITE = "site"; static final String ENV = "env"; @@ -174,6 +175,16 @@ public boolean isEvaluationTelemetryPerSubjectOptOutEnabled() { return Boolean.TRUE.equals(getBoolean(EVALUATION_TELEMETRY_PER_SUBJECT_OPT_OUT_ENABLED)); } + /** + * Tells whether {@code DD_TRACE_DEBUG} is on. It gates the diagnostics that log raw evaluation + * contexts and values, on top of the application's logging configuration. + * + * @return whether trace debug is enabled (off by default). + */ + public boolean isTraceDebug() { + return Boolean.TRUE.equals(getBoolean(TRACE_DEBUG)); + } + /** * @return the Datadog API key, or {@code null} if not set. */ diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSource.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSource.java index cfb38849cab..7eed50bb8d2 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSource.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/CdnConfigurationSource.java @@ -122,6 +122,16 @@ public void start(final Consumer listener) { TimeUnit.MILLISECONDS); } } + // Logged last: a diagnostic must not keep the polling from starting. + LOGGER.debug( + "Feature Flags agentless configuration source started: endpoint={}://{}{}, pollIntervalMillis={}, requestTimeout={}", + // The origin only: the userinfo, the path and the query of a custom endpoint can carry a + // credential. + this.endpoint.getScheme(), + this.endpoint.getHost(), + this.endpoint.getPort() < 0 ? "" : ":" + this.endpoint.getPort(), + this.pollIntervalMillis, + this.requestTimeout); } @Override @@ -146,6 +156,8 @@ public void close() { poll.cancel(true); } this.executor.shutdownNow(); + // Logged last: a diagnostic must not keep the source from being closed. + LOGGER.debug("Feature Flags agentless configuration source stopped"); } /** Test seam: sets the configuration listener without starting the polling. */ @@ -212,6 +224,7 @@ private boolean fetchAndApply() { private boolean apply(final HttpResponse response) { final int status = response.statusCode(); if (status == HttpURLConnection.HTTP_NOT_MODIFIED) { + LOGGER.debug("Feature Flags agentless configuration unchanged (HTTP 304)"); return true; } if (status == HttpURLConnection.HTTP_UNAUTHORIZED @@ -229,15 +242,21 @@ private boolean apply(final HttpResponse response) { try { configuration = UniversalFlagConfigParser.parseJsonApi(Gzip.decode(response)); } catch (final IOException | RuntimeException e) { - LOGGER.debug("Feature Flags configuration endpoint returned a malformed payload", e); + LOGGER.debug( + "Feature Flags configuration endpoint returned a malformed payload ({}); retaining the current configuration", + e.getClass().getSimpleName(), + e); return false; } if (configuration == null) { + LOGGER.debug( + "Feature Flags configuration endpoint returned no flag data; retaining the current configuration"); return false; } this.listener.accept(configuration); final String nextEtag = response.headers().firstValue("ETag").orElse(null); this.etag = nextEtag == null || nextEtag.trim().isEmpty() ? null : nextEtag; + LOGGER.debug("Feature Flags agentless configuration applied: etag={}", this.etag); return true; } @@ -254,10 +273,20 @@ private HttpResponse fetchWithRetries() throws IOException, InterruptedE return this.activeCall; } }, - (response, attempt) -> - response == null || isRetryableStatus(response.statusCode()) - ? retryDelayMillis(this.pollIntervalMillis, attempt, this.jitter.getAsDouble()) - : -1, + (response, attempt) -> { + if (response != null && !isRetryableStatus(response.statusCode())) { + return -1; + } + final long delayMillis = + retryDelayMillis(this.pollIntervalMillis, attempt, this.jitter.getAsDouble()); + LOGGER.debug( + "Feature Flags agentless configuration poll attempt {}/{} {}; retrying in {} ms", + attempt, + MAX_ATTEMPTS, + response == null ? "failed" : "returned HTTP " + response.statusCode(), + delayMillis); + return delayMillis; + }, MAX_ATTEMPTS, this.retrySleeper, () -> this.closed); diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/RemoteConfigurationService.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/RemoteConfigurationService.java index 63cae4527cb..f0179ab3b81 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/RemoteConfigurationService.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/delivery/RemoteConfigurationService.java @@ -25,6 +25,7 @@ public synchronized void start(final Consumer listener) { if (this.subscription != null) { return; } + LOGGER.debug("Feature Flags subscribing to Remote Configuration"); this.subscription = this.source.subscribe( content -> { @@ -36,7 +37,10 @@ public synchronized void start(final Consumer listener) { try { configuration = UniversalFlagConfigParser.parse(content); } catch (final IOException | RuntimeException e) { - LOGGER.debug("Remote Configuration returned a malformed flag configuration", e); + LOGGER.debug( + "Remote Configuration returned a malformed flag configuration ({}); retaining the current configuration", + e.getClass().getSimpleName(), + e); return; } if (configuration != null) { diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposurePipeline.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposurePipeline.java index fb0acac0989..829d74f77d7 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposurePipeline.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/exposure/ExposurePipeline.java @@ -11,6 +11,7 @@ import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; import java.util.function.Consumer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -29,6 +30,7 @@ public class ExposurePipeline implements Consumer, EventPipeline private final Thread serializerThread; private final Object lifecycleLock = new Object(); private final AtomicBoolean closed = new AtomicBoolean(); + private final AtomicLong droppedQueueFull = new AtomicLong(); public ExposurePipeline( final int capacity, @@ -39,7 +41,8 @@ public ExposurePipeline( final RuntimeServices services) { this.queue = new EventQueue<>(capacity); this.serializer = - new ExposureSerializingHandler(transport, this.queue, flushInterval, timeUnit, context); + new ExposureSerializingHandler( + transport, this.queue, flushInterval, timeUnit, context, this.droppedQueueFull); this.serializerThread = services.newThread("exposures", this.serializer); } @@ -56,7 +59,12 @@ public void start() { public void requestShutdown() { synchronized (this.lifecycleLock) { if (this.closed.compareAndSet(false, true)) { + final int queued = this.queue.size(); this.serializer.requestShutdown(); + // Logged last: a diagnostic must not keep the worker from being signaled. + LOGGER.debug( + "Feature Flags exposure shutdown requested: queued={}; pending exposures get a final best-effort flush", + queued); } } } @@ -68,8 +76,9 @@ public void awaitTermination(final long deadlineNanos) { @Override public void accept(final ExposureEvent event) { - if (!this.closed.get()) { - this.queue.offer(event); + // Runs on the application thread: count the drops, the serializer reports them once per flush. + if (!this.closed.get() && !this.queue.offer(event)) { + this.droppedQueueFull.incrementAndGet(); } } @@ -91,13 +100,16 @@ private static class ExposureSerializingHandler implements Runnable { private final List buffer = new ArrayList<>(); private final AtomicBoolean shutdownRequested = new AtomicBoolean(); + private final AtomicLong droppedQueueFull; ExposureSerializingHandler( final EventTransport transport, final EventQueue queue, final long flushInterval, final TimeUnit timeUnit, - final Map context) { + final Map context, + final AtomicLong droppedQueueFull) { + this.droppedQueueFull = droppedQueueFull; this.queue = queue; this.transport = transport; this.context = context; @@ -163,9 +175,16 @@ protected void flushIfNecessary() { } private void flush() { + final long dropped = this.droppedQueueFull.getAndSet(0); + if (dropped > 0) { + LOGGER.debug( + "Feature Flags exposures dropped since the last flush: queue full, count={}", dropped); + } if (this.buffer.isEmpty()) { return; } + final int count = this.buffer.size(); + LOGGER.debug("Feature Flags exposure flush starting: count={}", count); final byte[] payload; try { final ExposuresRequest exposures = new ExposuresRequest(this.context, this.buffer); @@ -177,8 +196,13 @@ private void flush() { } try { this.transport.post(EXPOSURES_ROUTE, payload); + LOGGER.debug("Feature Flags exposure flush delivered: count={}", count); } catch (Exception e) { - LOGGER.debug("Could not submit exposures", e); + LOGGER.debug( + "Feature Flags exposure flush failed: {}; dropping the batch of {} exposure(s)", + e.toString(), + count, + e); } finally { // Best-effort delivery must not retry an ambiguously accepted batch. this.buffer.clear(); diff --git a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipeline.java b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipeline.java index 915ce31f61b..af4dfaa2ca3 100644 --- a/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipeline.java +++ b/dd-openfeature/src/main/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipeline.java @@ -130,6 +130,8 @@ public void requestShutdown() { if (closed.compareAndSet(false, true)) { // The worker polls every 100 ms: do not interrupt a current or final HTTP request. serializer.requestShutdown(); + // Logged last: a diagnostic must not keep the worker from being signaled. + LOGGER.debug("Feature Flags flag evaluation shutdown requested"); } } } @@ -220,6 +222,10 @@ FlagEvalEvent pollQueuedEventForTest() { return queue.poll(); } + boolean isSerializerThreadAlive() { + return serializerThread.isAlive(); + } + /** Test seam: flushes serializer state without starting the worker. */ void flushForTest() { serializer.flush(); @@ -407,6 +413,10 @@ void flush() { for (final byte[] payload : payloads.bodies) { transport.post(FLAG_EVALUATION_ROUTE, payload); } + if (!payloads.bodies.isEmpty()) { + LOGGER.debug( + "Feature Flags flag evaluation flush delivered: payloads={}", payloads.bodies.size()); + } } catch (Exception e) { LOGGER.error("Could not submit flag evaluations", e); } finally { diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/DebugLoggingTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/DebugLoggingTest.java new file mode 100644 index 00000000000..e09e14df637 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/DebugLoggingTest.java @@ -0,0 +1,626 @@ +package com.datadog.openfeature; + +import static java.util.Collections.emptyList; +import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonList; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.datadog.openfeature.Provider.Options; +import com.datadog.openfeature.internal.ufc.Allocation; +import com.datadog.openfeature.internal.ufc.Flag; +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import com.datadog.openfeature.internal.ufc.Split; +import com.datadog.openfeature.internal.ufc.ValueType; +import com.datadog.openfeature.internal.ufc.Variant; +import com.datadog.openfeature.testlog.CapturingServiceProvider; +import com.datadog.openfeature.testlog.CapturingServiceProvider.Event; +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.FlagValueType; +import dev.openfeature.sdk.HookContext; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.MutableContext; +import dev.openfeature.sdk.MutableStructure; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.Structure; +import dev.openfeature.sdk.Value; +import java.time.Duration; +import java.util.AbstractList; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.event.Level; + +class DebugLoggingTest { + private static final String TRACE_DEBUG = "dd.trace.debug"; + private static final String AGENTLESS_BASE_URL = + "dd.feature.flags.configuration.source.agentless.base.url"; + + private static final String SDK_LOGGERS = "com.datadog.openfeature"; + + @BeforeEach + void captureLogs() { + CapturingServiceProvider.reset(); + CapturingServiceProvider.enable(SDK_LOGGERS, Level.DEBUG); + } + + @AfterEach + void restore() { + System.clearProperty(TRACE_DEBUG); + System.clearProperty(AGENTLESS_BASE_URL); + OpenFeatureAPI.getInstance().clearHooks(); + OpenFeatureAPI.getInstance().shutdown(); + CapturingServiceProvider.reset(); + } + + @Test + void logsTheRawEvaluationWhenTraceDebugIsOn() throws Exception { + System.setProperty(TRACE_DEBUG, "true"); + final Client client = register(); + + client.getIntegerValue("flag", 5, new MutableContext("user-1").add("plan", "pro")); + + final String message = singleMessage("Feature Flagging evaluation:"); + assertTrue(message.contains("flag=flag"), message); + assertTrue(message.contains("targetingKey=user-1"), message); + assertTrue(message.contains("plan=pro"), message); + assertTrue(message.contains("defaultValue=5"), message); + assertTrue(message.contains("value=1"), message); + assertTrue(message.contains("variant=on"), message); + assertTrue(message.contains("reason=STATIC"), message); + assertEquals( + Level.DEBUG, + CapturingServiceProvider.events().stream() + .filter(e -> e.message.equals(message)) + .findFirst() + .get() + .level); + } + + @Test + void logsTheErrorOfAnEvaluation() throws Exception { + System.setProperty(TRACE_DEBUG, "true"); + final Client client = register(); + + client.getIntegerValue("missing", 5, new MutableContext("user-1")); + + final String message = singleMessage("Feature Flagging evaluation:"); + assertTrue(message.contains("flag=missing"), message); + assertTrue(message.contains("errorCode=FLAG_NOT_FOUND"), message); + } + + @Test + void doesNotLogRawEvaluationsWithoutTraceDebug() throws Exception { + // Explicit, so that a DD_TRACE_DEBUG in the environment of the test JVM does not matter. + System.setProperty(TRACE_DEBUG, "false"); + final Client client = register(); + + client.getIntegerValue("flag", 5, new MutableContext("user-1")); + + assertTrue(messages("Feature Flagging evaluation:").isEmpty()); + } + + @Test + void warnsWhenTraceDebugIsOnButTheLoggerDoesNotAllowDebug() throws Exception { + System.setProperty(TRACE_DEBUG, "true"); + CapturingServiceProvider.enable(DebugLoggingHook.class.getName(), Level.INFO); + final Client client = register(); + + client.getIntegerValue("flag", 5, new MutableContext("user-1")); + + assertTrue(messages("Feature Flagging evaluation:").isEmpty()); + final List warnings = + CapturingServiceProvider.events().stream() + .filter(e -> e.level == Level.WARN) + .filter(e -> e.message.contains("DD_TRACE_DEBUG is enabled")) + .collect(Collectors.toList()); + assertEquals(1, warnings.size()); + } + + @Test + void stopsLoggingRawEvaluationsWhenTheLoggerIsLoweredAfterRegistration() throws Exception { + System.setProperty(TRACE_DEBUG, "true"); + final Client client = register(); + client.getIntegerValue("flag", 5, new MutableContext("user-1")); + assertEquals(1, messages("Feature Flagging evaluation:").size()); + + CapturingServiceProvider.enable(DebugLoggingHook.class.getName(), Level.INFO); + client.getIntegerValue("flag", 5, new MutableContext("user-2")); + + assertEquals(1, messages("Feature Flagging evaluation:").size()); + } + + @Test + void announcesThatRawEvaluationsAreLoggedWhenTheHookIsRegistered() throws Exception { + System.setProperty(TRACE_DEBUG, "true"); + register(); + + final List announcements = + CapturingServiceProvider.events().stream() + .filter(e -> e.message.contains("personal data")) + .collect(Collectors.toList()); + assertEquals(1, announcements.size(), announcements.toString()); + assertEquals(Level.INFO, announcements.get(0).level); + } + + @Test + void announcesThroughTheLoggerOfTheHookAlone() throws Exception { + System.setProperty(TRACE_DEBUG, "true"); + // Only the category named by the provider warning is enabled: the provider logger is not. + CapturingServiceProvider.enable(SDK_LOGGERS, Level.WARN); + CapturingServiceProvider.enable(DebugLoggingHook.class.getName(), Level.DEBUG); + register(); + + final List announcements = + CapturingServiceProvider.events().stream() + .filter(e -> e.message.contains("personal data")) + .collect(Collectors.toList()); + assertEquals(1, announcements.size(), announcements.toString()); + assertEquals(DebugLoggingHook.class.getName(), announcements.get(0).logger); + assertEquals(Level.INFO, announcements.get(0).level); + } + + @Test + void doesNotAnnounceRawLoggingWithoutTraceDebug() throws Exception { + System.setProperty(TRACE_DEBUG, "false"); + register(); + + assertTrue(messages("DD_TRACE_DEBUG is enabled").isEmpty()); + } + + @Test + void containsAFailureToFormatTheEvaluation() { + final EvaluationContext failing = mock(EvaluationContext.class); + when(failing.asUnmodifiableMap()).thenThrow(new IllegalStateException("cannot render")); + final HookContext hookContext = + HookContext.builder() + .flagKey("flag") + .type(FlagValueType.INTEGER) + .defaultValue(0) + .ctx(failing) + .build(); + final FlagEvaluationDetails details = + FlagEvaluationDetails.builder().flagKey("flag").value(1).build(); + + assertDoesNotThrow( + () -> new DebugLoggingHook<>().finallyAfter(hookContext, details, emptyMap())); + + assertEquals( + 1, + messages("Feature Flagging evaluation diagnostic unavailable: java.lang.IllegalState") + .size()); + } + + @Test + void doesNotExplainExposureSuppressionsAtDebug() throws Exception { + final Client client = register(); + + client.getIntegerValue("missing", 5, new MutableContext("user-1")); + client.getIntegerValue("flag", 5, new MutableContext("user-1")); + client.getIntegerValue("flag", 5, new MutableContext("user-1")); + + assertTrue(messages("Feature Flagging exposure suppressed").isEmpty()); + } + + @Test + void capsTheLengthOfALoggedContextValue() throws Exception { + System.setProperty(TRACE_DEBUG, "true"); + final Client client = register(); + final String blob = "x".repeat(5_000); + + client.getIntegerValue("flag", 5, new MutableContext("user-1").add("blob", blob)); + + final String message = singleMessage("Feature Flagging evaluation:"); + assertTrue(message.contains("...(truncated)"), message); + assertFalse(message.contains(blob), message); + assertTrue(message.length() < 3_000, "Logged " + message.length() + " characters"); + } + + @Test + void rendersNestedValuesAsTheyAre() { + final Value nested = + new Value( + Arrays.asList( + new Value("a"), new Value(new MutableStructure().add("k", "v")), new Value(2))); + + final String message = hookMessage(hookContext(new MutableContext("u"), nested), details()); + + assertTrue(message.contains("defaultValue=[a, {k=v}, 2]"), message); + assertTrue(message.contains("context={targetingKey=u}"), message); + } + + @Test + void doesNotFollowAValueThatContainsItself() { + final List list = new ArrayList<>(); + final Value cyclic = new Value(list); + list.add(cyclic); + final Map attributes = new HashMap<>(); + attributes.put("loop", cyclic); + + final String message = + hookMessage(hookContext(new MutableContext("u", attributes), new Value(0)), details()); + + assertTrue(message.contains("loop=[(cycle)]"), message); + } + + @Test + @SuppressWarnings({"unchecked", "rawtypes"}) + void doesNotFollowAListThatContainsNullAndItself() { + final List list = new ArrayList<>(); + final Value cyclic = new Value((List) list); + list.add(null); + list.add(cyclic); + + final String message = hookMessage(hookContext(new MutableContext("u"), cyclic), details()); + + assertTrue(message.contains("defaultValue=[null, (cycle)]"), message); + } + + @Test + void stopsWalkingAListOnceTheBudgetIsSpent() { + final int[] reads = {0}; + final List wide = + new AbstractList() { + @Override + public String get(final int index) { + reads[0]++; + return "element-" + index; + } + + @Override + public int size() { + return 1_000_000; + } + }; + + final String message = hookMessage(hookContext(new MutableContext("u"), wide), details()); + + assertTrue(message.contains("...(truncated)"), message); + assertTrue(reads[0] < 1_000, "Read " + reads[0] + " elements"); + } + + @Test + void doesNotScanAWrappedListBeforeRenderingIt() { + final int[] reads = {0}; + final List wide = + new AbstractList() { + @Override + public Value get(final int index) { + reads[0]++; + return new Value("element-" + index); + } + + @Override + public int size() { + return 1_000_000; + } + }; + + final String message = + hookMessage(hookContext(new MutableContext("u"), new Value(wide)), details()); + + assertTrue(message.contains("...(truncated)"), message); + assertTrue(reads[0] < 1_000, "Read " + reads[0] + " elements"); + } + + @Test + void returnsFromAnInfiniteIterable() { + final Iterable infinite = + () -> + new Iterator() { + @Override + public boolean hasNext() { + return true; + } + + @Override + public String next() { + return "x"; + } + }; + + final String message = + assertTimeoutPreemptively( + Duration.ofSeconds(10), + () -> hookMessage(hookContext(new MutableContext("u"), infinite), details())); + + assertTrue(message.contains("...(truncated)"), message); + } + + @Test + void doesNotRenderAMapKeyOutsideTheGuards() { + final List first = new ArrayList<>(); + final List second = new ArrayList<>(); + first.add(second); + second.add(first); + final Map map = new IdentityHashMap<>(); + map.put(first, "v"); + + final String message = hookMessage(hookContext(new MutableContext("u"), map), details()); + + assertTrue(message.contains("defaultValue={[[(cycle)]]=v}"), message); + } + + @Test + void rendersANullStructureKey() { + final Value structure = new Value(new MutableStructure().add((String) null, "v")); + + final String message = hookMessage(hookContext(new MutableContext("u"), structure), details()); + + assertTrue(message.contains("defaultValue={null=v}"), message); + } + + @Test + void doesNotOverflowTheStackOnAVeryDeepValue() { + Value deep = new Value("leaf"); + for (int i = 0; i < 5_000; i++) { + deep = new Value(Collections.singletonList(deep)); + } + final Map attributes = new HashMap<>(); + attributes.put("deep", deep); + + final String message = + hookMessage(hookContext(new MutableContext("u", attributes), new Value(0)), details()); + + assertTrue(message.contains("deep=[[[[[[[[...]]]]]]]]"), message); + assertFalse(message.contains("leaf"), message); + assertTrue(message.length() < 2_000, "Logged " + message.length() + " characters"); + } + + @Test + void rendersAWideContextWithinTheBudget() { + final Map attributes = new HashMap<>(); + for (int i = 0; i < 20_000; i++) { + attributes.put("attribute-" + i, new Value("value-" + i)); + } + + final String message = + hookMessage(hookContext(new MutableContext("u", attributes), new Value(0)), details()); + + assertTrue(message.contains("...(truncated)"), message); + assertTrue(message.length() < 2_000, "Logged " + message.length() + " characters"); + } + + @Test + void rendersAWideImmutableContextWithinTheBudget() { + final Map attributes = new HashMap<>(); + for (int i = 0; i < 20_000; i++) { + attributes.put("attribute-" + i, new Value("value-" + i)); + } + + final String message = + hookMessage(hookContext(new ImmutableContext("u", attributes), new Value(0)), details()); + + assertTrue(message.contains("...(truncated)"), message); + assertTrue(message.length() < 2_000, "Logged " + message.length() + " characters"); + } + + @Test + void rendersAStructureWithoutCopyingItsKeysOrValues() { + final Map attributes = new HashMap<>(); + attributes.put("plan", new Value("pro")); + final Structure structure = mock(Structure.class); + when(structure.asUnmodifiableMap()).thenReturn(Collections.unmodifiableMap(attributes)); + when(structure.keySet()).thenThrow(new AssertionError("keySet() copies the keys")); + when(structure.getValue("plan")).thenThrow(new AssertionError("getValue() clones the value")); + + final String message = + hookMessage(hookContext(new MutableContext("u"), new Value(structure)), details()); + + assertTrue(message.contains("defaultValue={plan=pro}"), message); + } + + @Test + void capsTheVariantAndTheReason() { + final FlagEvaluationDetails details = + FlagEvaluationDetails.builder() + .flagKey("flag") + .value(1) + .variant("v".repeat(5_000)) + .reason("r".repeat(5_000)) + .build(); + + final String message = hookMessage(hookContext(new MutableContext("u"), new Value(0)), details); + + assertTrue( + message.contains("variant=" + "v".repeat(1_024) + "...(truncated, 5000 characters), "), + message); + assertTrue( + message.contains("reason=" + "r".repeat(1_024) + "...(truncated, 5000 characters), "), + message); + } + + @Test + void capsTheFlagKey() throws Exception { + System.setProperty(TRACE_DEBUG, "true"); + final Client client = register(); + + client.getIntegerValue("k".repeat(5_000), 5, new MutableContext("user-1")); + + final String message = singleMessage("Feature Flagging evaluation:"); + assertTrue( + message.startsWith( + "Feature Flagging evaluation: flag=" + + "k".repeat(1_024) + + "...(truncated, 5000 characters), context="), + message.substring(0, Math.min(message.length(), 1_200))); + } + + @Test + void doesNotCutASurrogatePairAtTheLimit() throws Exception { + System.setProperty(TRACE_DEBUG, "true"); + final Client client = register(); + // The emoji is a surrogate pair whose first half is the last character below the limit. + final String key = "k".repeat(1_023) + "\uD83D\uDE00" + "k".repeat(100); + + client.getIntegerValue(key, 5, new MutableContext("user-1")); + + final String message = singleMessage("Feature Flagging evaluation:"); + assertTrue( + message.startsWith( + "Feature Flagging evaluation: flag=" + + "k".repeat(1_023) + + "...(truncated, 1125 characters), context="), + message.substring(0, Math.min(message.length(), 1_200))); + assertFalse(message.contains("\uD83D"), "A lone surrogate was logged"); + } + + @Test + void doesNotLogTheCredentialsOfAnInvalidEndpoint() { + System.setProperty(AGENTLESS_BASE_URL, "ftp://user:pw-secret@example.com/ufc?token=tok-secret"); + + // The provider cannot initialize: the endpoint is invalid. + assertThrows( + Exception.class, + () -> + OpenFeatureAPI.getInstance() + .setProviderAndWait(new Provider(new Options().initTimeout(1, SECONDS)))); + + assertEquals(1, messages("Feature Flagging provider initialization failed: java.lang.").size()); + for (final Event event : CapturingServiceProvider.events()) { + assertFalse(event.message.contains("pw-secret"), event.toString()); + assertFalse(event.message.contains("tok-secret"), event.toString()); + } + } + + @Test + void logsTheErrorMessageTheApplicationObserves() throws Exception { + System.setProperty(TRACE_DEBUG, "true"); + final Client client = register(); + + // The configuration does not observe full evaluation data, so the provider drops the message. + client.getStringValue("flag", "default", new MutableContext("user-1")); + + final String message = singleMessage("Feature Flagging evaluation:"); + assertTrue(message.contains("errorCode=TYPE_MISMATCH"), message); + assertTrue(message.contains("errorMessage=null"), message); + } + + @Test + void logsTheProviderLifecycle() throws Exception { + register(); + OpenFeatureAPI.getInstance().shutdown(); + + assertEquals(1, messages("Feature Flagging provider initializing").size()); + assertFalse(messages("Feature Flagging provider ready").isEmpty()); + assertFalse(messages("Feature Flagging provider shut down").isEmpty()); + } + + @Test + void initializesWhenTheReadyLogFails() throws Exception { + CapturingServiceProvider.failOn("Feature Flagging provider ready"); + + final Client client = register(); + + // The registration did not fail, and the provider evaluates. + assertEquals(1, client.getIntegerValue("flag", 5, new MutableContext("user-1"))); + } + + @Test + void shuttingDownDoesNotDependOnTheLoggingBackend() { + final boolean[] evaluatorShutDown = {false}; + final ExposureTestEvaluator evaluator = + new ExposureTestEvaluator(configuration(), true) { + @Override + public synchronized void shutdown() { + super.shutdown(); + evaluatorShutDown[0] = true; + } + }; + final Provider provider = new Provider(new Options(), evaluator); + CapturingServiceProvider.failOn("provider shut down"); + + assertThrows(IllegalStateException.class, provider::shutdown); + + assertTrue(evaluatorShutDown[0], "The cleanup must not wait for the log"); + } + + @Test + void explainsWhyAnExposureIsSuppressed() throws Exception { + CapturingServiceProvider.enable(SDK_LOGGERS, Level.TRACE); + final Client client = register(); + + client.getIntegerValue("missing", 5, new MutableContext("user-1")); + client.getIntegerValue("flag", 5, new MutableContext("user-1")); + client.getIntegerValue("flag", 5, new MutableContext("user-1")); + + assertEquals( + 1, + messages("Feature Flagging exposure suppressed: evaluation failed or details unavailable") + .size()); + assertEquals( + 1, messages("Feature Flagging exposure suppressed: exposure already recorded").size()); + } + + private static HookContext hookContext( + final EvaluationContext context, final Object defaultValue) { + return HookContext.builder() + .flagKey("flag") + .type(FlagValueType.OBJECT) + .defaultValue(defaultValue) + .ctx(context) + .build(); + } + + private static FlagEvaluationDetails details() { + return FlagEvaluationDetails.builder().flagKey("flag").value(1).build(); + } + + private String hookMessage( + final HookContext hookContext, final FlagEvaluationDetails details) { + new DebugLoggingHook<>().finallyAfter(hookContext, details, emptyMap()); + return singleMessage("Feature Flagging evaluation:"); + } + + private Client register() throws Exception { + final OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + api.setProviderAndWait( + new Provider( + new Options().initTimeout(10, SECONDS), + new ExposureTestEvaluator(configuration(), true))); + return api.getClient(); + } + + private String singleMessage(final String prefix) { + final List messages = messages(prefix); + assertEquals(1, messages.size(), messages.toString()); + return messages.get(0); + } + + private List messages(final String prefix) { + return CapturingServiceProvider.events().stream() + .map(e -> e.message) + .filter(message -> message.startsWith(prefix)) + .collect(Collectors.toList()); + } + + private static ServerConfiguration configuration() { + final Map variations = new HashMap<>(); + variations.put("on", new Variant("on", 1)); + final Split split = new Split(emptyList(), "on", emptyMap(), 7); + final Allocation allocation = + new Allocation("allocation", null, null, null, singletonList(split), Boolean.TRUE); + final Map flags = new HashMap<>(); + flags.put( + "flag", new Flag("flag", true, ValueType.INTEGER, variations, singletonList(allocation))); + return new ServerConfiguration("", "", false, null, flags); + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java index 1104b34f8fc..f6924b3b4a9 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/FeatureFlagsRuntimeTest.java @@ -23,6 +23,7 @@ import com.datadog.openfeature.internal.config.TestSettings; import com.datadog.openfeature.internal.flagevaluation.FlagEvalEvent; import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import com.datadog.openfeature.testlog.CapturingServiceProvider; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; @@ -33,7 +34,10 @@ import java.util.concurrent.Future; import java.util.concurrent.TimeoutException; import java.util.function.Consumer; +import java.util.stream.Collectors; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import org.slf4j.event.Level; class FeatureFlagsRuntimeTest { private static final byte[] EMPTY_UFC = @@ -143,6 +147,81 @@ void remoteConfigurationDeliversParsedConfigurationsAndReplaysToLateListeners() assertTrue(source.closed); } + @AfterEach + void resetCapturedLogs() { + CapturingServiceProvider.reset(); + } + + @Test + void stoppingDoesNotDependOnTheLoggingBackend() { + CapturingServiceProvider.enable("com.datadog.openfeature", Level.DEBUG); + CapturingServiceProvider.failOn("Feature Flags runtime stopped"); + final TestConfigurationSource source = new TestConfigurationSource(); + final FeatureFlagsRuntime runtime = + FeatureFlagsRuntime.create( + new TestAgentBridge(source, null), + TestSettings.of("feature.flags.configuration.source", "remote_config")); + runtime.start(); + + assertThrows(IllegalStateException.class, runtime::stop); + + assertTrue(source.closed); + } + + @Test + void appliesConfigurationsWhenTheirLogFails() { + CapturingServiceProvider.enable("com.datadog.openfeature", Level.DEBUG); + CapturingServiceProvider.failOn("Feature Flags configuration"); + final TestConfigurationSource source = new TestConfigurationSource(); + final FeatureFlagsRuntime runtime = + FeatureFlagsRuntime.create( + new TestAgentBridge(source, null), + TestSettings.of("feature.flags.configuration.source", "remote_config")); + final List received = new CopyOnWriteArrayList<>(); + runtime.addConfigurationListener(received::add); + runtime.start(); + try { + assertThrows(IllegalStateException.class, () -> source.listener.accept(EMPTY_UFC)); + assertEquals(1, received.size(), "The listener must not wait for the log"); + assertThrows(IllegalStateException.class, () -> source.listener.accept(null)); + assertEquals(2, received.size(), "The removal must not wait for the log"); + assertNull(received.get(1)); + + final List late = new CopyOnWriteArrayList<>(); + runtime.addConfigurationListener(late::add); + assertTrue(late.isEmpty(), "The removed configuration is still the current one"); + } finally { + CapturingServiceProvider.reset(); + runtime.stop(); + } + } + + @Test + void reportsAppliedAndRemovedConfigurations() { + CapturingServiceProvider.enable("com.datadog.openfeature", Level.DEBUG); + final TestConfigurationSource source = new TestConfigurationSource(); + final FeatureFlagsRuntime runtime = + FeatureFlagsRuntime.create( + new TestAgentBridge(source, null), + TestSettings.of("feature.flags.configuration.source", "remote_config")); + runtime.start(); + try { + source.listener.accept(EMPTY_UFC); + source.listener.accept(null); + } finally { + runtime.stop(); + } + + final List messages = + CapturingServiceProvider.events().stream().map(e -> e.message).collect(Collectors.toList()); + assertTrue( + messages.contains( + "Feature Flags configuration applied: flags=0, invalidFlags=0, configurationEnv=Test, createdAt=null"), + messages.toString()); + assertTrue(messages.contains("Feature Flags configuration removed"), messages.toString()); + assertTrue(messages.contains("Feature Flags runtime stopped"), messages.toString()); + } + @Test void concurrentRemovalIsDeliveredAfterTheInitialReplay() throws Exception { final TestConfigurationSource source = new TestConfigurationSource(); diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/SettingsTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/SettingsTest.java index 9e6675a1edc..a2476b5bae7 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/SettingsTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/config/SettingsTest.java @@ -192,6 +192,25 @@ void readsToggles() { assertFalse(settings.isEvaluationCountsEnabled()); } + @Test + void readsTraceDebugFromTheBridgeThenSystemPropertiesThenEnvironment() { + final Map bridge = new HashMap<>(); + final Map properties = new HashMap<>(); + final Map environment = new HashMap<>(); + final Settings settings = + new Settings(new SettingAgentBridge(bridge), properties::get, environment::get); + assertFalse(settings.isTraceDebug()); + + environment.put("DD_TRACE_DEBUG", "1"); + assertTrue(settings.isTraceDebug()); + + properties.put("dd.trace.debug", "false"); + assertFalse(settings.isTraceDebug()); + + bridge.put("trace.debug", "TRUE"); + assertTrue(settings.isTraceDebug()); + } + private static void putIfNotNull( final Map map, final String key, final String value) { if (value != null) { diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/DeliveryDiagnosticsTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/DeliveryDiagnosticsTest.java new file mode 100644 index 00000000000..d8e8e46abf3 --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/delivery/DeliveryDiagnosticsTest.java @@ -0,0 +1,237 @@ +package com.datadog.openfeature.internal.delivery; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import com.datadog.openfeature.internal.LocalHttpServer; +import com.datadog.openfeature.internal.bridge.v1.ConfigurationSource; +import com.datadog.openfeature.internal.ufc.ServerConfiguration; +import com.datadog.openfeature.testlog.CapturingServiceProvider; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ScheduledExecutorService; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.event.Level; + +class DeliveryDiagnosticsTest { + private static final String CONFIG = + "{\"data\":{\"type\":\"universal-flag-configuration\",\"attributes\":" + + "{\"createdAt\":\"2024-04-17T19:40:53.716Z\",\"environment\":{\"name\":\"Test\"}," + + "\"flags\":{}}}}"; + + private LocalHttpServer server; + + @BeforeEach + void setUp() throws IOException { + this.server = new LocalHttpServer(); + CapturingServiceProvider.reset(); + CapturingServiceProvider.enable("com.datadog.openfeature", Level.DEBUG); + } + + @AfterEach + void tearDown() { + CapturingServiceProvider.reset(); + this.server.close(); + } + + @Test + void agentlessReportsAppliedAndUnchangedConfigurations() { + this.server.enqueue(200, CONFIG, "ETag", "\"v1\"").enqueue(304, null); + final CdnConfigurationSource source = source(); + + assertTrue(source.pollOnce()); + assertTrue(source.pollOnce()); + + assertLogged("agentless configuration applied: etag=\"v1\""); + assertLogged("agentless configuration unchanged (HTTP 304)"); + // A successful poll reports its outcome once: a retry line is only for a retry. + assertTrue(messages("poll attempt").isEmpty(), messages("").toString()); + } + + @Test + void agentlessReportsAFailedAttemptWithoutItsStackTrace() { + final CdnConfigurationSource source = source(URI.create("http://127.0.0.1:1/ufc"), null); + + assertFalse(source.pollOnce()); + + final List attempts = + CapturingServiceProvider.events().stream() + .filter(e -> e.message.contains("poll attempt")) + .collect(Collectors.toList()); + assertEquals(2, attempts.size(), attempts.toString()); + for (final CapturingServiceProvider.Event attempt : attempts) { + assertNull(attempt.throwable, attempt.toString()); + } + } + + @Test + void agentlessReportsAMalformedPayloadAsRetainingTheConfiguration() { + this.server.enqueue(200, "{not json"); + + assertFalse(source().pollOnce()); + + assertLogged("malformed payload"); + assertLogged("retaining the current configuration"); + } + + @Test + void agentlessReportsRetriesAndTheirDelay() { + this.server.enqueue(503, null).enqueue(200, CONFIG); + + assertTrue(source().pollOnce()); + + assertLogged("poll attempt 1/3 returned HTTP 503; retrying in"); + } + + @Test + void agentlessReportsItsLifecycleWithoutQueryOrCredentials() { + final CdnConfigurationSource source = source("secret"); + this.server.enqueue(200, CONFIG); + + source.start(configuration -> {}); + source.close(); + + final String starting = logged("agentless configuration source started"); + assertTrue(starting.contains("pollIntervalMillis=30000"), starting); + assertFalse(starting.contains("/ufc"), starting); + assertFalse(starting.contains("secret"), starting); + assertFalse(starting.contains("token"), starting); + assertFalse(starting.contains("hidden"), starting); + assertLogged("agentless configuration source stopped"); + } + + @Test + void agentlessStartsPollingWhenItsLogFails() { + final ScheduledExecutorService executor = mock(ScheduledExecutorService.class); + final CdnConfigurationSource source = source(this.server.uri("/ufc"), null, executor); + this.server.enqueue(200, CONFIG); + CapturingServiceProvider.failOn("agentless configuration source started"); + + assertThrows(IllegalStateException.class, () -> source.start(configuration -> {})); + + assertEquals(1, this.server.requests().size(), "The first poll must not wait for the log"); + verify(executor) + .scheduleWithFixedDelay(any(Runnable.class), eq(30_000L), eq(30_000L), eq(MILLISECONDS)); + } + + @Test + void agentlessClosesWhenItsLogFails() { + final ScheduledExecutorService executor = mock(ScheduledExecutorService.class); + final CdnConfigurationSource source = source(this.server.uri("/ufc"), null, executor); + CapturingServiceProvider.failOn("agentless configuration source stopped"); + + assertThrows(IllegalStateException.class, source::close); + + verify(executor).shutdownNow(); + } + + @Test + void agentlessReportsTheOriginOfTheEndpointOnly() { + final URI server = this.server.uri("/ufc?token=hidden"); + final URI endpoint = + URI.create( + "http://user:s3cret@" + + server.getHost() + + ":" + + server.getPort() + + "/path-secret/ufc?token=hidden"); + this.server.enqueue(200, CONFIG); + final CdnConfigurationSource source = source(endpoint, null); + + source.start(configuration -> {}); + source.close(); + + final String starting = logged("agentless configuration source started"); + assertTrue(starting.contains("http://" + server.getHost() + ":" + server.getPort()), starting); + assertFalse(starting.contains("user"), starting); + assertFalse(starting.contains("s3cret"), starting); + assertFalse(starting.contains("path-secret"), starting); + assertFalse(starting.contains("hidden"), starting); + } + + @Test + void remoteConfigurationReportsItsSubscriptionAndMalformedDocuments() { + final CapturingSource connector = new CapturingSource(); + final RemoteConfigurationService service = new RemoteConfigurationService(connector); + service.start(configuration -> {}); + + connector.listener.accept("{\"environment\":{\"name\":\"Test\"},\"flags\":{}}".getBytes(UTF_8)); + connector.listener.accept("{not json".getBytes(UTF_8)); + connector.listener.accept(null); + + assertLogged("subscribing to Remote Configuration"); + assertLogged("malformed flag configuration"); + } + + private CdnConfigurationSource source() { + return source(null); + } + + private CdnConfigurationSource source(final String apiKey) { + return source(this.server.uri("/ufc?token=hidden"), apiKey); + } + + private CdnConfigurationSource source(final URI endpoint, final String apiKey) { + return source(endpoint, apiKey, mock(ScheduledExecutorService.class)); + } + + private CdnConfigurationSource source( + final URI endpoint, final String apiKey, final ScheduledExecutorService executor) { + final CdnConfigurationSource source = + new CdnConfigurationSource( + endpoint, + apiKey, + 30_000, + Duration.ofSeconds(5), + HttpClient.newHttpClient(), + executor, + delay -> {}, + () -> 1.0); + source.setListenerForTest(new CopyOnWriteArrayList()::add); + return source; + } + + private void assertLogged(final String text) { + assertFalse(messages(text).isEmpty(), "Missing log: " + text + " in " + messages("")); + } + + private String logged(final String text) { + final List found = messages(text); + assertEquals(1, found.size(), found.toString()); + return found.get(0); + } + + private static List messages(final String text) { + return CapturingServiceProvider.events().stream() + .map(e -> e.message) + .filter(message -> message.contains(text)) + .collect(Collectors.toList()); + } + + private static final class CapturingSource implements ConfigurationSource { + volatile Consumer listener; + + @Override + public AutoCloseable subscribe(final Consumer listener) { + this.listener = listener; + return () -> {}; + } + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposurePipelineDiagnosticsTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposurePipelineDiagnosticsTest.java new file mode 100644 index 00000000000..98343ea2fbe --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/exposure/ExposurePipelineDiagnosticsTest.java @@ -0,0 +1,152 @@ +package com.datadog.openfeature.internal.exposure; + +import static java.util.Collections.emptyMap; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.datadog.openfeature.internal.RuntimeServices; +import com.datadog.openfeature.internal.bridge.v1.EventTransport; +import com.datadog.openfeature.internal.bridge.v1.HealthMetrics; +import com.datadog.openfeature.testlog.CapturingServiceProvider; +import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.event.Level; + +class ExposurePipelineDiagnosticsTest { + private static final long AWAIT_MILLIS = SECONDS.toMillis(5); + + @BeforeEach + void captureLogs() { + CapturingServiceProvider.reset(); + CapturingServiceProvider.enable("com.datadog.openfeature", Level.DEBUG); + } + + @AfterEach + void restore() { + CapturingServiceProvider.reset(); + } + + @Test + void reportsTheExposuresDroppedByAFullQueueOncePerFlush() throws Exception { + final ExposurePipeline pipeline = pipeline(1, (route, json) -> {}); + final int logged = pipelineEvents().size(); + + pipeline.accept(exposure()); + pipeline.accept(exposure()); + pipeline.accept(exposure()); + + // The producer, an application thread, does not log. + assertEquals(logged, pipelineEvents().size(), messages("").toString()); + pipeline.start(); + try { + awaitLog("exposures dropped since the last flush: queue full, count=2"); + } finally { + pipeline.close(); + } + assertEquals(1, messages("queue full").size(), messages("").toString()); + } + + @Test + void reportsTheShutdownRequestWithoutLoggingEachExposure() { + final ExposurePipeline pipeline = pipeline(16, (route, json) -> {}); + final int logged = pipelineEvents().size(); + + pipeline.accept(exposure()); + pipeline.requestShutdown(); + pipeline.accept(exposure()); + + assertEquals(logged + 1, pipelineEvents().size(), messages("").toString()); + assertEquals(1, messages("exposure shutdown requested: queued=1").size()); + } + + @Test + void signalsTheWorkerWhenTheShutdownLogFails() { + final ExposurePipeline pipeline = pipeline(16, (route, json) -> {}); + pipeline.start(); + CapturingServiceProvider.failOn("exposure shutdown requested"); + + assertThrows(IllegalStateException.class, pipeline::requestShutdown); + + pipeline.awaitTermination(System.nanoTime() + SECONDS.toNanos(5)); + assertFalse(pipeline.isSerializerThreadAlive(), "The worker was not signaled"); + } + + @Test + void reportsADeliveredFlush() throws Exception { + final ExposurePipeline pipeline = pipeline(16, (route, json) -> {}); + pipeline.start(); + try { + pipeline.accept(exposure()); + + awaitLog("exposure flush delivered: count=1"); + } finally { + pipeline.close(); + } + + assertFalse(messages("exposure flush starting: count=1").isEmpty()); + } + + @Test + void reportsAFailedFlushAsDroppingTheBatch() throws Exception { + final ExposurePipeline pipeline = + pipeline( + 16, + (route, json) -> { + throw new IOException("returned error response 503"); + }); + pipeline.start(); + try { + pipeline.accept(exposure()); + + awaitLog("exposure flush failed: java.io.IOException: returned error response 503"); + } finally { + pipeline.close(); + } + + assertFalse(messages("dropping the batch of 1 exposure(s)").isEmpty()); + } + + private static ExposurePipeline pipeline(final int capacity, final EventTransport transport) { + return new ExposurePipeline( + capacity, 10, MILLISECONDS, transport, emptyMap(), new RuntimeServices(HealthMetrics.NOOP)); + } + + private static ExposureEvent exposure() { + return new ExposureEvent( + 1L, + new Allocation("allocation"), + new Flag("flag"), + new Variant("variant"), + new Subject("subject", emptyMap()), + 1); + } + + private static void awaitLog(final String text) throws InterruptedException { + final long deadline = System.currentTimeMillis() + AWAIT_MILLIS; + while (messages(text).isEmpty() && System.currentTimeMillis() < deadline) { + Thread.sleep(10); + } + assertFalse(messages(text).isEmpty(), "Missing log: " + text + " in " + messages("")); + } + + /** The events of the pipeline only: other threads of the JVM can log in the same window. */ + private static List pipelineEvents() { + return CapturingServiceProvider.events().stream() + .filter(e -> e.logger.equals(ExposurePipeline.class.getName())) + .collect(Collectors.toList()); + } + + private static List messages(final String text) { + return CapturingServiceProvider.events().stream() + .map(e -> e.message) + .filter(message -> message.contains(text)) + .collect(Collectors.toList()); + } +} diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipelineTest.java b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipelineTest.java index 269c37f3b48..433e8e26135 100644 --- a/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipelineTest.java +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/internal/flagevaluation/FlagEvaluationPipelineTest.java @@ -14,19 +14,24 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import com.datadog.openfeature.internal.EventQueue; import com.datadog.openfeature.internal.RuntimeServices; import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationTestSupport.CapturingTransport; import com.datadog.openfeature.internal.flagevaluation.FlagEvaluationTestSupport.RecordingMetrics; +import com.datadog.openfeature.testlog.CapturingServiceProvider; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; import org.junit.jupiter.api.Test; +import org.slf4j.event.Level; class FlagEvaluationPipelineTest { @@ -58,6 +63,48 @@ void degradedCapOverflowTelemetryIsEmittedOnFlush() { "reason:" + FlagEvaluationPipeline.DROP_REASON_DEGRADED_CAP)); } + @Test + void reportsADeliveredFlushAndTheShutdownRequest() { + CapturingServiceProvider.reset(); + CapturingServiceProvider.enable("com.datadog.openfeature", Level.DEBUG); + try { + final FlagEvaluationTestSupport.TestWriterSetup setup = buildTestWriter(); + setup.handler.add(simpleEvent("logged-flag", "on")); + flushAndCapture(setup); + writer(16, Long.MAX_VALUE, TimeUnit.NANOSECONDS).requestShutdown(); + + final List messages = + CapturingServiceProvider.events().stream() + .map(e -> e.message) + .collect(Collectors.toList()); + assertTrue( + messages.contains("Feature Flags flag evaluation flush delivered: payloads=1"), + messages.toString()); + assertTrue( + messages.contains("Feature Flags flag evaluation shutdown requested"), + messages.toString()); + } finally { + CapturingServiceProvider.reset(); + } + } + + @Test + void signalsTheWorkerWhenTheShutdownLogFails() { + CapturingServiceProvider.reset(); + CapturingServiceProvider.enable("com.datadog.openfeature", Level.DEBUG); + CapturingServiceProvider.failOn("flag evaluation shutdown requested"); + final FlagEvaluationPipeline writer = writer(16, 10L, TimeUnit.SECONDS); + writer.start(); + try { + assertThrows(IllegalStateException.class, writer::requestShutdown); + + writer.awaitTermination(System.nanoTime() + TimeUnit.SECONDS.toNanos(5)); + assertFalse(writer.isSerializerThreadAlive(), "The worker was not signaled"); + } finally { + CapturingServiceProvider.reset(); + } + } + @Test void queueOverflowIncrementsObservableDropCounter() { final FlagEvaluationPipeline writer = writer(2, 10L, TimeUnit.SECONDS); diff --git a/dd-openfeature/src/test/java/com/datadog/openfeature/testlog/CapturingServiceProvider.java b/dd-openfeature/src/test/java/com/datadog/openfeature/testlog/CapturingServiceProvider.java new file mode 100644 index 00000000000..c1a8bba0e9a --- /dev/null +++ b/dd-openfeature/src/test/java/com/datadog/openfeature/testlog/CapturingServiceProvider.java @@ -0,0 +1,208 @@ +package com.datadog.openfeature.testlog; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.CopyOnWriteArrayList; +import org.slf4j.ILoggerFactory; +import org.slf4j.IMarkerFactory; +import org.slf4j.Marker; +import org.slf4j.event.Level; +import org.slf4j.helpers.AbstractLogger; +import org.slf4j.helpers.BasicMarkerFactory; +import org.slf4j.helpers.MessageFormatter; +import org.slf4j.helpers.NOPMDCAdapter; +import org.slf4j.spi.MDCAdapter; +import org.slf4j.spi.SLF4JServiceProvider; + +/** + * Test SLF4J provider that captures log events. The module has no SLF4J 2 compatible logging + * backend, so this one lets tests assert diagnostics. Nothing is enabled or captured until a test + * calls {@link #enable(String, Level)}, which keeps the other tests as quiet as without a backend. + */ +public final class CapturingServiceProvider implements SLF4JServiceProvider { + private static final ConcurrentMap LEVELS = new ConcurrentHashMap<>(); + private static final List EVENTS = new CopyOnWriteArrayList<>(); + private static volatile String failOn; + + /** A captured log event. */ + public static final class Event { + public final String logger; + public final Level level; + public final String message; + public final Throwable throwable; + + Event(final String logger, final Level level, final String message, final Throwable throwable) { + this.logger = logger; + this.level = level; + this.message = message; + this.throwable = throwable; + } + + @Override + public String toString() { + return level + " " + logger + ": " + message; + } + } + + private final ILoggerFactory loggerFactory = name -> new CapturingLogger(name); + private final IMarkerFactory markerFactory = new BasicMarkerFactory(); + private final MDCAdapter mdcAdapter = new NOPMDCAdapter(); + + /** + * Enables a level and captures the events of the loggers whose name starts with the prefix. The + * longest matching prefix decides. + * + * @param prefix the logger name prefix. + * @param level the lowest enabled level, or {@code null} to disable the prefix. + */ + public static void enable(final String prefix, final Level level) { + if (level == null) { + LEVELS.remove(prefix); + } else { + LEVELS.put(prefix, level); + } + } + + /** + * Makes the logging calls throw, as a failing logging backend would: an enabled event whose + * message contains the text is captured, then the logging call throws. + * + * @param text the text of the messages that fail, or {@code null} to stop failing. + */ + public static void failOn(final String text) { + failOn = text; + } + + /** Disables every logger, stops the failures and forgets the captured events. */ + public static void reset() { + LEVELS.clear(); + EVENTS.clear(); + failOn = null; + } + + /** + * @return the captured events, oldest first. + */ + public static List events() { + return EVENTS; + } + + @Override + public ILoggerFactory getLoggerFactory() { + return this.loggerFactory; + } + + @Override + public IMarkerFactory getMarkerFactory() { + return this.markerFactory; + } + + @Override + public MDCAdapter getMDCAdapter() { + return this.mdcAdapter; + } + + @Override + public String getRequestedApiVersion() { + return "2.0.99"; + } + + @Override + public void initialize() {} + + private static final class CapturingLogger extends AbstractLogger { + private static final long serialVersionUID = 1L; + + CapturingLogger(final String name) { + this.name = name; + } + + private boolean isEnabled(final Level level) { + // Read the entries once: a test can reset the levels while a worker thread is logging. + String best = null; + Level threshold = null; + for (final Map.Entry entry : LEVELS.entrySet()) { + final String prefix = entry.getKey(); + if (name.startsWith(prefix) && (best == null || prefix.length() > best.length())) { + best = prefix; + threshold = entry.getValue(); + } + } + return threshold != null && level.toInt() >= threshold.toInt(); + } + + @Override + public boolean isTraceEnabled() { + return isEnabled(Level.TRACE); + } + + @Override + public boolean isTraceEnabled(final Marker marker) { + return isTraceEnabled(); + } + + @Override + public boolean isDebugEnabled() { + return isEnabled(Level.DEBUG); + } + + @Override + public boolean isDebugEnabled(final Marker marker) { + return isDebugEnabled(); + } + + @Override + public boolean isInfoEnabled() { + return isEnabled(Level.INFO); + } + + @Override + public boolean isInfoEnabled(final Marker marker) { + return isInfoEnabled(); + } + + @Override + public boolean isWarnEnabled() { + return isEnabled(Level.WARN); + } + + @Override + public boolean isWarnEnabled(final Marker marker) { + return isWarnEnabled(); + } + + @Override + public boolean isErrorEnabled() { + return isEnabled(Level.ERROR); + } + + @Override + public boolean isErrorEnabled(final Marker marker) { + return isErrorEnabled(); + } + + @Override + protected String getFullyQualifiedCallerName() { + return null; + } + + @Override + protected void handleNormalizedLoggingCall( + final Level level, + final Marker marker, + final String messagePattern, + final Object[] arguments, + final Throwable throwable) { + if (isEnabled(level)) { + final String message = MessageFormatter.arrayFormat(messagePattern, arguments).getMessage(); + EVENTS.add(new Event(this.name, level, message, throwable)); + final String failing = failOn; + if (failing != null && message.contains(failing)) { + throw new IllegalStateException("The logging backend failed on: " + message); + } + } + } + } +} diff --git a/dd-openfeature/src/test/resources/META-INF/services/org.slf4j.spi.SLF4JServiceProvider b/dd-openfeature/src/test/resources/META-INF/services/org.slf4j.spi.SLF4JServiceProvider new file mode 100644 index 00000000000..4b23d4ccf4a --- /dev/null +++ b/dd-openfeature/src/test/resources/META-INF/services/org.slf4j.spi.SLF4JServiceProvider @@ -0,0 +1 @@ +com.datadog.openfeature.testlog.CapturingServiceProvider