From 96e7535c589e8c889143bb1cc64a23f64159a73f Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Thu, 9 Jul 2026 16:40:11 +0200 Subject: [PATCH 1/5] profiling(ddprof): migrate context bridge to the all-native API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch the profiler context bridge off the deprecated DirectByteBuffer (DBB) context API onto java-profiler's all-native API (setTraceContext / clearTraceContext / setContextValue / clearContextValue). This eliminates the virtual-thread use-after-free (the DBB cached buffer that dangled on carrier migration) and folds the per-activation sequence (setContext + two setContextValue) into a single native call. - DatadogProfilingIntegration.activate: one setTraceContext(...) carrying trace/span context + operation and resource attributes (3 JNI calls -> 1); close/clearContext: clearTraceContext() (wipes op/resource slots too). - DatadogProfiler: setContextValue/clearContextValue/reapplyAppContext/ syncNativeAppContext are now all-native. reapplyAppContext uses a per-slot native setContextValue loop (native setContextValue publishes valid=1, so app context stays visible without an active span — preserves PR #11646). A native batch reapply is deferred to a measured follow-up (java-profiler PROF-15361). - AppContextSnapshot simplified to strings-only (the native path resolves each value's encoding via the process-wide cache; no cached id/utf8/snapshotTags). - snapshot() uses the new native copyContextTags read (no ThreadContext/DBB, so it observes native writes without resetting the record). - ContextSetter kept only for offsetOf + size (pure Java); no DBB usage remains. Requires java-profiler with the all-native API (ddprof >= the phase-1 release; DataDog/java-profiler#631). Build/test with -PddprofUseSnapshot=true against a local publishToMavenLocal 1.47.0-SNAPSHOT until that ships. ddprof suite is Linux-gated (assumeTrue(isLinux)) — verify on Linux/CI. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../profiling/ddprof/DatadogProfiler.java | 174 +++++++++--------- .../ddprof/DatadogProfilingIntegration.java | 24 +-- 2 files changed, 98 insertions(+), 100 deletions(-) diff --git a/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfiler.java b/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfiler.java index df1b75789db..231dc455a58 100644 --- a/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfiler.java +++ b/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfiler.java @@ -44,7 +44,6 @@ import datadog.trace.bootstrap.instrumentation.api.TaskWrapper; import datadog.trace.util.TempLocationManager; import java.io.IOException; -import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.attribute.PosixFilePermissions; @@ -119,22 +118,12 @@ public static DatadogProfiler newInstance(ConfigProvider configProvider) { /** * Per-thread snapshot of application attribute values. Lazily allocated; only threads that call - * setContextValue for an app attribute ever allocate. Holds the ddprof constant ID and - * pre-encoded UTF-8 bytes for each slot, ready for a zero-allocation reapply via - * setContextValuesByIdAndBytes. + * setContextValue for an app attribute ever allocate. Holds the String value for each slot; the + * all-native reapply resolves each value's encoding through the process-wide value cache. */ private final ThreadLocal appContextValues = new ThreadLocal<>(); private final ThreadLocal scopeStack = new ThreadLocal<>(); - // Scratch buffer for snapshotTags in recordAppContextValue; per-thread, sized to context slots. - // Lives here rather than on AppContextSnapshot so save-slots in ScopeStack don't carry it. - private final ThreadLocal contextScratch = - new ThreadLocal() { - @Override - protected int[] initialValue() { - return new int[isAppOffset.length]; - } - }; /** Per-thread stack of pre-allocated save slots for {@link DatadogProfilingScope}. */ static final class ScopeStack { @@ -170,35 +159,28 @@ void release() { // Package-private so DatadogProfilingScope can hold a typed reference for save/restore. static final class AppContextSnapshot { - private final int[] ids; - private final byte[][] utf8; - // Cached string values for change detection — avoids re-encoding and re-snapshotting - // the constant ID when the same value is set again on the same thread. + // App-managed attribute values, indexed by slot. On the all-native path a value is written via + // JavaProfiler.setContextValue (which resolves it through the process-wide value cache), so the + // snapshot only needs the String — no cached constant ID / UTF-8 bytes. private final String[] strings; - // Count of slots with a non-zero constant ID; allows O(1) isEmpty(). + // Count of slots with a non-null value; allows O(1) isEmpty(). private int nonZeroCount; AppContextSnapshot(int size) { - ids = new int[size]; - utf8 = new byte[size][]; strings = new String[size]; } - void record(int offset, int constantId, byte[] utf8Bytes, String value) { - if (ids[offset] == 0 && constantId != 0) { + void record(int offset, String value) { + if (strings[offset] == null && value != null) { nonZeroCount++; - } else if (ids[offset] != 0 && constantId == 0) { + } else if (strings[offset] != null && value == null) { nonZeroCount--; } - ids[offset] = constantId; - utf8[offset] = utf8Bytes; strings[offset] = value; } void clear(int offset) { - if (ids[offset] != 0) nonZeroCount--; - ids[offset] = 0; - utf8[offset] = null; + if (strings[offset] != null) nonZeroCount--; strings[offset] = null; } @@ -214,24 +196,12 @@ String stringAt(int offset) { return strings[offset]; } - int[] ids() { - return ids; - } - - byte[][] utf8() { - return utf8; - } - void copyFrom(AppContextSnapshot src) { - System.arraycopy(src.ids, 0, ids, 0, ids.length); - System.arraycopy(src.utf8, 0, utf8, 0, utf8.length); System.arraycopy(src.strings, 0, strings, 0, strings.length); nonZeroCount = src.nonZeroCount; } void reset() { - Arrays.fill(ids, 0); - Arrays.fill(utf8, null); Arrays.fill(strings, null); nonZeroCount = 0; } @@ -271,7 +241,9 @@ private DatadogProfiler(ConfigProvider configProvider) { this.orderedContextAttributes = getOrderedContextAttributes(contextAttributes, configProvider); this.contextSetter = new ContextSetter(profiler, orderedContextAttributes); // ContextSetter deduplicates and truncates to 10 internally; size arrays to its actual size. - int contextSize = contextSetter.snapshotTags().length; + // size() is a pure-Java count (no DBB read) — kept alongside offsetOf as the only ContextSetter + // methods this bridge still uses; all context writes/reads are all-native. + int contextSize = contextSetter.size(); boolean[] appOffsets = new boolean[contextSize]; boolean anyApp = false; for (String attribute : contextAttributes) { @@ -514,32 +486,64 @@ public int offsetOf(String attribute) { return contextSetter.offsetOf(attribute); } - public void setSpanContext(long rootSpanId, long spanId, long traceIdHigh, long traceIdLow) { + /** + * Combined per-activation write: full trace/span context plus the span-derived operation and + * resource attributes, in a single native call, followed by a reapply of app-managed attributes + * (the native call resets all custom slots). Replaces the previous {@code setContext} + two + * {@code setContextValue} calls. A negative attribute offset (or null value) skips that + * attribute. + */ + public void setTraceContext( + long rootSpanId, + long spanId, + long traceIdHigh, + long traceIdLow, + int operationOffset, + CharSequence operationName, + int resourceOffset, + CharSequence resourceName) { debugLogging(rootSpanId); try { - profiler.setContext(rootSpanId, spanId, traceIdHigh, traceIdLow); + profiler.setTraceContext( + rootSpanId, + spanId, + traceIdHigh, + traceIdLow, + operationOffset, + operationName, + resourceOffset, + resourceName); } catch (Throwable e) { - log.debug("Failed to clear context", e); + log.debug("Failed to set trace context", e); } reapplyAppContext(); } - public void clearSpanContext() { + /** Per-deactivation clear; reapplies app-managed attributes afterwards (see setTraceContext). */ + public void clearTraceContext() { debugLogging(0L); try { - profiler.setContext(0L, 0L, 0L, 0L); + profiler.clearTraceContext(); } catch (Throwable e) { - log.debug("Failed to set context", e); + log.debug("Failed to clear trace context", e); } reapplyAppContext(); } + public void setSpanContext(long rootSpanId, long spanId, long traceIdHigh, long traceIdLow) { + setTraceContext(rootSpanId, spanId, traceIdHigh, traceIdLow, -1, null, -1, null); + } + + public void clearSpanContext() { + clearTraceContext(); + } + public boolean setContextValue(int offset, String value) { - if (contextSetter != null && offset >= 0 && value != null) { + if (offset >= 0 && value != null) { try { // Native call first; snapshot updated only on success so Java and ddprof state stay in // sync. - if (contextSetter.setContextValue(offset, value)) { + if (profiler.setContextValue(offset, value)) { recordAppContextValue(offset, value); return true; } @@ -565,13 +569,13 @@ public boolean clearContextValue(String attribute) { } public boolean clearContextValue(int offset) { - if (contextSetter != null && offset >= 0) { + if (offset >= 0) { try { // Native call first; snapshot updated only after it returns so a throw leaves both sides // consistent. - boolean cleared = contextSetter.clearContextValue(offset); + profiler.clearContextValue(offset); recordAppContextValue(offset, null); - return cleared; + return true; } catch (Throwable t) { log.debug("Failed to clear context value", t); } @@ -581,19 +585,16 @@ public boolean clearContextValue(int offset) { /** * Re-applies this thread's application-managed context attributes after a span activation or - * deactivation. ddprof's {@code setContext} clears all custom attribute slots; this restores only - * the app-owned ones so they remain visible during the new span's lifetime (or after the last - * span closes). No-op when no application attributes are configured or none have been set on this - * thread. + * deactivation. The native {@code setTraceContext}/{@code clearTraceContext} clears all custom + * attribute slots; this restores the app-owned ones so they remain visible during the new span's + * lifetime — or after the last span closes, since native {@code setContextValue} publishes the + * record (valid=1) even with no active span. No-op when no application attributes are configured + * or none have been set on this thread. * - *

Fast path (span activation): uses the pre-computed constant IDs and UTF-8 bytes from {@link - * #recordAppContextValue} in a single {@code setContextValuesByIdAndBytes} call — no String - * allocation, no hash lookup. - * - *

Fallback (span deactivation via {@code setContext(0,0,0,0)}): {@code clearContextDirect} - * calls {@code detach()} but not {@code attach()}, leaving the thread's {@code validOffset=0}. - * {@code setContextValuesByIdAndBytes} returns {@code false} in that state, so we fall back to - * individual {@code setContextValue} calls which go through the proper detach/attach cycle. + *

Per-slot native {@code setContextValue} calls (each resolved through the process-wide value + * cache, so no re-encoding on a hit). A single-JNI-call batch is a possible future optimization + * (see java-profiler PROF-15361); per-slot preserves the prior shape and is adequate for the + * typical small app-attribute count. */ public void reapplyAppContext() { if (!hasAppContext) { @@ -604,16 +605,12 @@ public void reapplyAppContext() { return; } try { - if (!contextSetter.setContextValuesByIdAndBytes(snapshot.ids(), snapshot.utf8())) { - // validOffset=0 after clearContextDirect (setContext(0,0,0,0) path) — fall back to - // individual writes which go through the proper detach/attach cycle. - int remaining = snapshot.nonZeroCount(); - for (int i = 0; i < isAppOffset.length && remaining > 0; i++) { - String s = snapshot.stringAt(i); - if (s != null) { - contextSetter.setContextValue(i, s); - remaining--; - } + int remaining = snapshot.nonZeroCount(); + for (int i = 0; i < isAppOffset.length && remaining > 0; i++) { + String s = snapshot.stringAt(i); + if (s != null) { + profiler.setContextValue(i, s); + remaining--; } } } catch (Throwable e) { @@ -625,7 +622,6 @@ public void reapplyAppContext() { void clearAppContextSnapshot() { appContextValues.remove(); scopeStack.remove(); - contextScratch.remove(); } /** @@ -637,7 +633,7 @@ void clearAppContextSnapshot() { * the restored Java-side snapshot right away, without waiting for the next span activation. */ void syncNativeAppContext() { - if (!hasAppContext || contextSetter == null) { + if (!hasAppContext) { return; } AppContextSnapshot snapshot = appContextValues.get(); @@ -648,9 +644,9 @@ void syncNativeAppContext() { } String value = snapshot != null ? snapshot.stringAt(i) : null; if (value != null) { - contextSetter.setContextValue(i, value); + profiler.setContextValue(i, value); } else { - contextSetter.clearContextValue(i); + profiler.clearContextValue(i); } } } catch (Throwable e) { @@ -719,13 +715,10 @@ private void recordAppContextValue(int offset, String value) { snapshot = new AppContextSnapshot(isAppOffset.length); appContextValues.set(snapshot); } + // The all-native path resolves the value → encoding via the process-wide cache on reapply, so + // the snapshot only needs the String (no per-thread snapshotTags read of the encoding). if (!value.equals(snapshot.stringAt(offset))) { - byte[] utf8Bytes = value.getBytes(StandardCharsets.UTF_8); - // ContextSetter has no single-slot readback API; snapshotTags fills all slots at once. - // The scratch array is per-thread and reused across calls, so this is allocation-free. - int[] scratch = contextScratch.get(); - contextSetter.snapshotTags(scratch); - snapshot.record(offset, scratch[offset], utf8Bytes, value); + snapshot.record(offset, value); } } @@ -736,10 +729,15 @@ private void debugLogging(long localRootSpanId) { } public int[] snapshot() { - if (contextSetter != null) { - return contextSetter.snapshotTags(); - } - return EMPTY; + int n = isAppOffset.length; + if (n == 0) { + return EMPTY; + } + // Native read of the current thread's sidecar tag encodings — no ThreadContext / DBB (which + // would reset the record). Observes encodings written through the all-native path. + int[] tags = new int[n]; + profiler.copyContextTags(tags); + return tags; } public void recordSetting(String name, String value) { diff --git a/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfilingIntegration.java b/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfilingIntegration.java index 65462a46bce..45f5f5fb015 100644 --- a/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfilingIntegration.java +++ b/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfilingIntegration.java @@ -32,24 +32,26 @@ public class DatadogProfilingIntegration implements ProfilingContextIntegration new Stateful() { @Override public void close() { - DDPROF.clearSpanContext(); - DDPROF.clearContextValue(SPAN_NAME_INDEX); - DDPROF.clearContextValue(RESOURCE_NAME_INDEX); + // clearTraceContext wipes all custom slots (incl. operation/resource) and reapplies + // app-managed context, so no separate clearContextValue calls are needed. + DDPROF.clearTraceContext(); } @Override public void activate(Object context) { if (context instanceof ProfilerContext) { ProfilerContext profilerContext = (ProfilerContext) context; - // setSpanContext() calls reapplyAppContext() internally, so no explicit call is needed. - DDPROF.setSpanContext( + // One native call: trace/span context + operation and resource attributes, then + // reapply of app-managed context (setTraceContext resets custom slots). + DDPROF.setTraceContext( profilerContext.getRootSpanId(), profilerContext.getSpanId(), profilerContext.getTraceIdHigh(), - profilerContext.getTraceIdLow()); - DDPROF.setContextValue(SPAN_NAME_INDEX, profilerContext.getOperationName().toString()); - DDPROF.setContextValue( - RESOURCE_NAME_INDEX, profilerContext.getResourceName().toString()); + profilerContext.getTraceIdLow(), + SPAN_NAME_INDEX, + profilerContext.getOperationName(), + RESOURCE_NAME_INDEX, + profilerContext.getResourceName()); } } }; @@ -79,9 +81,7 @@ public String name() { } public void clearContext() { - DDPROF.clearSpanContext(); - DDPROF.clearContextValue(SPAN_NAME_INDEX); - DDPROF.clearContextValue(RESOURCE_NAME_INDEX); + DDPROF.clearTraceContext(); } @Override From d1ef668995ec67b5c0a13c6d9b4afca742bcecf1 Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Mon, 13 Jul 2026 11:47:57 +0000 Subject: [PATCH 2/5] profiling(ddprof): guard zero span in setTraceContext; document clearContextValue The native setTraceContext rejects spanId==0 with IllegalArgumentException (it is the activation path; clearing is clearTraceContext). The bridge's catch(Throwable) would swallow that throw and leave the previous span's context stale on the thread. Span ids are non-zero by construction (IdGenerationStrategy never yields 0, DDSpanId.ZERO means "no span", and DDSpanContext is the only ProfilerContext), so this is defensive: route a zero span to a clean clearTraceContext instead of a silently-swallowed throw over stale state. Also document clearContextValue(int)'s return contract (@param/@return) and add a testContextRegistration scenario asserting a zero-span activation does not throw and still reapplies app context. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../profiling/ddprof/DatadogProfiler.java | 21 +++++++++++++++++ .../profiling/ddprof/DatadogProfilerTest.java | 23 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfiler.java b/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfiler.java index 231dc455a58..0f1b2256443 100644 --- a/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfiler.java +++ b/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfiler.java @@ -502,6 +502,16 @@ public void setTraceContext( CharSequence operationName, int resourceOffset, CharSequence resourceName) { + if (spanId == 0) { + // The native setTraceContext is the activation path and rejects a zero span with + // IllegalArgumentException — which the catch below would swallow, leaving the previous + // span's context stale on this thread. Span ids are non-zero by construction + // (IdGenerationStrategy never returns 0; DDSpanId.ZERO means "no span"), so a zero here is + // not expected; degrade to a clean clear rather than a silently-swallowed throw over stale + // state. + clearTraceContext(); + return; + } debugLogging(rootSpanId); try { profiler.setTraceContext( @@ -568,6 +578,17 @@ public boolean clearContextValue(String attribute) { return false; } + /** + * Clears the app-managed context attribute at {@code offset} on this thread (native slot plus the + * per-thread snapshot). The underlying native {@code clearContextValue} is best-effort and + * returns no status. No caller currently consumes the result; it is kept for symmetry with {@link + * #setContextValue(int, String)} and to signal an unconfigured/failed clear. + * + * @param offset the app-managed context-attribute slot to clear; a negative value (an + * unconfigured attribute) is a no-op + * @return {@code true} if a valid, non-negative {@code offset} was cleared without error; {@code + * false} if {@code offset} is negative or the native clear threw + */ public boolean clearContextValue(int offset) { if (offset >= 0) { try { diff --git a/dd-java-agent/agent-profiling/profiling-ddprof/src/test/java/com/datadog/profiling/ddprof/DatadogProfilerTest.java b/dd-java-agent/agent-profiling/profiling-ddprof/src/test/java/com/datadog/profiling/ddprof/DatadogProfilerTest.java index 6dcb3e9d231..74b43d90066 100644 --- a/dd-java-agent/agent-profiling/profiling-ddprof/src/test/java/com/datadog/profiling/ddprof/DatadogProfilerTest.java +++ b/dd-java-agent/agent-profiling/profiling-ddprof/src/test/java/com/datadog/profiling/ddprof/DatadogProfilerTest.java @@ -455,6 +455,29 @@ public void testContextRegistration() { scope6.close(); }, "Acceptance 6: DatadogProfilingScope.close() must not throw even when scopeStack is absent"); + + // Guard: a zero span id must not throw and must degrade to a clean clear that still reapplies + // app context. The native setTraceContext rejects spanId==0 (IllegalArgumentException); the + // bridge routes a zero span to clearTraceContext rather than letting that throw be swallowed + // over stale context. Span ids are non-zero by construction, so this only exercises the + // defensive path. Note: the clean-clear-vs-stale distinction lives in the trace/span and + // operation/resource slots, which this fixture does not expose (operation/resource context + // attributes are not configured here, and snapshot() reads only custom app-attribute + // encodings); so this locks the observable contract — no throw, app context preserved. + profiler.clearSpanContext(); + profiler.clearContextValue("foo"); + profiler.clearAppContextSnapshot(); + fooSetter.set("zero-span-guard"); + assertNotEquals( + 0, profiler.snapshot()[fooOffset], "foo must be live before the zero-span activation"); + assertDoesNotThrow( + () -> profiler.setSpanContext(1L, 0L, 0L, 1L), + "Guard: a zero span id must not throw (routed to clearTraceContext, not a swallowed IAE)"); + assertNotEquals( + 0, + profiler.snapshot()[fooOffset], + "Guard: zero-span activation must degrade to a clean clear that still reapplies app context"); + profiler.clearContextValue("foo"); } private static ConfigProvider configProvider( From b05dd3f4200bf5edbd2a7df34b88d9c75cff8201 Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Mon, 13 Jul 2026 12:40:48 +0000 Subject: [PATCH 3/5] profiling(ddprof): describe current behavior in comments; drop historical references Rewrite or remove comments that documented the superseded DirectByteBuffer context API and the ddprof-version history of the bridge (e.g. "Replaces the previous setContext...", "no DBB read", the 1.41.0/1.45.0 no-op history in DatadogProfilingScope). Those explain how the code got here, not what it does now; the commit history and PRs carry that evolution. Comment-only, behavior unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../profiling/ddprof/DatadogProfiler.java | 32 ++++++++----------- .../ddprof/DatadogProfilingScope.java | 5 --- 2 files changed, 14 insertions(+), 23 deletions(-) diff --git a/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfiler.java b/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfiler.java index 0f1b2256443..5e85db941c8 100644 --- a/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfiler.java +++ b/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfiler.java @@ -110,16 +110,16 @@ public static DatadogProfiler newInstance(ConfigProvider configProvider) { private final List orderedContextAttributes; // True for each attribute slot that was configured by the application (e.g. foo, bar). - // ddprof wipes all custom slots on setContext; these slots are re-applied via - // reapplyAppContext() on span activation. + // setTraceContext/clearTraceContext reset all custom slots; these app-owned slots are + // re-applied afterwards via reapplyAppContext(). private final boolean[] isAppOffset; private final boolean hasAppContext; /** * Per-thread snapshot of application attribute values. Lazily allocated; only threads that call - * setContextValue for an app attribute ever allocate. Holds the String value for each slot; the - * all-native reapply resolves each value's encoding through the process-wide value cache. + * setContextValue for an app attribute ever allocate. Holds the String value for each slot; + * reapply resolves each value's encoding through the process-wide value cache. */ private final ThreadLocal appContextValues = new ThreadLocal<>(); @@ -159,9 +159,9 @@ void release() { // Package-private so DatadogProfilingScope can hold a typed reference for save/restore. static final class AppContextSnapshot { - // App-managed attribute values, indexed by slot. On the all-native path a value is written via - // JavaProfiler.setContextValue (which resolves it through the process-wide value cache), so the - // snapshot only needs the String — no cached constant ID / UTF-8 bytes. + // App-managed attribute values, indexed by slot. A value is written via + // JavaProfiler.setContextValue, which resolves it through the process-wide value cache, so the + // snapshot only needs the String value. private final String[] strings; // Count of slots with a non-null value; allows O(1) isEmpty(). private int nonZeroCount; @@ -241,8 +241,7 @@ private DatadogProfiler(ConfigProvider configProvider) { this.orderedContextAttributes = getOrderedContextAttributes(contextAttributes, configProvider); this.contextSetter = new ContextSetter(profiler, orderedContextAttributes); // ContextSetter deduplicates and truncates to 10 internally; size arrays to its actual size. - // size() is a pure-Java count (no DBB read) — kept alongside offsetOf as the only ContextSetter - // methods this bridge still uses; all context writes/reads are all-native. + // size() and offsetOf are pure-Java; they are the only ContextSetter methods this bridge uses. int contextSize = contextSetter.size(); boolean[] appOffsets = new boolean[contextSize]; boolean anyApp = false; @@ -489,9 +488,8 @@ public int offsetOf(String attribute) { /** * Combined per-activation write: full trace/span context plus the span-derived operation and * resource attributes, in a single native call, followed by a reapply of app-managed attributes - * (the native call resets all custom slots). Replaces the previous {@code setContext} + two - * {@code setContextValue} calls. A negative attribute offset (or null value) skips that - * attribute. + * (the native call resets all custom slots). A negative attribute offset (or null value) skips + * that attribute. */ public void setTraceContext( long rootSpanId, @@ -614,8 +612,7 @@ public boolean clearContextValue(int offset) { * *

Per-slot native {@code setContextValue} calls (each resolved through the process-wide value * cache, so no re-encoding on a hit). A single-JNI-call batch is a possible future optimization - * (see java-profiler PROF-15361); per-slot preserves the prior shape and is adequate for the - * typical small app-attribute count. + * (java-profiler PROF-15361); per-slot is adequate for the typical small app-attribute count. */ public void reapplyAppContext() { if (!hasAppContext) { @@ -736,8 +733,8 @@ private void recordAppContextValue(int offset, String value) { snapshot = new AppContextSnapshot(isAppOffset.length); appContextValues.set(snapshot); } - // The all-native path resolves the value → encoding via the process-wide cache on reapply, so - // the snapshot only needs the String (no per-thread snapshotTags read of the encoding). + // Reapply resolves the value → encoding via the process-wide cache, so the snapshot only needs + // the String value. if (!value.equals(snapshot.stringAt(offset))) { snapshot.record(offset, value); } @@ -754,8 +751,7 @@ public int[] snapshot() { if (n == 0) { return EMPTY; } - // Native read of the current thread's sidecar tag encodings — no ThreadContext / DBB (which - // would reset the record). Observes encodings written through the all-native path. + // Native read of the current thread's sidecar tag encodings; does not reset the record. int[] tags = new int[n]; profiler.copyContextTags(tags); return tags; diff --git a/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfilingScope.java b/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfilingScope.java index 27454cdc851..eda276416ac 100644 --- a/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfilingScope.java +++ b/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfilingScope.java @@ -46,11 +46,6 @@ public void close() { // Restores the app-managed context slots that were active when this scope opened. // Span context slots are NOT touched here; they are managed independently by the // tracer via DatadogProfilingIntegration.activate()/close(). - // - // Prior to ddprof 1.45.0 this method was a no-op: ddprof 1.41.0 removed the - // int-encoding setter that the previous snapshot/restore relied on, leaving no - // supported API for restoring context. The new setContextValuesByIdAndBytes API - // (1.45.0) makes targeted per-slot restore possible again — for app slots only. profiler.restoreAppContext(savedAppContext); profiler.syncNativeAppContext(); } From 6b06d16b5f697d411559c4dbc37e337328fc60ef Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Fri, 24 Jul 2026 06:47:25 +0000 Subject: [PATCH 4/5] profiling(ddprof): fix JMH benchmark for strings-only AppContextSnapshot AppContextSnapshotBenchmark.setup() still called the old record(int, int, byte[], String) signature; AppContextSnapshot.record is now record(int, String) (strings-only, per the all-native context migration). compileJmhJava was failing in CI. Co-Authored-By: Claude Sonnet 5 --- .../datadog/profiling/ddprof/AppContextSnapshotBenchmark.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/dd-java-agent/agent-profiling/profiling-ddprof/src/jmh/java/com/datadog/profiling/ddprof/AppContextSnapshotBenchmark.java b/dd-java-agent/agent-profiling/profiling-ddprof/src/jmh/java/com/datadog/profiling/ddprof/AppContextSnapshotBenchmark.java index 53dd2ed9932..683f8ed2484 100644 --- a/dd-java-agent/agent-profiling/profiling-ddprof/src/jmh/java/com/datadog/profiling/ddprof/AppContextSnapshotBenchmark.java +++ b/dd-java-agent/agent-profiling/profiling-ddprof/src/jmh/java/com/datadog/profiling/ddprof/AppContextSnapshotBenchmark.java @@ -1,6 +1,5 @@ package com.datadog.profiling.ddprof; -import java.nio.charset.StandardCharsets; import java.util.concurrent.TimeUnit; import org.openjdk.jmh.annotations.Benchmark; import org.openjdk.jmh.annotations.BenchmarkMode; @@ -52,8 +51,7 @@ public class AppContextSnapshotBenchmark { public void setup() { source = new DatadogProfiler.AppContextSnapshot(attrCount); for (int i = 0; i < attrCount; i++) { - byte[] utf8 = ("value-" + i).getBytes(StandardCharsets.UTF_8); - source.record(i, i + 1, utf8, "value-" + i); + source.record(i, "value-" + i); } slot = new DatadogProfiler.AppContextSnapshot(attrCount); stack = new DatadogProfiler.ScopeStack(attrCount); From 3a248ca26764294a8cfa783c3a2bf857013659c3 Mon Sep 17 00:00:00 2001 From: Roman Kennke Date: Fri, 24 Jul 2026 11:10:46 +0000 Subject: [PATCH 5/5] profiling(ddprof): fix two reapply ordering bugs found by PR review bots - setContextValue: a rejected native write (e.g. >255-byte UTF-8) clears the native slot but left the Java snapshot untouched, so the attribute read as unset until the next span boundary silently resurrected the stale prior value. Reapply immediately on rejection so the prior value stays visible continuously, matching pre-migration DBB behavior. - setTraceContext: reapplyAppContext() ran unconditionally after the native call, so when profiling.context.attributes also names _dd.trace.operation/resource (with span-name/resource-name context enabled), the trailing reapply clobbered the span-derived value that setTraceContext just wrote to the same offset with a stale app-recorded one. reapplyAppContext now takes the operation/resource offsets to skip. Both were flagged independently by Codex and Datadog Autotest PR review bots on #11899, with a concrete repro for the first. Added regression coverage to DatadogProfilerTest#testContextRegistration for both (verified each new assertion fails without its corresponding fix). Co-Authored-By: Claude Sonnet 5 --- .../profiling/ddprof/DatadogProfiler.java | 23 ++++++++- .../profiling/ddprof/DatadogProfilerTest.java | 48 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfiler.java b/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfiler.java index 5e85db941c8..8b7b4cf9c9b 100644 --- a/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfiler.java +++ b/dd-java-agent/agent-profiling/profiling-ddprof/src/main/java/com/datadog/profiling/ddprof/DatadogProfiler.java @@ -524,7 +524,11 @@ public void setTraceContext( } catch (Throwable e) { log.debug("Failed to set trace context", e); } - reapplyAppContext(); + // Skip operationOffset/resourceOffset: setTraceContext just wrote the span-derived values + // there natively. If profiling.context.attributes also names _dd.trace.operation/resource, + // that offset is app-owned too (see isAppOffset); reapplying it here would immediately + // overwrite the fresh span-derived value with a stale app-recorded one. + reapplyAppContext(operationOffset, resourceOffset); } /** Per-deactivation clear; reapplies app-managed attributes afterwards (see setTraceContext). */ @@ -555,6 +559,10 @@ public boolean setContextValue(int offset, String value) { recordAppContextValue(offset, value); return true; } + // Rejected (e.g. >255-byte UTF-8, dictionary full): the native call already cleared this + // slot. Restore it from the still-current Java snapshot so a rejected write doesn't blank + // the attribute until the next span boundary. + reapplyAppContext(); } catch (Throwable e) { log.debug("Failed to set context value", e); } @@ -615,6 +623,16 @@ public boolean clearContextValue(int offset) { * (java-profiler PROF-15361); per-slot is adequate for the typical small app-attribute count. */ public void reapplyAppContext() { + reapplyAppContext(-1, -1); + } + + /** + * Same as {@link #reapplyAppContext()}, but leaves {@code skipOffset1}/{@code skipOffset2} alone. + * Used by {@link #setTraceContext} to avoid clobbering the operation/resource offsets it just + * wrote natively, in the edge case where those offsets are also app-owned (see {@link + * #isAppOffset}). Pass -1 for either argument to skip nothing. + */ + private void reapplyAppContext(int skipOffset1, int skipOffset2) { if (!hasAppContext) { return; } @@ -625,6 +643,9 @@ public void reapplyAppContext() { try { int remaining = snapshot.nonZeroCount(); for (int i = 0; i < isAppOffset.length && remaining > 0; i++) { + if (i == skipOffset1 || i == skipOffset2) { + continue; + } String s = snapshot.stringAt(i); if (s != null) { profiler.setContextValue(i, s); diff --git a/dd-java-agent/agent-profiling/profiling-ddprof/src/test/java/com/datadog/profiling/ddprof/DatadogProfilerTest.java b/dd-java-agent/agent-profiling/profiling-ddprof/src/test/java/com/datadog/profiling/ddprof/DatadogProfilerTest.java index 74b43d90066..0ef487f3630 100644 --- a/dd-java-agent/agent-profiling/profiling-ddprof/src/test/java/com/datadog/profiling/ddprof/DatadogProfilerTest.java +++ b/dd-java-agent/agent-profiling/profiling-ddprof/src/test/java/com/datadog/profiling/ddprof/DatadogProfilerTest.java @@ -478,6 +478,54 @@ public void testContextRegistration() { profiler.snapshot()[fooOffset], "Guard: zero-span activation must degrade to a clean clear that still reapplies app context"); profiler.clearContextValue("foo"); + + // Regression: a native setContextValue rejection (e.g. an oversized value, >255 UTF-8 bytes) + // clears the native slot; the prior value must be resynced immediately instead of only + // reappearing on the next span boundary (a flicker the pre-migration DBB path never had, + // since it retained the prior value continuously). + fooSetter.set("valid-before-reject"); + int validEncoding = profiler.snapshot()[fooOffset]; + assertNotEquals(0, validEncoding, "foo must be live before the rejected write"); + StringBuilder oversized = new StringBuilder(); + for (int i = 0; i < 300; i++) { + oversized.append('x'); + } + assertFalse( + profiler.setContextValue("foo", oversized.toString()), + "an oversized (>255 UTF-8 bytes) value must be rejected"); + assertEquals( + validEncoding, + profiler.snapshot()[fooOffset], + "a rejected write must not blank the slot; the prior value must stay visible immediately"); + profiler.clearContextValue("foo"); + + // Regression: setTraceContext's trailing reapplyAppContext must not clobber the span-derived + // value it just wrote natively to operationOffset/resourceOffset, even when that offset is + // also app-owned (e.g. profiling.context.attributes names _dd.trace.operation/resource while + // span-name/resource-name context is enabled). Reuses the "foo" app-owned offset as a stand-in + // operationOffset — the profiler is a process-wide singleton and its context attributes can't + // be reconfigured to register the real _dd.trace.operation/resource offsets here. + profiler.setContextValue(fooOffset, "span-derived-value"); + int spanDerivedEncoding = profiler.snapshot()[fooOffset]; + assertNotEquals(0, spanDerivedEncoding, "fixture sanity: span-derived value must be live"); + profiler.clearContextValue("foo"); + + fooSetter.set("stale-app-value"); + int appEncoding = profiler.snapshot()[fooOffset]; + assertNotEquals(0, appEncoding, "foo app value must be recorded before the trace-context call"); + assertNotEquals( + spanDerivedEncoding, + appEncoding, + "fixture sanity: span-derived and app values must have distinct encodings"); + + profiler.setTraceContext(1L, 1L, 0L, 1L, fooOffset, "span-derived-value", -1, null); + assertEquals( + spanDerivedEncoding, + profiler.snapshot()[fooOffset], + "setTraceContext's trailing reapplyAppContext must skip operationOffset/resourceOffset so" + + " it doesn't overwrite the span-derived value just written to an app-owned offset"); + profiler.clearSpanContext(); + profiler.clearContextValue("foo"); } private static ConfigProvider configProvider(