Skip to content

Decouple Kafka DSM instrumentation from APM tracing enablement - #12350

Draft
ericfirth wants to merge 24 commits into
masterfrom
worktree-dsm-kafka-decoupling
Draft

Decouple Kafka DSM instrumentation from APM tracing enablement#12350
ericfirth wants to merge 24 commits into
masterfrom
worktree-dsm-kafka-decoupling

Conversation

@ericfirth

Copy link
Copy Markdown
Contributor

Summary

  • Switches Kafka clients (0.11, 3.8) and kafka-streams-0.11 instrumentation modules to InstrumenterModule.DataStreams, so DSM checkpoints are created even when APM tracing/integrations are disabled.
  • Forces DSM-only produce/consume/poll spans to PrioritySampling.USER_DROP via SamplingMechanism.DATA_STREAMS when they are the local root span, so they aren't billed as APM — while never dropping an ambient customer trace.
  • Dedupes each KafkaDecorator's hardcoded integration-name list into shared constants reused by TRACING_ENABLED and every module constructor, delegates Config.isDataStreamsEnabled() to InstrumenterConfig, extracts a duplicated DSM-drop guard in KafkaStreamTaskInstrumentation into a helper, and rewrites InstrumenterModuleTest to use @WithConfig instead of raw reflection.

Motivation

Copart uses JRuby, which doesn't support DSM. They previously ran dd-trace-java and had DSM working, but moved to dd-trace-rb for better APM tracing — and dd-trace-rb doesn't support DSM either, so their DSM broke. This explores whether Java's DSM instrumentation can run standalone (independent of APM tracing) as a path back to DSM support for cases like this.

This is exploratory — tracked in DSM2-278. It may or may not be accepted upstream.

Test plan

  • ./gradlew :dd-java-agent:instrumentation:kafka-clients-0.11:test
  • ./gradlew :dd-java-agent:instrumentation:kafka-clients-3.8:test
  • ./gradlew :dd-java-agent:instrumentation:kafka-streams-0.11:test
  • ./gradlew :internal-api:test
  • ./gradlew :dd-java-agent:agent-tooling:test
  • Manual verification against a real Java Kafka client test app with DSM enabled / APM tracing disabled
  • Techdebt and perf review passes run over the branch; findings fixed

🤖 Generated with Claude Code

ericfirth and others added 24 commits August 26, 2026 16:00
Implements the pre-instrumentation config accessor for the data streams feature flag,
mirroring the existing usmEnabled pattern. Reads the same data.streams.enabled key
that Config.isDataStreamsEnabled() uses, enabling DSM to work independently of the
general DD_INTEGRATIONS_ENABLED APM gate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ledOverridesFalse

Save and restore the original dataStreamsEnabled value instead of
hardcoding false in the finally block. This prevents test pollution
where an incorrect state could leak to the next test.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add DATA_STREAMS sampling mechanism (byte value 14) to support
decoupling Kafka DSM from APM tracing. This mechanism enforces
USER_DROP priority for DSM-only spans and allows overriding locked
priority when DSM is enabled.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Use qualified PrioritySampling.USER_DROP form in validateWithSamplingPriority
to match the DATA_JOBS case style (line 70) which uses qualified form.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…reams base class

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
When Kafka tracing is disabled but Data Streams Monitoring is enabled,
produce-path spans created without a propagated trace context should be
marked with PrioritySampling.USER_DROP to avoid billing as APM spans.
This implements the 'local-root spans only' scoping from the spec: spans
with an extracted parent context (genuine distributed traces) are left
completely untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…eams base class

Change extends InstrumenterModule.Tracing to extends InstrumenterModule.DataStreams
for all 8 instrumentation classes in the kafka-clients-3.8 module. This decouples
Data Streams Monitoring from APM tracing and prepares for billing-suppression logic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…me paths)

Add TRACING_ENABLED constant and billing-suppression logic for produce and
consume paths to suppress unnecessary billing when APM tracing is disabled but
DSM is enabled. The suppression only applies to local-root spans (no extracted
parent context) per the DSM-Kafka decoupling plan.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tion to DataStreams

