diff --git a/dd-java-agent/agent-profiling/profiling-ddprof/build.gradle b/dd-java-agent/agent-profiling/profiling-ddprof/build.gradle index 2fa61e6d34b..52ec99874ef 100644 --- a/dd-java-agent/agent-profiling/profiling-ddprof/build.gradle +++ b/dd-java-agent/agent-profiling/profiling-ddprof/build.gradle @@ -38,6 +38,7 @@ dependencies { testImplementation libs.bundles.jmc testImplementation libs.bundles.junit5 + testImplementation libs.bundles.mockito } 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 3d9325168e7..616f7694402 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 @@ -332,8 +332,10 @@ String cmdStartProfiling(Path file) throws IllegalStateException { return cmdString; } - public void recordTraceRoot(long rootSpanId, String endpoint, String operation) { - if (!profiler.recordTraceRoot(rootSpanId, endpoint, operation, MAX_NUM_ENDPOINTS)) { + public void recordTraceRoot( + long rootSpanId, long parentSpanId, long startTicks, String endpoint, String operation) { + if (!profiler.recordTraceRoot( + rootSpanId, parentSpanId, startTicks, endpoint, operation, MAX_NUM_ENDPOINTS)) { log.debug( "Endpoint event not written because more than {} distinct endpoints have been encountered." + " This avoids excessive memory overhead.", @@ -341,6 +343,10 @@ public void recordTraceRoot(long rootSpanId, String endpoint, String operation) } } + public long getCurrentTicks() { + return profiler.getCurrentTicks(); + } + public int operationNameOffset() { return offsetOf(OPERATION); } @@ -455,6 +461,34 @@ boolean shouldRecordQueueTimeEvent(long startMillis) { return System.currentTimeMillis() - startMillis >= queueTimeThresholdMillis; } + void recordTaskBlockEvent( + long startTicks, long spanId, long rootSpanId, long blocker, long unblockingSpanId) { + if (profiler != null) { + long endTicks = profiler.getCurrentTicks(); + profiler.recordTaskBlock(startTicks, endTicks, spanId, rootSpanId, blocker, unblockingSpanId); + } + } + + public void recordSpanNodeEvent( + long spanId, + long parentSpanId, + long rootSpanId, + long startNanos, + long durationNanos, + int encodedOperation, + int encodedResource) { + if (profiler != null) { + profiler.recordSpanNode( + spanId, + parentSpanId, + rootSpanId, + startNanos, + durationNanos, + encodedOperation, + encodedResource); + } + } + void recordQueueTimeEvent( long startTicks, Object task, 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 f6b09476df4..f4f8d1987ac 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 @@ -93,6 +93,31 @@ public String name() { return "ddprof"; } + @Override + public long getCurrentTicks() { + return DDPROF.getCurrentTicks(); + } + + @Override + public void recordTaskBlock( + long startTicks, long spanId, long rootSpanId, long blocker, long unblockingSpanId) { + DDPROF.recordTaskBlockEvent(startTicks, spanId, rootSpanId, blocker, unblockingSpanId); + } + + @Override + public void onSpanFinished(AgentSpan span) { + if (span == null || !(span.context() instanceof ProfilerContext)) return; + ProfilerContext ctx = (ProfilerContext) span.context(); + DDPROF.recordSpanNodeEvent( + ctx.getSpanId(), + ctx.getParentSpanId(), + ctx.getRootSpanId(), + span.getStartTime(), + span.getDurationNano(), + ctx.getEncodedOperationName(), + ctx.getEncodedResourceName()); + } + public void clearContext() { DDPROF.clearSpanContext(); DDPROF.clearContextValue(SPAN_NAME_INDEX); @@ -115,15 +140,25 @@ public void onRootSpanFinished(AgentSpan rootSpan, EndpointTracker tracker) { CharSequence resourceName = rootSpan.getResourceName(); CharSequence operationName = rootSpan.getOperationName(); if (resourceName != null && operationName != null) { + long startTicks = + (tracker instanceof RootSpanTracker) ? ((RootSpanTracker) tracker).startTicks : 0L; + long parentSpanId = 0L; + if (rootSpan.context() instanceof ProfilerContext) { + parentSpanId = ((ProfilerContext) rootSpan.context()).getParentSpanId(); + } DDPROF.recordTraceRoot( - rootSpan.getSpanId(), resourceName.toString(), operationName.toString()); + rootSpan.getSpanId(), + parentSpanId, + startTicks, + resourceName.toString(), + operationName.toString()); } } } @Override public EndpointTracker onRootSpanStarted(AgentSpan rootSpan) { - return NoOpEndpointTracker.INSTANCE; + return new RootSpanTracker(DDPROF.getCurrentTicks()); } @Override @@ -135,12 +170,14 @@ public Timing start(TimerType type) { } /** - * This implementation is actually stateless, so we don't actually need a tracker object, but - * we'll create a singleton to avoid returning null and risking NPEs elsewhere. + * Captures the TSC tick at root span start so we can emit real duration in the Endpoint event. */ - private static final class NoOpEndpointTracker implements EndpointTracker { + private static final class RootSpanTracker implements EndpointTracker { + final long startTicks; - public static final NoOpEndpointTracker INSTANCE = new NoOpEndpointTracker(); + RootSpanTracker(long startTicks) { + this.startTicks = startTicks; + } @Override public void endpointWritten(AgentSpan span) {} diff --git a/dd-java-agent/agent-profiling/profiling-ddprof/src/test/java/com/datadog/profiling/ddprof/DatadogProfilerSpanNodeTest.java b/dd-java-agent/agent-profiling/profiling-ddprof/src/test/java/com/datadog/profiling/ddprof/DatadogProfilerSpanNodeTest.java new file mode 100644 index 00000000000..a5cad42552c --- /dev/null +++ b/dd-java-agent/agent-profiling/profiling-ddprof/src/test/java/com/datadog/profiling/ddprof/DatadogProfilerSpanNodeTest.java @@ -0,0 +1,157 @@ +package com.datadog.profiling.ddprof; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import datadog.trace.bootstrap.instrumentation.api.AgentSpan; +import datadog.trace.bootstrap.instrumentation.api.AgentSpanContext; +import datadog.trace.bootstrap.instrumentation.api.ProfilerContext; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link DatadogProfilingIntegration#onSpanFinished(AgentSpan)}. + * + *
Because {@link DatadogProfiler} wraps a native library, we verify the filtering logic and
+ * dispatch path without asserting on the native event itself. Native calls simply must not throw
+ * (the {@code if (profiler != null)} guard inside {@link DatadogProfiler} protects them on systems
+ * where the native library is unavailable).
+ */
+class DatadogProfilerSpanNodeTest {
+
+ /**
+ * When the span's context does NOT implement {@link ProfilerContext}, {@code onSpanFinished}
+ * should be a no-op and must not throw.
+ */
+ @Test
+ void onSpanFinished_nonProfilerContext_isNoOp() {
+ DatadogProfilingIntegration integration = new DatadogProfilingIntegration();
+ AgentSpan span = mock(AgentSpan.class);
+ AgentSpanContext ctx = mock(AgentSpanContext.class); // plain context, NOT a ProfilerContext
+ when(span.context()).thenReturn(ctx);
+
+ assertDoesNotThrow(() -> integration.onSpanFinished(span));
+ }
+
+ /**
+ * When the span's context DOES implement {@link ProfilerContext}, {@code onSpanFinished} extracts
+ * fields and attempts to emit a SpanNode event. Must not throw regardless of whether the native
+ * profiler is loaded.
+ */
+ @Test
+ void onSpanFinished_profilerContext_doesNotThrow() {
+ DatadogProfilingIntegration integration = new DatadogProfilingIntegration();
+
+ // Mockito can create a mock that implements multiple interfaces
+ AgentSpanContext ctx = mock(AgentSpanContext.class, org.mockito.Answers.RETURNS_DEFAULTS);
+ ProfilerContext profilerCtx = mock(ProfilerContext.class);
+
+ // We need a single object that satisfies both instanceof checks.
+ // Use a hand-rolled stub instead.
+ TestContext combinedCtx = new TestContext(42L, 7L, 1L, 3, 5);
+
+ AgentSpan span = mock(AgentSpan.class);
+ when(span.context()).thenReturn(combinedCtx);
+ when(span.getStartTime()).thenReturn(1_700_000_000_000_000_000L);
+ when(span.getDurationNano()).thenReturn(1_000_000L);
+
+ assertDoesNotThrow(() -> integration.onSpanFinished(span));
+ }
+
+ /** Null span must not throw (guard at top of onSpanFinished). */
+ @Test
+ void onSpanFinished_nullSpan_doesNotThrow() {
+ DatadogProfilingIntegration integration = new DatadogProfilingIntegration();
+ assertDoesNotThrow(() -> integration.onSpanFinished(null));
+ }
+
+ // ---------------------------------------------------------------------------
+ // Stub: a single object that satisfies both AgentSpanContext and ProfilerContext
+ // ---------------------------------------------------------------------------
+
+ private static final class TestContext implements AgentSpanContext, ProfilerContext {
+
+ private final long spanId;
+ private final long parentSpanId;
+ private final long rootSpanId;
+ private final int encodedOp;
+ private final int encodedResource;
+
+ TestContext(
+ long spanId, long parentSpanId, long rootSpanId, int encodedOp, int encodedResource) {
+ this.spanId = spanId;
+ this.parentSpanId = parentSpanId;
+ this.rootSpanId = rootSpanId;
+ this.encodedOp = encodedOp;
+ this.encodedResource = encodedResource;
+ }
+
+ // ProfilerContext
+ @Override
+ public long getSpanId() {
+ return spanId;
+ }
+
+ @Override
+ public long getParentSpanId() {
+ return parentSpanId;
+ }
+
+ @Override
+ public long getRootSpanId() {
+ return rootSpanId;
+ }
+
+ @Override
+ public int getEncodedOperationName() {
+ return encodedOp;
+ }
+
+ @Override
+ public CharSequence getOperationName() {
+ return "test-op";
+ }
+
+ @Override
+ public int getEncodedResourceName() {
+ return encodedResource;
+ }
+
+ @Override
+ public CharSequence getResourceName() {
+ return "test-resource";
+ }
+
+ // AgentSpanContext
+ @Override
+ public datadog.trace.api.DDTraceId getTraceId() {
+ return datadog.trace.api.DDTraceId.ZERO;
+ }
+
+ @Override
+ public datadog.trace.bootstrap.instrumentation.api.AgentTraceCollector getTraceCollector() {
+ return datadog.trace.bootstrap.instrumentation.api.AgentTracer.NoopAgentTraceCollector
+ .INSTANCE;
+ }
+
+ @Override
+ public int getSamplingPriority() {
+ return datadog.trace.api.sampling.PrioritySampling.UNSET;
+ }
+
+ @Override
+ public Iterable Also instruments {@link java.util.concurrent.locks.LockSupport#unpark} to capture the span ID
+ * of the unblocking thread, which is then recorded in the TaskBlock event.
+ *
+ * Only fires when a Datadog span is active on the calling thread, so there is no overhead on
+ * threads that are not part of a traced request.
+ */
+@AutoService(InstrumenterModule.class)
+public class LockSupportProfilingInstrumentation extends InstrumenterModule.Profiling
+ implements Instrumenter.ForKnownTypes, Instrumenter.HasMethodAdvice {
+
+ public LockSupportProfilingInstrumentation() {
+ super("lock-support-profiling");
+ }
+
+ @Override
+ public String[] knownMatchingTypes() {
+ return new String[] {"java.util.concurrent.locks.LockSupport"};
+ }
+
+ @Override
+ public void methodAdvice(MethodTransformer transformer) {
+ transformer.applyAdvice(
+ isMethod()
+ .and(isStatic())
+ .and(nameStartsWith("park"))
+ .and(isDeclaredBy(named("java.util.concurrent.locks.LockSupport"))),
+ getClass().getName() + "$ParkAdvice");
+ transformer.applyAdvice(
+ isMethod()
+ .and(isStatic())
+ .and(ElementMatchers.named("unpark"))
+ .and(isDeclaredBy(named("java.util.concurrent.locks.LockSupport"))),
+ getClass().getName() + "$UnparkAdvice");
+ }
+
+ /** Holds shared state accessible from both {@link ParkAdvice} and {@link UnparkAdvice}. */
+ public static final class State {
+ /** Maps target thread to the span ID of the thread that called {@code unpark()} on it. */
+ public static final ConcurrentHashMap These tests exercise the {@link State} map directly, verifying the mechanism used to
+ * communicate the unblocking span ID from {@code UnparkAdvice} to {@code ParkAdvice}.
+ */
+class LockSupportProfilingInstrumentationTest {
+
+ @BeforeEach
+ void clearState() {
+ State.UNPARKING_SPAN.clear();
+ }
+
+ @AfterEach
+ void cleanupState() {
+ State.UNPARKING_SPAN.clear();
+ }
+
+ // -------------------------------------------------------------------------
+ // State map — basic contract
+ // -------------------------------------------------------------------------
+
+ @Test
+ void state_put_and_remove() {
+ Thread t = Thread.currentThread();
+ long spanId = 12345L;
+
+ State.UNPARKING_SPAN.put(t, spanId);
+ Long retrieved = State.UNPARKING_SPAN.remove(t);
+
+ assertNotNull(retrieved);
+ assertEquals(spanId, (long) retrieved);
+ // After removal the entry should be gone
+ assertNull(State.UNPARKING_SPAN.get(t));
+ }
+
+ @Test
+ void state_remove_returns_null_when_absent() {
+ Thread t = new Thread(() -> {});
+ assertNull(State.UNPARKING_SPAN.remove(t));
+ }
+
+ @Test
+ void state_is_initially_empty() {
+ assertTrue(State.UNPARKING_SPAN.isEmpty());
+ }
+
+ // -------------------------------------------------------------------------
+ // Multithreaded: unpark thread populates map, parked thread reads it
+ // -------------------------------------------------------------------------
+
+ /**
+ * Simulates the UnparkAdvice → ParkAdvice handoff:
+ *
+ *
+ *
+ */
+ @Test
+ void unparking_spanId_is_visible_to_parked_thread() throws InterruptedException {
+ long unparkingSpanId = 99887766L;
+
+ CountDownLatch ready = new CountDownLatch(1);
+ CountDownLatch go = new CountDownLatch(1);
+ AtomicLong capturedSpanId = new AtomicLong(-1L);
+ AtomicReference