From 8e5771c747ee9d36c57cd1e54a8a87cc8c097814 Mon Sep 17 00:00:00 2001 From: labbati Date: Fri, 15 May 2026 15:18:09 +0200 Subject: [PATCH 1/4] feat(tt): plumb tt_extraction_patterns through DynamicConfig Adds the TransactionTrackingPatterns util (hand-rolled glob matcher with a single volatile snapshot, zero allocations on the empty fast path), wires the new optional 'tt_extraction_patterns' field through the APM_TRACING remote-config DTO and DynamicConfig snapshot, exposes a Config static fallback (always empty for now) and adds the InstrumentationTags TT_EXTRACTION_SOURCES constant. No tagging behaviour yet \u2014 only the configuration plumbing and the compiled-pattern snapshot publication. tag: ai generated --- .../trace/core/TracingConfigPoller.java | 8 + .../trace/core/TracingConfigPollerTest.java | 103 ++++++++++ .../main/java/datadog/trace/api/Config.java | 9 + .../java/datadog/trace/api/DynamicConfig.java | 24 +++ .../java/datadog/trace/api/TraceConfig.java | 6 + .../api/tt/TransactionTrackingPatterns.java | 188 ++++++++++++++++++ .../instrumentation/api/AgentTracer.java | 5 + .../api/InstrumentationTags.java | 4 + .../tt/TransactionTrackingPatternsTest.java | 115 +++++++++++ 9 files changed, 462 insertions(+) create mode 100644 internal-api/src/main/java/datadog/trace/api/tt/TransactionTrackingPatterns.java create mode 100644 internal-api/src/test/java/datadog/trace/api/tt/TransactionTrackingPatternsTest.java diff --git a/dd-trace-core/src/main/java/datadog/trace/core/TracingConfigPoller.java b/dd-trace-core/src/main/java/datadog/trace/core/TracingConfigPoller.java index 6bbc13151e3..0e712a88cf2 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/TracingConfigPoller.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/TracingConfigPoller.java @@ -256,6 +256,8 @@ void applyConfigOverrides(LibConfig libConfig) { maybeOverride(builder::setTraceSampleRate, libConfig.traceSampleRate); maybeOverride(builder::setTracingTags, parseTagListToMap(libConfig.tracingTags)); + maybeOverride( + builder::setTransactionTrackingExtractionPatterns, libConfig.ttExtractionPatterns); DebuggerConfigBridge.updateConfig( new DebuggerConfigUpdate( libConfig.dynamicInstrumentationEnabled, @@ -419,6 +421,9 @@ static final class LibConfig { @Json(name = "data_streams_transaction_extractors") public DataStreamsTransactionExtractors dataStreamsTransactionExtractors; + @Json(name = "tt_extraction_patterns") + public List ttExtractionPatterns; + /** * Merges a list of LibConfig objects by taking the first non-null value for each field. * @@ -482,6 +487,9 @@ public static LibConfig mergeLibConfigs(List configs) { if (merged.liveDebuggingEnabled == null) { merged.liveDebuggingEnabled = config.liveDebuggingEnabled; } + if (merged.ttExtractionPatterns == null) { + merged.ttExtractionPatterns = config.ttExtractionPatterns; + } } return merged; diff --git a/dd-trace-core/src/test/java/datadog/trace/core/TracingConfigPollerTest.java b/dd-trace-core/src/test/java/datadog/trace/core/TracingConfigPollerTest.java index a5105d326c9..d45773f103e 100644 --- a/dd-trace-core/src/test/java/datadog/trace/core/TracingConfigPollerTest.java +++ b/dd-trace-core/src/test/java/datadog/trace/core/TracingConfigPollerTest.java @@ -19,6 +19,7 @@ import datadog.remoteconfig.state.ParsedConfigKey; import datadog.remoteconfig.state.ProductListener; import datadog.trace.api.datastreams.DataStreamsTransactionExtractor; +import datadog.trace.api.tt.TransactionTrackingPatterns; import java.lang.reflect.Field; import java.nio.charset.StandardCharsets; import java.util.Arrays; @@ -219,6 +220,108 @@ void actualConfigCommitWithServiceAndOrgLevelConfigs() throws Exception { } } + @Test + void ttExtractionPatternsArePropagatedAndPublished() throws Exception { + ParsedConfigKey key = ParsedConfigKey.parse("datadog/2/APM_TRACING/org_config/config"); + ConfigurationPoller poller = mock(ConfigurationPoller.class); + SharedCommunicationObjects sco = createScoWithPoller(poller); + + ProductListener[] capturedUpdater = {null}; + doAnswer( + inv -> { + capturedUpdater[0] = inv.getArgument(1, ProductListener.class); + return null; + }) + .when(poller) + .addListener(eq(Product.APM_TRACING), any(ProductListener.class)); + + CoreTracer tracer = + CoreTracer.builder().sharedCommunicationObjects(sco).pollForTracingConfiguration().build(); + unclosedTracers.add(tracer); + + try { + TransactionTrackingPatterns.resetForTest(); + assertEquals( + Collections.emptyList(), + tracer.captureTraceConfig().getTransactionTrackingExtractionPatterns()); + assertTrue(TransactionTrackingPatterns.isEmpty()); + + ProductListener updater = capturedUpdater[0]; + updater.accept( + key, + ("{\n" + + " \"service_target\": {\"service\": \"*\", \"env\": \"*\"},\n" + + " \"lib_config\": {\n" + + " \"tt_extraction_patterns\": [\"x-trace-*\", \"*-tenant\"]\n" + + " }\n" + + "}") + .getBytes(StandardCharsets.UTF_8), + null); + updater.commit(null); + + assertEquals( + Arrays.asList("x-trace-*", "*-tenant"), + tracer.captureTraceConfig().getTransactionTrackingExtractionPatterns()); + assertFalse(TransactionTrackingPatterns.isEmpty()); + assertTrue(TransactionTrackingPatterns.matchesAny("X-Trace-Id")); + assertTrue(TransactionTrackingPatterns.matchesAny("customer-tenant")); + assertFalse(TransactionTrackingPatterns.matchesAny("unrelated")); + + // Removing the config should clear the static snapshot back to empty. + updater.remove(key, null); + updater.commit(null); + assertEquals( + Collections.emptyList(), + tracer.captureTraceConfig().getTransactionTrackingExtractionPatterns()); + assertTrue(TransactionTrackingPatterns.isEmpty()); + } finally { + TransactionTrackingPatterns.resetForTest(); + tracer.close(); + } + } + + @Test + void absentTtExtractionPatternsFieldKeepsSnapshotEmpty() throws Exception { + ParsedConfigKey key = ParsedConfigKey.parse("datadog/2/APM_TRACING/org_config/config"); + ConfigurationPoller poller = mock(ConfigurationPoller.class); + SharedCommunicationObjects sco = createScoWithPoller(poller); + + ProductListener[] capturedUpdater = {null}; + doAnswer( + inv -> { + capturedUpdater[0] = inv.getArgument(1, ProductListener.class); + return null; + }) + .when(poller) + .addListener(eq(Product.APM_TRACING), any(ProductListener.class)); + + CoreTracer tracer = + CoreTracer.builder().sharedCommunicationObjects(sco).pollForTracingConfiguration().build(); + unclosedTracers.add(tracer); + + try { + TransactionTrackingPatterns.resetForTest(); + ProductListener updater = capturedUpdater[0]; + updater.accept( + key, + ("{\n" + + " \"service_target\": {\"service\": \"*\", \"env\": \"*\"},\n" + + " \"lib_config\": {\"tracing_sampling_rate\": 0.5}\n" + + "}") + .getBytes(StandardCharsets.UTF_8), + null); + updater.commit(null); + + assertEquals( + Collections.emptyList(), + tracer.captureTraceConfig().getTransactionTrackingExtractionPatterns()); + assertTrue(TransactionTrackingPatterns.isEmpty()); + } finally { + TransactionTrackingPatterns.resetForTest(); + tracer.close(); + } + } + @Test void twoOrgLevelsConfigSettingDifferentFlagsWorks() throws Exception { ParsedConfigKey orgConfig1Key = diff --git a/internal-api/src/main/java/datadog/trace/api/Config.java b/internal-api/src/main/java/datadog/trace/api/Config.java index a463887f61a..094518eddab 100644 --- a/internal-api/src/main/java/datadog/trace/api/Config.java +++ b/internal-api/src/main/java/datadog/trace/api/Config.java @@ -4872,6 +4872,15 @@ public String getDataStreamsTransactionExtractors() { return dataStreamsTransactionExtractors; } + /** + * Static fallback for Transaction Tracking extraction patterns. Always returns an empty list; + * non-empty values are only delivered through {@code APM_TRACING} remote-config (field {@code + * tt_extraction_patterns}). + */ + public java.util.List getTransactionTrackingExtractionPatterns() { + return java.util.Collections.emptyList(); + } + public long getDataStreamsBucketDurationNanoseconds() { // Rounds to the nearest millisecond before converting to nanos int milliseconds = Math.round(dataStreamsBucketDurationSeconds * 1000); diff --git a/internal-api/src/main/java/datadog/trace/api/DynamicConfig.java b/internal-api/src/main/java/datadog/trace/api/DynamicConfig.java index 19a1ca84abf..1e314dc129b 100644 --- a/internal-api/src/main/java/datadog/trace/api/DynamicConfig.java +++ b/internal-api/src/main/java/datadog/trace/api/DynamicConfig.java @@ -17,6 +17,7 @@ import datadog.trace.api.datastreams.DataStreamsTransactionExtractor; import datadog.trace.api.sampling.SamplingRule.SpanSamplingRule; import datadog.trace.api.sampling.SamplingRule.TraceSamplingRule; +import datadog.trace.api.tt.TransactionTrackingPatterns; import java.util.Collection; import java.util.Collections; import java.util.HashMap; @@ -87,6 +88,7 @@ public Builder current() { public void resetTraceConfig() { currentSnapshot = initialSnapshot; reportConfigChange(initialSnapshot); + TransactionTrackingPatterns.update(initialSnapshot.ttExtractionPatterns); } @Override @@ -113,6 +115,7 @@ public final class Builder { Pair preferredServiceNameAndSource; List dataStreamsTransactionExtractors; + List ttExtractionPatterns; Builder() {} @@ -137,6 +140,7 @@ public final class Builder { this.preferredServiceNameAndSource = snapshot.preferredServiceNameAndSource; this.dataStreamsTransactionExtractors = snapshot.dataStreamsTransactionExtractors; + this.ttExtractionPatterns = snapshot.ttExtractionPatterns; } public Builder setRuntimeMetricsEnabled(boolean runtimeMetricsEnabled) { @@ -232,6 +236,15 @@ public Builder setPreferredServiceNameAndSource( return this; } + /** + * Sets the list of {@code *}-glob patterns used by Transaction Tracking to flag inbound HTTP + * header / query-string parameter names. A {@code null} or empty list disables the feature. + */ + public Builder setTransactionTrackingExtractionPatterns(List patterns) { + this.ttExtractionPatterns = patterns; + return this; + } + /** Overwrites the current configuration with a new snapshot. */ public DynamicConfig apply() { S oldSnapshot = currentSnapshot; @@ -243,6 +256,8 @@ public DynamicConfig apply() { currentSnapshot = newSnapshot; reportConfigChange(newSnapshot); } + // Publish the compiled snapshot to the static holder used on the request hot path. + TransactionTrackingPatterns.update(newSnapshot.ttExtractionPatterns); return DynamicConfig.this; } } @@ -335,6 +350,7 @@ public static class Snapshot implements TraceConfig { final Pair preferredServiceNameAndSource; final List dataStreamsTransactionExtractors; + final List ttExtractionPatterns; protected Snapshot(DynamicConfig.Builder builder, Snapshot oldSnapshot) { @@ -357,6 +373,7 @@ protected Snapshot(DynamicConfig.Builder builder, Snapshot oldSnapshot) { this.preferredServiceNameAndSource = builder.preferredServiceNameAndSource; this.dataStreamsTransactionExtractors = builder.dataStreamsTransactionExtractors; + this.ttExtractionPatterns = nullToEmpty(builder.ttExtractionPatterns); } private static Map nullToEmpty(Map mapping) { @@ -432,6 +449,11 @@ public List getDataStreamsTransactionExtractors return dataStreamsTransactionExtractors; } + @Override + public List getTransactionTrackingExtractionPatterns() { + return ttExtractionPatterns; + } + @Override public Map getTracingTags() { return tracingTags; @@ -466,6 +488,8 @@ public String toString() { + tracingTags + ", preferredServiceNameAndSource=" + preferredServiceNameAndSource + + ", ttExtractionPatterns=" + + ttExtractionPatterns + '}'; } } diff --git a/internal-api/src/main/java/datadog/trace/api/TraceConfig.java b/internal-api/src/main/java/datadog/trace/api/TraceConfig.java index 801e8e516b4..ab91ae401fb 100644 --- a/internal-api/src/main/java/datadog/trace/api/TraceConfig.java +++ b/internal-api/src/main/java/datadog/trace/api/TraceConfig.java @@ -55,4 +55,10 @@ public interface TraceConfig { * @return List of Data Streams Transactions extractors. */ List getDataStreamsTransactionExtractors(); + + /** + * Glob patterns used by Transaction Tracking to flag inbound HTTP header / query-string parameter + * names. An empty list disables the feature. + */ + List getTransactionTrackingExtractionPatterns(); } diff --git a/internal-api/src/main/java/datadog/trace/api/tt/TransactionTrackingPatterns.java b/internal-api/src/main/java/datadog/trace/api/tt/TransactionTrackingPatterns.java new file mode 100644 index 00000000000..edc101ee085 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/tt/TransactionTrackingPatterns.java @@ -0,0 +1,188 @@ +package datadog.trace.api.tt; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Snapshot of compiled "transaction tracking" extraction glob patterns delivered through + * remote-config under the {@code APM_TRACING} product (field {@code tt_extraction_patterns}). + * + *

The hot path on every server request only does a single volatile read followed by an {@link + * List#isEmpty()} check when no patterns are configured, so this class is zero-allocation in the + * disabled case. + * + *

Matching is case-insensitive on the candidate name and the supported wildcard alphabet is + * limited to {@code *} (zero-or-more characters). The matcher is hand-rolled to avoid {@link + * java.util.regex.Pattern} compilation on the request hot path. + */ +public final class TransactionTrackingPatterns { + + private static final Logger log = LoggerFactory.getLogger(TransactionTrackingPatterns.class); + + /** Shared empty snapshot — referenced when no patterns are configured. */ + private static final List EMPTY = Collections.emptyList(); + + private static volatile List snapshot = EMPTY; + + private TransactionTrackingPatterns() {} + + /** + * Re-compile the raw pattern list and atomically publish a new snapshot. A {@code null} or empty + * input clears the snapshot back to the no-op default. Malformed entries (null/blank) are dropped + * with a debug log; the remaining entries are kept. + */ + public static void update(List rawPatterns) { + if (rawPatterns == null || rawPatterns.isEmpty()) { + snapshot = EMPTY; + return; + } + List compiled = new ArrayList<>(rawPatterns.size()); + for (String raw : rawPatterns) { + if (raw == null) { + log.debug("Ignoring null tt_extraction_pattern entry"); + continue; + } + String trimmed = raw.trim(); + if (trimmed.isEmpty()) { + log.debug("Ignoring blank tt_extraction_pattern entry"); + continue; + } + compiled.add(CompiledPattern.compile(trimmed)); + } + if (compiled.isEmpty()) { + snapshot = EMPTY; + } else { + snapshot = Collections.unmodifiableList(compiled); + } + } + + /** Fast no-allocation check used as the hot-path guard. */ + public static boolean isEmpty() { + return snapshot.isEmpty(); + } + + /** Returns the current immutable snapshot. */ + public static List currentSnapshot() { + return snapshot; + } + + /** True if {@code candidate} matches any compiled pattern in the current snapshot. */ + public static boolean matchesAny(String candidate) { + if (candidate == null) { + return false; + } + List local = snapshot; + if (local.isEmpty()) { + return false; + } + String lowered = candidate.toLowerCase(Locale.ROOT); + // noinspection ForLoopReplaceableByForEach -- avoid iterator allocation on the hot path + for (int i = 0; i < local.size(); i++) { + if (local.get(i).matchesLowercased(lowered)) { + return true; + } + } + return false; + } + + /** Test-only: replaces the snapshot atomically. */ + public static void resetForTest() { + snapshot = EMPTY; + } + + /** + * Compiled glob pattern. Supports only {@code *} (zero-or-more) wildcard. Comparison is + * case-insensitive: the pattern is lowercased once at compile time and the candidate must be + * lowercased by the caller before invoking {@link #matchesLowercased(String)}. + */ + public static final class CompiledPattern { + private final String original; + private final String[] segments; + private final boolean anchorStart; + private final boolean anchorEnd; + + private CompiledPattern( + String original, String[] segments, boolean anchorStart, boolean anchorEnd) { + this.original = original; + this.segments = segments; + this.anchorStart = anchorStart; + this.anchorEnd = anchorEnd; + } + + static CompiledPattern compile(String raw) { + String lower = raw.toLowerCase(Locale.ROOT); + boolean anchorStart = !lower.startsWith("*"); + boolean anchorEnd = !lower.endsWith("*"); + // hand-rolled split on '*' that preserves empty segments only when needed + List parts = new ArrayList<>(); + int start = 0; + for (int i = 0; i < lower.length(); i++) { + if (lower.charAt(i) == '*') { + if (i > start) { + parts.add(lower.substring(start, i)); + } + start = i + 1; + } + } + if (start < lower.length()) { + parts.add(lower.substring(start)); + } + return new CompiledPattern(raw, parts.toArray(new String[0]), anchorStart, anchorEnd); + } + + public String original() { + return original; + } + + /** Caller must pass an already-lowercased candidate. */ + public boolean matchesLowercased(String candidate) { + // Pattern was just "*" / "***" etc. -> matches anything. + if (segments.length == 0) { + return true; + } + + // Special case: literal pattern (no '*' at all) -> exact match. + if (anchorStart && anchorEnd && segments.length == 1) { + return candidate.equals(segments[0]); + } + + int idx = 0; + int firstFloating = 0; + if (anchorStart) { + String first = segments[0]; + if (!candidate.startsWith(first)) { + return false; + } + idx = first.length(); + firstFloating = 1; + } + + int endIdx = candidate.length(); + int lastFloating = segments.length; // exclusive + if (anchorEnd) { + String last = segments[segments.length - 1]; + int tailStart = candidate.length() - last.length(); + if (tailStart < idx || !candidate.regionMatches(tailStart, last, 0, last.length())) { + return false; + } + endIdx = tailStart; + lastFloating = segments.length - 1; + } + + // walk remaining floating segments via indexOf, all must fit within [idx, endIdx) + for (int segIndex = firstFloating; segIndex < lastFloating; segIndex++) { + String seg = segments[segIndex]; + int found = candidate.indexOf(seg, idx); + if (found < 0 || found + seg.length() > endIdx) { + return false; + } + idx = found + seg.length(); + } + return true; + } + } +} diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java index 14a5b5d6d21..dd3fca04482 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/AgentTracer.java @@ -735,5 +735,10 @@ public List getTraceSamplingRules() { public List getDataStreamsTransactionExtractors() { return null; } + + @Override + public List getTransactionTrackingExtractionPatterns() { + return Collections.emptyList(); + } } } diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/InstrumentationTags.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/InstrumentationTags.java index 0c1054e7776..92b59663c34 100644 --- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/InstrumentationTags.java +++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/InstrumentationTags.java @@ -8,6 +8,10 @@ public class InstrumentationTags { // start looking at generating constants based on the // enabled instrumentations. + // Transaction tracking — extraction sources tag (set by HttpServerDecorator when a configured + // glob pattern matches an inbound HTTP header name or query-string parameter name). + public static final String TT_EXTRACTION_SOURCES = "_dd.tt.extraction_sources"; + public static final String PARTITION = "partition"; public static final String OFFSET = "offset"; public static final String CONSUMER_GROUP = "kafka.group"; diff --git a/internal-api/src/test/java/datadog/trace/api/tt/TransactionTrackingPatternsTest.java b/internal-api/src/test/java/datadog/trace/api/tt/TransactionTrackingPatternsTest.java new file mode 100644 index 00000000000..159d7add80b --- /dev/null +++ b/internal-api/src/test/java/datadog/trace/api/tt/TransactionTrackingPatternsTest.java @@ -0,0 +1,115 @@ +package datadog.trace.api.tt; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.Collections; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class TransactionTrackingPatternsTest { + + @AfterEach + void reset() { + TransactionTrackingPatterns.resetForTest(); + } + + @Test + void emptyByDefault() { + assertTrue(TransactionTrackingPatterns.isEmpty()); + assertFalse(TransactionTrackingPatterns.matchesAny("x-foo")); + } + + @Test + void nullOrEmptyUpdateKeepsEmpty() { + TransactionTrackingPatterns.update(null); + assertTrue(TransactionTrackingPatterns.isEmpty()); + TransactionTrackingPatterns.update(Collections.emptyList()); + assertTrue(TransactionTrackingPatterns.isEmpty()); + } + + @Test + void literalPatternIsExactCaseInsensitive() { + TransactionTrackingPatterns.update(Collections.singletonList("X-Request-Id")); + assertTrue(TransactionTrackingPatterns.matchesAny("x-request-id")); + assertTrue(TransactionTrackingPatterns.matchesAny("X-REQUEST-ID")); + assertFalse(TransactionTrackingPatterns.matchesAny("x-request-id-2")); + assertFalse(TransactionTrackingPatterns.matchesAny("yx-request-id")); + } + + @Test + void prefixWildcard() { + TransactionTrackingPatterns.update(Collections.singletonList("*-id")); + assertTrue(TransactionTrackingPatterns.matchesAny("X-Request-Id")); + assertTrue(TransactionTrackingPatterns.matchesAny("-id")); + assertFalse(TransactionTrackingPatterns.matchesAny("id")); + assertFalse(TransactionTrackingPatterns.matchesAny("X-Request-Token")); + } + + @Test + void suffixWildcard() { + TransactionTrackingPatterns.update(Collections.singletonList("x-trace-*")); + assertTrue(TransactionTrackingPatterns.matchesAny("X-Trace-Id")); + assertTrue(TransactionTrackingPatterns.matchesAny("x-trace-")); + assertFalse(TransactionTrackingPatterns.matchesAny("x-trace")); + assertFalse(TransactionTrackingPatterns.matchesAny("y-trace-id")); + } + + @Test + void middleWildcard() { + TransactionTrackingPatterns.update(Collections.singletonList("x-*-id")); + assertTrue(TransactionTrackingPatterns.matchesAny("X-Request-Id")); + assertTrue(TransactionTrackingPatterns.matchesAny("x--id")); + assertFalse(TransactionTrackingPatterns.matchesAny("x-request")); + assertFalse(TransactionTrackingPatterns.matchesAny("y-request-id")); + } + + @Test + void multipleWildcards() { + TransactionTrackingPatterns.update(Collections.singletonList("*foo*bar*")); + assertTrue(TransactionTrackingPatterns.matchesAny("xxfooyybarzz")); + assertTrue(TransactionTrackingPatterns.matchesAny("foobar")); + assertFalse(TransactionTrackingPatterns.matchesAny("barfoo")); + assertFalse(TransactionTrackingPatterns.matchesAny("fooxx")); + } + + @Test + void starOnlyMatchesAnything() { + TransactionTrackingPatterns.update(Collections.singletonList("*")); + assertTrue(TransactionTrackingPatterns.matchesAny("anything")); + assertTrue(TransactionTrackingPatterns.matchesAny("")); + } + + @Test + void multiplePatternsAnyMatch() { + TransactionTrackingPatterns.update(Arrays.asList("x-foo-*", "*-trace")); + assertTrue(TransactionTrackingPatterns.matchesAny("x-foo-1")); + assertTrue(TransactionTrackingPatterns.matchesAny("dd-trace")); + assertFalse(TransactionTrackingPatterns.matchesAny("dd-other")); + } + + @Test + void blankAndNullEntriesAreSkipped() { + TransactionTrackingPatterns.update(Arrays.asList(null, "", " ", "x-keep")); + assertFalse(TransactionTrackingPatterns.isEmpty()); + assertTrue(TransactionTrackingPatterns.matchesAny("x-keep")); + assertEquals(1, TransactionTrackingPatterns.currentSnapshot().size()); + } + + @Test + void allBlankClearsSnapshot() { + TransactionTrackingPatterns.update(Arrays.asList(null, "", " ")); + assertTrue(TransactionTrackingPatterns.isEmpty()); + } + + @Test + void overlappingSegmentsDoNotMatch() { + TransactionTrackingPatterns.update(Collections.singletonList("ab*ab")); + assertTrue(TransactionTrackingPatterns.matchesAny("abxab")); + assertTrue(TransactionTrackingPatterns.matchesAny("abab")); + assertFalse(TransactionTrackingPatterns.matchesAny("aba")); + assertFalse(TransactionTrackingPatterns.matchesAny("xab")); + } +} From 61ffc5258530bcf1f09b915a9691e0e5e0bce835 Mon Sep 17 00:00:00 2001 From: labbati Date: Fri, 15 May 2026 15:22:53 +0200 Subject: [PATCH 2/4] feat(tt): tag server spans with _dd.tt.extraction_sources Adds the HttpServerDecorator.forEachRequestHeaderName extension point (no-op default) and a guarded tagging block in onRequest. When the TransactionTrackingPatterns snapshot is non-empty the server span gets a single _dd.tt.extraction_sources tag whose value is a deterministic CSV of matching header / query-string parameter names, sorted within each source bucket (headers first, then qs) and lowercased. Fast path on the empty snapshot is a single volatile read + isEmpty() check, no allocation. A negative unit test asserts no tag is set when the pattern list is empty. tag: ai generated --- .../decorator/HttpServerDecorator.java | 129 +++++++++++++ ...HttpServerDecoratorTtExtractionTest.groovy | 181 ++++++++++++++++++ 2 files changed, 310 insertions(+) create mode 100644 dd-java-agent/agent-bootstrap/src/test/groovy/datadog/trace/bootstrap/instrumentation/decorator/HttpServerDecoratorTtExtractionTest.groovy diff --git a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/HttpServerDecorator.java b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/HttpServerDecorator.java index a60401f5811..346423f5f1e 100644 --- a/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/HttpServerDecorator.java +++ b/dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap/instrumentation/decorator/HttpServerDecorator.java @@ -26,12 +26,14 @@ import datadog.trace.api.gateway.RequestContext; import datadog.trace.api.gateway.RequestContextSlot; import datadog.trace.api.naming.SpanNaming; +import datadog.trace.api.tt.TransactionTrackingPatterns; import datadog.trace.bootstrap.instrumentation.api.AgentPropagation; import datadog.trace.bootstrap.instrumentation.api.AgentSpan; import datadog.trace.bootstrap.instrumentation.api.AgentSpanContext; import datadog.trace.bootstrap.instrumentation.api.AgentTracer; import datadog.trace.bootstrap.instrumentation.api.ClientIpAddressData; import datadog.trace.bootstrap.instrumentation.api.ErrorPriorities; +import datadog.trace.bootstrap.instrumentation.api.InstrumentationTags; import datadog.trace.bootstrap.instrumentation.api.InternalSpanTypes; import datadog.trace.bootstrap.instrumentation.api.ResourceNamePriorities; import datadog.trace.bootstrap.instrumentation.api.TagContext; @@ -44,8 +46,10 @@ import java.util.BitSet; import java.util.Locale; import java.util.Map; +import java.util.TreeSet; import java.util.concurrent.ExecutionException; import java.util.function.BiFunction; +import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Supplier; import javax.annotation.Nonnull; @@ -119,6 +123,17 @@ protected String getRequestHeader(REQUEST request, String key) { return null; } + /** + * Iterates the names of every inbound HTTP request header, invoking {@code consumer} once per + * name. Default no-op implementation: subclasses with cheap access to the underlying request's + * header enumeration should override this so the Transaction Tracking extraction-sources tag + * works for that stack. Used only when {@link TransactionTrackingPatterns#isEmpty()} returns + * false. + */ + protected void forEachRequestHeaderName(REQUEST request, Consumer consumer) { + // no-op: stacks without cheap header enumeration silently produce no tag. + } + protected String requestedSessionId(REQUEST request) { return null; } @@ -344,6 +359,15 @@ public AgentSpan onRequest( } catch (final Exception e) { log.debug("Error tagging url", e); } + // Transaction Tracking: tag span with matching header / query-param names when the + // remote-config snapshot is non-empty. Fast path is a single volatile read + isEmpty(). + if (!TransactionTrackingPatterns.isEmpty()) { + try { + tagTransactionTrackingExtractionSources(span, request); + } catch (Exception e) { + log.debug("Error tagging tt extraction sources", e); + } + } } String peerIp = null; @@ -420,6 +444,111 @@ public AgentSpan onRequest( return span; } + /** + * Adds the {@code _dd.tt.extraction_sources} tag based on the currently active {@link + * TransactionTrackingPatterns} snapshot. Caller must have already verified that the snapshot is + * non-empty. + * + *

The tag value is a CSV with deterministic ordering: {@code header:} entries (sorted), then + * {@code qs:} entries (sorted). Names are lowercased and de-duplicated within each bucket. The + * tag is only set if at least one match is found. + */ + private void tagTransactionTrackingExtractionSources(AgentSpan span, REQUEST request) { + if (request == null) { + return; + } + TreeSet headerHits = null; + TreeSet qsHits = null; + + // 1. Header names. + HeaderNameCollector collector = new HeaderNameCollector<>(); + forEachRequestHeaderName(request, collector); + if (collector.matches != null) { + headerHits = collector.matches; + } + + // 2. Query-string parameter names. Re-resolve the URL adapter so this code path does not + // depend on whether the URL block above succeeded. + try { + URIDataAdapter url = url(request); + String rawQuery = url == null ? null : url.rawQuery(); + if (rawQuery != null && !rawQuery.isEmpty()) { + qsHits = collectQueryParameterMatches(rawQuery); + } + } catch (Exception e) { + log.debug("Error resolving URL for tt extraction sources", e); + } + + if (headerHits == null && qsHits == null) { + return; + } + StringBuilder sb = new StringBuilder(); + if (headerHits != null) { + for (String name : headerHits) { + if (sb.length() > 0) { + sb.append(','); + } + sb.append("header:").append(name); + } + } + if (qsHits != null) { + for (String name : qsHits) { + if (sb.length() > 0) { + sb.append(','); + } + sb.append("qs:").append(name); + } + } + if (sb.length() > 0) { + span.setTag(InstrumentationTags.TT_EXTRACTION_SOURCES, sb.toString()); + } + } + + private static TreeSet collectQueryParameterMatches(String rawQuery) { + TreeSet hits = null; + int len = rawQuery.length(); + int start = 0; + while (start <= len) { + int amp = rawQuery.indexOf('&', start); + int end = amp < 0 ? len : amp; + if (end > start) { + int eq = rawQuery.indexOf('=', start); + int nameEnd = (eq < 0 || eq > end) ? end : eq; + if (nameEnd > start) { + String name = rawQuery.substring(start, nameEnd); + if (TransactionTrackingPatterns.matchesAny(name)) { + if (hits == null) { + hits = new TreeSet<>(); + } + hits.add(name.toLowerCase(Locale.ROOT)); + } + } + } + if (amp < 0) { + break; + } + start = amp + 1; + } + return hits; + } + + private static final class HeaderNameCollector implements Consumer { + TreeSet matches; + + @Override + public void accept(String name) { + if (name == null) { + return; + } + if (TransactionTrackingPatterns.matchesAny(name)) { + if (matches == null) { + matches = new TreeSet<>(); + } + matches.add(name.toLowerCase(Locale.ROOT)); + } + } + } + protected static AgentSpanContext.Extracted getExtractedSpanContext(Context parentContext) { AgentSpan extractedSpan = AgentSpan.fromContext(parentContext); if (extractedSpan != null) { diff --git a/dd-java-agent/agent-bootstrap/src/test/groovy/datadog/trace/bootstrap/instrumentation/decorator/HttpServerDecoratorTtExtractionTest.groovy b/dd-java-agent/agent-bootstrap/src/test/groovy/datadog/trace/bootstrap/instrumentation/decorator/HttpServerDecoratorTtExtractionTest.groovy new file mode 100644 index 00000000000..87575d34152 --- /dev/null +++ b/dd-java-agent/agent-bootstrap/src/test/groovy/datadog/trace/bootstrap/instrumentation/decorator/HttpServerDecoratorTtExtractionTest.groovy @@ -0,0 +1,181 @@ +package datadog.trace.bootstrap.instrumentation.decorator + +import datadog.trace.api.tt.TransactionTrackingPatterns +import datadog.trace.bootstrap.instrumentation.api.AgentPropagation +import datadog.trace.bootstrap.instrumentation.api.AgentSpan +import datadog.trace.bootstrap.instrumentation.api.AgentTracer +import datadog.trace.bootstrap.instrumentation.api.AgentTracer.TracerAPI +import datadog.trace.bootstrap.instrumentation.api.ContextVisitors +import datadog.trace.bootstrap.instrumentation.api.InstrumentationTags +import datadog.trace.bootstrap.instrumentation.api.URIDataAdapter +import datadog.trace.bootstrap.instrumentation.api.URIDefaultDataAdapter +import datadog.trace.config.inversion.ConfigHelper +import datadog.trace.test.util.DDSpecification + +import java.util.function.Consumer + +class HttpServerDecoratorTtExtractionTest extends DDSpecification { + + def setupSpec() { + ConfigHelper.get().setConfigInversionStrict(ConfigHelper.StrictnessPolicy.TEST) + } + + def span = Mock(AgentSpan) + Map setTags = [:] + + void setup() { + TransactionTrackingPatterns.resetForTest() + span.setTag(_, _) >> { String k, Object v -> setTags[k] = v; null } + span.getTag(_) >> { String k -> setTags[k] } + } + + void cleanup() { + TransactionTrackingPatterns.resetForTest() + } + + def "no tag when pattern list is empty regardless of headers / qs"() { + setup: + def decorator = newDecorator(["X-Trace-Id", "tenant"], URI.create("http://h/p?tenant=42&debug=1")) + + when: + decorator.onRequest(span, null, [marker: "anything"], datadog.context.Context.root()) + + then: + // Fast path: no allocation, no tag. + setTags[InstrumentationTags.TT_EXTRACTION_SOURCES] == null + } + + def "tags matching headers and qs with deterministic order and lowercasing"() { + setup: + TransactionTrackingPatterns.update(["x-trace-*", "tenant", "*-id"]) + def decorator = newDecorator( + ["X-Trace-Id", "X-Trace-Source", "Authorization", "USER-ID"], + URI.create("http://h/p?tenant=42&debug=1&request-id=abc")) + + when: + decorator.onRequest(span, null, [marker: "anything"], datadog.context.Context.root()) + + then: + def csv = setTags[InstrumentationTags.TT_EXTRACTION_SOURCES] + csv != null + // headers first (sorted), then qs (sorted), all lowercased + deduped per bucket + csv == "header:user-id,header:x-trace-id,header:x-trace-source,qs:request-id,qs:tenant" + } + + def "headers only (no query string)"() { + setup: + TransactionTrackingPatterns.update(["x-foo"]) + def decorator = newDecorator(["X-FOO", "X-Bar"], URI.create("http://h/p")) + + when: + decorator.onRequest(span, null, [:], datadog.context.Context.root()) + + then: + setTags[InstrumentationTags.TT_EXTRACTION_SOURCES] == "header:x-foo" + } + + def "qs only (no header overrides)"() { + setup: + TransactionTrackingPatterns.update(["tenant*"]) + def decorator = newDecorator([], URI.create("http://h/p?tenantId=7&other=x")) + + when: + decorator.onRequest(span, null, [:], datadog.context.Context.root()) + + then: + setTags[InstrumentationTags.TT_EXTRACTION_SOURCES] == "qs:tenantid" + } + + def "no match means no tag even with non-empty patterns"() { + setup: + TransactionTrackingPatterns.update(["nope-*"]) + def decorator = newDecorator(["X-Foo"], URI.create("http://h/p?a=1")) + + when: + decorator.onRequest(span, null, [:], datadog.context.Context.root()) + + then: + setTags[InstrumentationTags.TT_EXTRACTION_SOURCES] == null + } + + def "duplicates within a bucket collapse to one entry"() { + setup: + TransactionTrackingPatterns.update(["x-trace-*"]) + def decorator = newDecorator(["X-Trace-Id", "x-trace-id", "X-TRACE-ID"], URI.create("http://h/p")) + + when: + decorator.onRequest(span, null, [:], datadog.context.Context.root()) + + then: + setTags[InstrumentationTags.TT_EXTRACTION_SOURCES] == "header:x-trace-id" + } + + def newDecorator(List headerNames, URI uri) { + return new HttpServerDecorator>() { + @Override + protected TracerAPI tracer() { + return AgentTracer.NOOP_TRACER + } + + @Override + protected String[] instrumentationNames() { + ["test1", "test2"] + } + + @Override + protected CharSequence component() { + "test-component" + } + + @Override + protected AgentPropagation.ContextVisitor> getter() { + return ContextVisitors.stringValuesMap() + } + + @Override + protected AgentPropagation.ContextVisitor responseGetter() { + null + } + + @Override + CharSequence spanName() { + "http-tt-span" + } + + @Override + protected String method(Map m) { + "GET" + } + + @Override + protected URIDataAdapter url(Map m) { + new URIDefaultDataAdapter(uri) + } + + @Override + protected String peerHostIP(Map m) { + null + } + + @Override + protected int peerPort(Map m) { + 0 + } + + @Override + protected int status(Map m) { + 0 + } + + @Override + protected String getRequestHeader(Map m, String key) { + null + } + + @Override + protected void forEachRequestHeaderName(Map m, Consumer consumer) { + headerNames.each { consumer.accept(it) } + } + } + } +} From bd58e17257652b431ed9badfa072270ec28e3b04 Mon Sep 17 00:00:00 2001 From: labbati Date: Fri, 15 May 2026 15:39:49 +0200 Subject: [PATCH 3/4] feat(tt): enumerate servlet request headers for tt extraction Overrides forEachRequestHeaderName in the javax-servlet 2.2 and 3.0 decorators using HttpServletRequest.getHeaderNames(), so Spring WebMVC running on top of any javax-servlet container (Tomcat, Jetty, etc.) emits the _dd.tt.extraction_sources tag transparently. Adds a Spring WebMVC 5.3 integration test (MockMvc) covering mixed header/qs matches and asserting absence of the tag when the pattern list is empty. The InstrumentationSpecification MOCK_DSM_TRACE_CONFIG now implements the new TraceConfig.getTransactionTrackingExtractionPatterns() with an empty list so the test infrastructure still compiles. tag: ai generated --- .../test/InstrumentationSpecification.groovy | 5 ++ .../servlet2/Servlet2Decorator.java | 22 ++++++ .../servlet3/Servlet3Decorator.java | 22 ++++++ .../test/tt/TtExtractionSpringBootTest.groovy | 79 +++++++++++++++++++ 4 files changed, 128 insertions(+) create mode 100644 dd-java-agent/instrumentation/spring/spring-webmvc/spring-webmvc-5.3/src/test/groovy/test/tt/TtExtractionSpringBootTest.groovy diff --git a/dd-java-agent/instrumentation-testing/src/main/groovy/datadog/trace/agent/test/InstrumentationSpecification.groovy b/dd-java-agent/instrumentation-testing/src/main/groovy/datadog/trace/agent/test/InstrumentationSpecification.groovy index 0c9bfd035b8..320407969fe 100644 --- a/dd-java-agent/instrumentation-testing/src/main/groovy/datadog/trace/agent/test/InstrumentationSpecification.groovy +++ b/dd-java-agent/instrumentation-testing/src/main/groovy/datadog/trace/agent/test/InstrumentationSpecification.groovy @@ -260,6 +260,11 @@ abstract class InstrumentationSpecification extends DDSpecification implements A List getDataStreamsTransactionExtractors() { return null } + + @Override + List getTransactionTrackingExtractionPatterns() { + return Collections.emptyList() + } } @SuppressFBWarnings(value = "AT_STALE_THREAD_WRITE_OF_PRIMITIVE", justification = "The variable is accessed only by the test thread in setup and cleanup.") diff --git a/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-2.2/src/main/java/datadog/trace/instrumentation/servlet2/Servlet2Decorator.java b/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-2.2/src/main/java/datadog/trace/instrumentation/servlet2/Servlet2Decorator.java index b93877f73ed..31f81ce85c1 100644 --- a/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-2.2/src/main/java/datadog/trace/instrumentation/servlet2/Servlet2Decorator.java +++ b/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-2.2/src/main/java/datadog/trace/instrumentation/servlet2/Servlet2Decorator.java @@ -8,6 +8,8 @@ import datadog.trace.bootstrap.instrumentation.api.URIDataAdapter; import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; import datadog.trace.bootstrap.instrumentation.decorator.HttpServerDecorator; +import java.util.Enumeration; +import java.util.function.Consumer; import javax.servlet.http.HttpServletRequest; public class Servlet2Decorator @@ -74,6 +76,26 @@ protected String getRequestHeader(final HttpServletRequest request, String key) return request.getHeader(key); } + @Override + @SuppressWarnings("unchecked") + protected void forEachRequestHeaderName( + final HttpServletRequest request, final Consumer consumer) { + if (request == null) { + return; + } + try { + Enumeration names = request.getHeaderNames(); + if (names == null) { + return; + } + while (names.hasMoreElements()) { + consumer.accept(names.nextElement()); + } + } catch (Throwable ignored) { + // best-effort + } + } + @Override protected int status(final Integer status) { return status; diff --git a/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/main/java/datadog/trace/instrumentation/servlet3/Servlet3Decorator.java b/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/main/java/datadog/trace/instrumentation/servlet3/Servlet3Decorator.java index 61a8d22a2b8..5733815135f 100644 --- a/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/main/java/datadog/trace/instrumentation/servlet3/Servlet3Decorator.java +++ b/dd-java-agent/instrumentation/servlet/javax-servlet/javax-servlet-3.0/src/main/java/datadog/trace/instrumentation/servlet3/Servlet3Decorator.java @@ -7,6 +7,8 @@ import datadog.trace.bootstrap.instrumentation.api.URIDataAdapter; import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; import datadog.trace.bootstrap.instrumentation.decorator.HttpServerDecorator; +import java.util.Enumeration; +import java.util.function.Consumer; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @@ -82,6 +84,26 @@ protected String getRequestHeader(final HttpServletRequest request, String key) return request.getHeader(key); } + @Override + protected void forEachRequestHeaderName( + final HttpServletRequest request, final Consumer consumer) { + if (request == null) { + return; + } + try { + Enumeration names = request.getHeaderNames(); + if (names == null) { + return; + } + while (names.hasMoreElements()) { + consumer.accept(names.nextElement()); + } + } catch (Throwable ignored) { + // some containers throw if headers are accessed at the wrong lifecycle stage; + // silently skip — the tt extraction-sources tag is best-effort. + } + } + @Override public AgentSpan onRequest( final AgentSpan span, diff --git a/dd-java-agent/instrumentation/spring/spring-webmvc/spring-webmvc-5.3/src/test/groovy/test/tt/TtExtractionSpringBootTest.groovy b/dd-java-agent/instrumentation/spring/spring-webmvc/spring-webmvc-5.3/src/test/groovy/test/tt/TtExtractionSpringBootTest.groovy new file mode 100644 index 00000000000..97b6513a5ff --- /dev/null +++ b/dd-java-agent/instrumentation/spring/spring-webmvc/spring-webmvc-5.3/src/test/groovy/test/tt/TtExtractionSpringBootTest.groovy @@ -0,0 +1,79 @@ +package test.tt + +import datadog.trace.agent.test.InstrumentationSpecification +import datadog.trace.api.DDSpanTypes +import datadog.trace.api.tt.TransactionTrackingPatterns +import datadog.trace.bootstrap.instrumentation.api.InstrumentationTags +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Controller +import org.springframework.test.web.servlet.MockMvc +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestMethod +import org.springframework.web.servlet.config.annotation.EnableWebMvc + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get + +@SpringBootTest(classes = TtExtractionSpringBootTest.TtController) +@EnableWebMvc +@AutoConfigureMockMvc +class TtExtractionSpringBootTest extends InstrumentationSpecification { + + @Controller + static class TtController { + @RequestMapping(value = "/tt", method = [RequestMethod.GET]) + ResponseEntity tt() { + return new ResponseEntity<>("ok", HttpStatus.OK) + } + } + + @Autowired + private MockMvc mvc + + def cleanup() { + TransactionTrackingPatterns.resetForTest() + } + + def 'sets _dd.tt.extraction_sources for mixed header and qs matches'() { + setup: + TransactionTrackingPatterns.update(["x-trace-*", "tenant", "*-id"]) + + when: + mvc.perform( + get("/tt?tenant=42&debug=1&request-id=abc") + .header("X-Trace-Id", "t1") + .header("X-Trace-Source", "test") + .header("Authorization", "secret") + ).andExpect({ res -> assert res.response.status == 200 }) + + TEST_WRITER.waitForTraces(1) + def serverSpan = TEST_WRITER.firstTrace().find { it.spanType == DDSpanTypes.HTTP_SERVER } + + then: + serverSpan != null + serverSpan.getTag(InstrumentationTags.TT_EXTRACTION_SOURCES) == + "header:x-trace-id,header:x-trace-source,qs:request-id,qs:tenant" + } + + def 'does not set the tag when the pattern list is empty'() { + setup: + TransactionTrackingPatterns.resetForTest() + assert TransactionTrackingPatterns.isEmpty() + + when: + mvc.perform( + get("/tt?tenant=42") + .header("X-Trace-Id", "t1") + ).andExpect({ res -> assert res.response.status == 200 }) + + TEST_WRITER.waitForTraces(1) + def serverSpan = TEST_WRITER.firstTrace().find { it.spanType == DDSpanTypes.HTTP_SERVER } + + then: + serverSpan != null + serverSpan.getTag(InstrumentationTags.TT_EXTRACTION_SOURCES) == null + } +} From 083f15fe0474516eb4c6d3fe040692eae9adbce5 Mon Sep 17 00:00:00 2001 From: labbati Date: Fri, 15 May 2026 15:40:23 +0200 Subject: [PATCH 4/4] docs(tt): document tt_extraction_patterns and the new span tag tag: ai generated tag: no release note --- docs/transaction_tracking_extraction.md | 50 +++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 docs/transaction_tracking_extraction.md diff --git a/docs/transaction_tracking_extraction.md b/docs/transaction_tracking_extraction.md new file mode 100644 index 00000000000..3ce30f4ddc2 --- /dev/null +++ b/docs/transaction_tracking_extraction.md @@ -0,0 +1,50 @@ +# Transaction Tracking — extraction sources + +The tracer reads an optional list of `*`-glob patterns from the existing +`APM_TRACING` remote-config product under the field `tt_extraction_patterns`. + +When that list is non-empty, every server span gets a single tag +`_dd.tt.extraction_sources` whose value is a CSV of matching inbound HTTP +header names and query-string parameter names. + +## Wire format + +```json +{ + "lib_config": { + "tt_extraction_patterns": ["x-trace-*", "tenant", "*-id"] + } +} +``` + +- Patterns support only `*` (zero-or-more). Matching is case-insensitive on + the candidate name. Values are never inspected. +- Empty or missing list disables the feature; the next request is back to + a single volatile read + `isEmpty()` check (no allocation). + +## Tag shape + +- Tag key: `_dd.tt.extraction_sources` (constant + `InstrumentationTags.TT_EXTRACTION_SOURCES`). +- Value: deterministic CSV. `header:` entries are emitted + first in alphabetical order, followed by `qs:` entries + in alphabetical order. Duplicates within a bucket are collapsed. +- The tag is set only when at least one match is found. + +Example: with patterns `["x-trace-*", "tenant", "*-id"]` and an inbound +request bearing the headers `X-Trace-Id`, `X-Trace-Source`, `Authorization` +and the query string `?tenant=42&debug=1&request-id=abc`, the tag value is: + +``` +header:x-trace-id,header:x-trace-source,qs:request-id,qs:tenant +``` + +## Coverage + +The feature is implemented at the `HttpServerDecorator` layer, with the +`forEachRequestHeaderName` extension point overridden in the `javax-servlet` +2.2 and 3.0 decorators. Stacks built on top of those (Spring WebMVC, the +typical Tomcat / Jetty servlet path) get the tag transparently. Stacks +whose decorator does not override `forEachRequestHeaderName` (Netty, +Vert.x, WebFlux, …) fall back to the no-op default and silently produce +no tag until someone wires the override for that stack.