This class populates StreamingContextUpdater's source/disabled-topic registry
via BuildAdvice.exit, which KafkaStreamTaskInstrumentation's DSM checkpoint
logic reads. Switching from InstrumenterModule.Tracing to DataStreams ensures
the registry is populated when DSM is the only reason Kafka is instrumented,
allowing DSM's STREAMING_CONTEXT.isSourceTopic()/isDisabledForTopic() checks
to work correctly instead of silently falling back to defaults.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Change ConnectWorkerInstrumentation to extend InstrumenterModule.DataStreams
instead of InstrumenterModule.Tracing. This class only wraps TaskStatus.Listener
for forwarding task status callbacks and does not create spans, making it
appropriate for the DataStreams module instead of the Tracing module.

Task 11 of DSMS-161 (kafka-connect-0.11: base-class swap)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…d tests

Converts the Groovy/Spock InstrumenterConfigTest to a JUnit 5 Java test
(per repo workflow), preserving all 44 existing cases, and adds explicit
coverage for InstrumenterConfig.isDataStreamsEnabled(): default false,
and true via DD_DATA_STREAMS_ENABLED. Part of the DSM/Kafka decoupling
test task (Task 12a).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Convert the Spock SamplingMechanismTest to a parameterized JUnit 5 Java
test, and add DATA_STREAMS cases mirroring the existing DATA_JOBS
coverage for validateWithSamplingPriority() and
canAvoidSamplingPriorityLock(), per DSMS-161 task 12.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…TREAMS flag

Test that AgentInstaller.getEnabledSystems() correctly includes or excludes
TargetSystem.DATA_STREAMS based on the data.streams.enabled configuration flag.
Also test USM and LLMOBS for consistency. Uses @WithConfig extension to manage
configuration state across test cases.

Tests verify:
- DATA_STREAMS not included when data.streams.enabled is false (default)
- DATA_STREAMS included when data.streams.enabled is true
- USM not included when usm.enabled is false (default)
- USM included when usm.enabled is true
- LLMOBS not included when llmobs.enabled is false (default)
- LLMOBS included when llmobs.enabled is true

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…lients-0.11

Mirrors the fix already applied to kafka-clients-3.8's TracingIterator:
when Kafka APM tracing is disabled but Data Streams Monitoring is enabled,
and no genuine propagated trace context was extracted from the record
headers, the local-root consume span is marked USER_DROP so it is not
billed as a normal APM span on kafka-clients < 3.8.0.
Cover the local-root sampling-priority suppression (USER_DROP) that
ProducerAdvice and TracingIterator apply when kafka tracing is disabled
(via integrations.enabled or the per-integration trace.kafka.enabled
override) but DSM is enabled, plus a regression guard confirming spans
that join a real propagated trace are not suppressed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… trace

`AgentSpan.setSamplingPriority` is trace-level: it resolves to the local root
span. The DSM billing-suppression guard was gated on "no context was extracted
from the record headers", which is not the same predicate as "this span is the
local root". Because the kafka-clients producer and the kafka-streams
StreamTask advices create their spans with scope-honouring `startSpan`
overloads (USE_SCOPE), the created span can inherit the locally active span --
and since the new DATA_STREAMS mechanism bypasses the sampling priority lock,
the guard could force-drop a genuine customer trace (e.g. producing a Kafka
message inside an instrumented HTTP request).

Producer sites (kafka-clients-0.11 and kafka-clients-3.8) now additionally
require `span.getLocalRootSpan() == span`. Entering the else branch already
rules out a header-extracted parent, so the local root check is exactly the
remaining condition.

The kafka-streams sites need a stronger predicate:

- `TIME_IN_QUEUE_ENABLED` (on by default) parents the consume span onto the
  queue span, so `getLocalRootSpan() == span` is never true there; the root
  candidate is the queue span when one exists.
- An `ExtractedContext` parent allocates a fresh TraceCollector, so a span
  continuing a header-propagated trace is still its own local root. The
  sibling ContextPropagationAdvice attaches that extracted context to the
  scope before this advice runs, so the active span captured on entry is what
  detects it.

Both sites therefore require no active span on entry and that the local root
is the first span the advice created.

Also switches the producer sites from the static `Config.get()` to the
remote-config-aware `traceConfig()`, matching the consumer/streams sites so a
dynamic DSM toggle cannot make them disagree.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`MessageListenerInstrumentation` applies Code Origin (span origin) advice to
Spring Kafka message listeners -- a purely-APM concern with no Data Streams
behaviour. Swapping it to `InstrumenterModule.DataStreams` ORed
`isDataStreamsEnabled()` into `isEnabled()`, so with `code.origin.enabled=true`
and `data.streams.enabled=true` it installed in DSM-only deployments where
Kafka tracing is off. Revert this one module to `InstrumenterModule.Tracing`
and record why in a class comment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…and streams

Regression guards for the two billing-suppression bugs, plus the coverage gaps
the final review flagged. All four new specs were confirmed red against a
temporarily reverted guard.

kafka-streams-0.11 (new spec, module previously had no working DSM coverage --
its only end-to-end test is @ignore'd):
- a local-root streams consume span is still forced to USER_DROP
- a streams consume span continuing a header-propagated trace is not. The
  local-root case needs kafka.client.propagation.disabled.topics, since an
  in-JVM producer would otherwise inject its own context into the headers.

kafka-clients-0.11:
- producing inside an active local trace does not force that trace to
  USER_DROP -- the one-line test that would have caught the producer bug
- strengthen the extracted-parent scenario from "!= USER_DROP" to the concrete
  expected SAMPLER_KEEP, and assert the consume span's trace id too

kafka-clients-3.8 (new spec, module had no suppression coverage at all): the
local-root USER_DROP scenario and the active-local-trace regression guard.
These extend InstrumentationSpecification rather than KafkaClientTestBase,
whose inherited test asserts Code Origin tags that correctly no longer apply
once Kafka tracing is off.

SamplingMechanismTest: the only DATA_STREAMS row in the parameterized
lock-bypass test left DSM at its disabled default, so it passed for a config
reason rather than a mechanism one. Add dedicated tests with
data.streams.enabled set both ways, including that enabling DSM does not
unlock any other mechanism.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…eview findings

Fixes a billing gap where the standalone kafka.poll span/trace created for
DSM was never forced to USER_DROP, so it would be billed as APM even with
Kafka tracing disabled. Also delegates Config.isDataStreamsEnabled() to
InstrumenterConfig, dedupes each KafkaDecorator's hardcoded integration-name
list into shared constants used by TRACING_ENABLED and every module
constructor, extracts the duplicated DSM-drop guard in
KafkaStreamTaskInstrumentation into a helper, and rewrites
InstrumenterModuleTest to use @WithConfig instead of raw reflection.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@ericfirth ericfirth added type: feature Enhancements and improvements inst: kafka Kafka instrumentation comp: data streams Data Streams Monitoring tag: ai generated Largely based on code generated by an AI or LLM labels Aug 31, 2026
@ericfirth ericfirth added comp: data streams Data Streams Monitoring tag: ai generated Largely based on code generated by an AI or LLM labels Aug 31, 2026
@dd-octo-sts

dd-octo-sts Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

❌ New Groovy Files Detected

Please avoid introducing new .groovy files to this repository.

  • dd-java-agent/instrumentation/kafka/kafka-clients-3.8/src/test/groovy/KafkaClientDataStreamsOnlyForkedTest.groovy
  • dd-java-agent/instrumentation/kafka/kafka-streams-0.11/src/test/groovy/KafkaStreamsDataStreamsOnlyForkedTest.groovy

Instead, rewrite the new file(s) in Java / JUnit. See the How to Test With JUnit Guide for more details.

If this PR needs an exception, add the tag: override groovy enforcement label to bypass this workflow.

@datadog-datadog-prod-us1

datadog-datadog-prod-us1 Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

🎯 Code Coverage (details)
Patch Coverage: 12.50%
Overall Coverage: 58.77% (-0.07%)

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 8f5f263 | Docs | View more details | Give us feedback!

@dd-octo-sts

dd-octo-sts Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

🟢 Java Benchmark SLOs — All performance SLOs passed

Suite Status
Startup 🟢 pass

SLO thresholds are defined here based on automatically generated metrics. A warning is raised when results are within 5% of the threshold.

PR vs. master results
Scenario Candidate master Δ (95% CI of mean)
startup:insecure-bank:iast:Agent 14.77 s 14.59 s [+0.5%; +2.0%] (maybe worse)
startup:insecure-bank:tracing:Agent 13.62 s 13.67 s [-1.4%; +0.5%] (no difference)
startup:petclinic:appsec:Agent 16.93 s 16.74 s [+0.1%; +2.2%] (maybe worse)
startup:petclinic:iast:Agent 16.84 s 16.94 s [-1.4%; +0.2%] (no difference)
startup:petclinic:profiling:Agent 16.69 s 16.79 s [-1.9%; +0.7%] (no difference)
startup:petclinic:sca:Agent 16.86 s 16.05 s [+0.6%; +9.5%] (maybe worse)
startup:petclinic:tracing:Agent 16.31 s 15.78 s [-1.1%; +7.8%] (no difference)

Commit: 8f5f2638 · CI Pipeline · Benchmarking Platform UI


Load and DaCapo benchmarks can be triggered manually in the GitLab pipeline. Results will appear in the Benchmarking Platform UI after completion.

@pr-commenter

pr-commenter Bot commented Aug 31, 2026

Copy link
Copy Markdown

Kafka / consumer-benchmark

Parameters

Baseline Candidate
baseline_or_candidate baseline candidate
git_branch master worktree-dsm-kafka-decoupling
git_commit_date 1787750265 1788201679
git_commit_sha 2782c6c 8f5f263
See matching parameters
Baseline Candidate
ci_job_date 1788203304 1788203304
ci_job_id 2000712080 2000712080
ci_pipeline_id 134416396 134416396
cpu_model Intel(R) Xeon(R) Platinum 8259CL CPU @ 2.50GHz Intel(R) Xeon(R) Platinum 8259CL CPU @ 2.50GHz
jdkVersion 11.0.31 11.0.31
jmhVersion 1.36 1.36
jvm /usr/lib/jvm/java-11-openjdk-amd64/bin/java /usr/lib/jvm/java-11-openjdk-amd64/bin/java
jvmArgs -Dhttp.proxyHost=127.0.0.1 -Dhttp.proxyPort=15002 -Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=15002 -Dhttp.nonProxyHosts=localhost *.localhost
kernel_version Linux runner-zfyrx7zua-project-304-concurrent-1-e6ss235h 6.8.0-1031-aws #33~22.04.1-Ubuntu SMP Thu Jun 26 14:22:30 UTC 2025 x86_64 x86_64 x86_64 GNU/Linux Linux runner-zfyrx7zua-project-304-concurrent-1-e6ss235h 6.8.0-1031-aws #33~22.04.1-Ubuntu SMP Thu Jun 26 14:22:30 UTC 2025 x86_64 x86_64 x86_64 GNU/Linux
vmName OpenJDK 64-Bit Server VM OpenJDK 64-Bit Server VM
vmVersion 11.0.31+11-post-1ubuntu1-22.04.2-Ubuntu 11.0.31+11-post-1ubuntu1-22.04.2-Ubuntu

Summary

Found 0 performance improvements and 0 performance regressions! Performance is the same for 3 metrics, 0 unstable metrics.

See unchanged results
scenario Δ mean throughput
scenario:not-instrumented/KafkaConsumerBenchmark.benchConsume same
scenario:only-tracing-dsm-disabled-benchmarks/KafkaConsumerBenchmark.benchConsume same
scenario:only-tracing-dsm-enabled-benchmarks/KafkaConsumerBenchmark.benchConsume same

// spans are written out together by TraceStructureWriter when running in strict mode
}

if (spanContext == null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This guard is skipped whenever spanContext is non-null, but that doesn't account for DD_TRACE_PROPAGATION_BEHAVIOR_EXTRACT=restart/ignore, under which CoreTracer.CoreSpanBuilder.startSpan nulls the extracted context internally and still creates a fresh local-root span. With Kafka tracing disabled + DSM enabled + that propagation mode, a record carrying real x-datadog-* headers extracts a non-null spanContext, so this guard never fires and the resulting local-root span never gets forced to USER_DROP — it gets billed as full APM despite Kafka tracing being off. Same pattern is mirrored in kafka-clients-3.8's TracingIterator.java. Might be worth gating on span.getLocalRootSpan() == span (as KafkaConsumerInfoInstrumentation does for the poll span) instead of the spanContext == null check, since that's what actually determines whether this span is APM-billable.

return (!Config.get().isApmTracingEnabled() && mechanism == SamplingMechanism.APPSEC)
|| (Config.get().isDataJobsEnabled() && mechanism == DATA_JOBS);
|| (Config.get().isDataJobsEnabled() && mechanism == DATA_JOBS)
|| (Config.get().isDataStreamsEnabled() && mechanism == DATA_STREAMS);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This reads the static, startup-only Config.get().isDataStreamsEnabled(), but every Kafka call site that actually forces USER_DROP for DSM gates on the dynamic, remote-config-aware traceConfig().isDataStreamsEnabled(). If DSM is off at JVM startup and later turned on via Remote Config, this method keeps returning false for DATA_STREAMS even though the call site's own dynamic check says DSM is enabled — so the force-drop falls into the CAS-guarded path in DDSpanContext, and if the priority was already set by another mechanism earlier in the span's life, the CAS fails and the DSM-only drop silently never applies (the trace gets billed as APM). Should this use traceConfig().isDataStreamsEnabled() to match the call sites?

iastFullyDisabled = true;
telemetryEnabled = false;
usmEnabled = false;
dataStreamsEnabled = false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This forces dataStreamsEnabled to false unconditionally under Platform.isNativeImageBuilder(). Before this PR, Config.isDataStreamsEnabled() read data.streams.enabled directly with no native-image carve-out; now Config.isDataStreamsEnabled() delegates entirely to this class (see the Config.java diff), so a GraalVM native-image deployment (e.g. Lambda) that sets DD_DATA_STREAMS_ENABLED=true will silently get DSM disabled — a behavior regression introduced by this PR, not a pre-existing constraint. (isUsmEnabled/isLlmObsEnabled already had this native-image gating before this PR; isDataStreamsEnabled didn't.) Is the native-image restriction intentional for DSM, or should this follow the same pattern as usmEnabled/llmObsEnabled only if DSM genuinely can't work under native-image?

public static final boolean KAFKA_LEGACY_TRACING = Config.get().isKafkaLegacyTracingEnabled();
public static final boolean TIME_IN_QUEUE_ENABLED =
Config.get().isTimeInQueueEnabled(!KAFKA_LEGACY_TRACING, KAFKA);
public static final boolean TRACING_ENABLED =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Within this module, the DSM-only USER_DROP guard is hand-copied 4 times: twice in TracingIterator.java (using spanContext == null, no local-root check — see my comment there), once in KafkaConsumerInfoInstrumentation.java's poll span, and once in KafkaProducerInstrumentation.java's produce span (the latter two correctly use span.getLocalRootSpan() == span plus a comment explaining why). That the fixed/correct version only made it to 2 of 4 in-module copies is exactly the risk of this duplication. Cross-module sharing with kafka-streams-0.11 etc. is understandably not worth the trouble, but a small static helper here in KafkaDecorator (e.g. maybeForceDsmOnlyDrop(AgentSpan span)), or a private helper in TracingIterator shared between its two call sites, seems low-cost and would have kept all four in-module copies in sync.

@dougqh

dougqh commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Heads up: the TRACING_CONFIG_NONDEFAULT system-tests scenario (main / End-to-end #8 / spring-boot 8) is currently failing because of this PR's core behavior change:

FAILED tests/test_config_consistency.py::Test_Config_IntegrationEnabled_False::test_integration_enabled_false
AssertionError: kafka.produce span was found in trace: [...]

That test disables the Kafka integration and asserts no kafka.produce span should reach the agent at all — the pre-PR contract. With this PR, when DSM is enabled, a kafka.produce/kafka.consume span is still created and sent (now carrying sampling_priority=USER_DROP) even though Kafka tracing itself is disabled — that's the intended decoupling. The scenario has DSM traffic (api_v2_data_streams_messages.json present in its logs), so this is a genuine interaction, not a flake.

This will need a system-tests-side update before merge: either exempt/adjust Test_Config_IntegrationEnabled_False for the DSM-enabled case, or have the scenario disable DSM alongside the integration so the old assertion still holds where DSM isn't in play. Worth confirming with whoever owns that shared parametric test (it's cross-language) before changing its semantics unilaterally.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp: data streams Data Streams Monitoring inst: kafka Kafka instrumentation tag: ai generated Largely based on code generated by an AI or LLM type: feature Enhancements and improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants