Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions dd-java-agent/agent-bootstrap/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,17 @@ plugins {
id 'idea'
}

apply from: "$rootDir/gradle/tries.gradle"

// The shadowJar of this project will be injected into the JVM's bootstrap classloader

tasks.named("compileJava", JavaCompile) {
configureCompiler(it, 8, JavaVersion.VERSION_1_8, "Need access to sun.* packages")
dependsOn 'generateClassNameTries'
}

tasks.named("sourcesJar") { dependsOn 'generateClassNameTries' }

// FIXME: Improve test coverage.
minimumBranchCoverage = 0.0
minimumInstructionCoverage = 0.0
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
package datadog.trace.bootstrap.instrumentation.java.concurrent;

import datadog.trace.bootstrap.FieldBackedContextAccessor;
import datadog.trace.bootstrap.instrumentation.java.concurrent.ExcludeFilter.ExcludeType;

/**
* This is used to wrap lambda runnables so we can apply field-injection. RunnableWrapper can be
* transformed to add the necessary context-store fields, while lambdas currently cannot until the
* issue reported in https://github.com/raphw/byte-buddy/issues/558 is addressed.
* Wraps anonymous Runnable classes that were not field-injected.
*
* <p>We also make this class final to stop instrumentations from extending it in their injected
* helper classes, because if this class is loaded during helper injection then we can miss the
Expand All @@ -25,9 +24,11 @@ public void run() {
}

public static Runnable wrapIfNeeded(final Runnable task) {
if (!(task instanceof RunnableWrapper) && !ExcludeFilter.exclude(ExcludeType.RUNNABLE, task)) {
// We wrap only lambdas' anonymous classes and if given object has not already been wrapped.
// Anonymous classes have '/' in class name which is not allowed in 'normal' classes.
// Field-injected tasks are already instrumented and must retain their identity.
if (!(task instanceof RunnableWrapper)
&& !(task instanceof FieldBackedContextAccessor)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

instanceof FieldBackedContextAccessor is used here as a proxy for "already has propagation fields," but that marker isn't scoped to a specific context store. If a future second ForLambda instrumenter targets Runnable with a different context store, this check would treat its lambdas as already handled and skip wrapping them too, silently dropping propagation for that store with no fallback.

&& !ExcludeFilter.exclude(ExcludeType.RUNNABLE, task)) {
Comment on lines +28 to +30

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Retain per-submission context for reused lambdas

When the same lambda instance is submitted more than once before its first execution consumes the stored continuation—for example, a static or non-capturing Runnable queued concurrently by two requests—skipping the wrapper makes every submission share one State slot. The second capture is rejected, and overlapping executions can either lose a parent or both resume the first submission's continuation, producing missing or incorrectly parented traces; this path needs per-submission state rather than opting every field-backed lambda out of wrapping.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

As I explcitly already put in the PR description for reviewers:

Previously, wrapped lambdas had per-submission state. This limitation is most relevant to reusable singleton or stateless lambdas submitted concurrently; lambdas created per operation are unlikely to encounter it.

This is a known limitation and already existing for all the other field injected classes that are going throught threadpool. It's not introduced by this change and can be considered a trade-off we can perhaps live together for now.

// Hidden lambda class names contain '/'.
final String className = task.getClass().getName();
if (className.indexOf('/', className.lastIndexOf('.')) > 0) {
return new RunnableWrapper(task);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import datadog.trace.api.InstrumenterConfig;
import datadog.trace.api.Platform;
import datadog.trace.bootstrap.ContextStore;
import datadog.trace.bootstrap.FieldBackedContextAccessor;
import java.util.Set;
import java.util.concurrent.ThreadPoolExecutor;

Expand All @@ -22,18 +23,19 @@ public final class TPEHelper {
// If legacy is enabled, we will try to propagate via wrapping, if not we will try to propagate
// via storing the state in the existing field in the Runnable
private static final boolean useWrapping;
// A ThreadPoolExecutor with one of these types will newer be propagated/wrapped
// A ThreadPoolExecutor with one of these types will never be propagated/wrapped
private static final Set<String> excludedClasses;
// A ThreadLocal to store the Scope between beforeExecute and afterExecute if wrapping is not used
private static final ThreadLocal<ContextScope> threadLocalScope;

private static final ClassValue<Boolean> WRAP =
GenericClassValue.of(
input -> {
if (FieldBackedContextAccessor.class.isAssignableFrom(input)) {
return false;
}
String className = input.getName();
// We should always wrap anonymous lambda classes since we can't inject fields into
// them, and they can never be anything more than a _pure_ Runnable. They have '/' in
// their class name which is not allowed in 'normal' classes.
// Wrap anonymous lambda classes that were not field-injected.
return className.indexOf('/', className.lastIndexOf('.')) > 0;
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package datadog.trace.bootstrap.instrumentation.java.lang.invoke;

/** Transforms a generated lambda class before it is defined. */
public interface LambdaTransformer {
/**
* @param slashClassName internal (slash-separated) name of the generated lambda class
* @param targetClass the class declaring the lambda
* @param classBytes the freshly generated lambda class bytes
* @param interfaceClassName the functional interface implemented by the lambda
* @return the transformed bytes, or {@code null}/the original bytes if unchanged
*/
byte[] transform(
String slashClassName, Class<?> targetClass, byte[] classBytes, String interfaceClassName);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package datadog.trace.bootstrap.instrumentation.java.lang.invoke;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/** Transforms eligible lambda bytes before definition, falling back to the original on failure. */
public final class LambdaTransformerHelper {
private static final Logger log = LoggerFactory.getLogger(LambdaTransformerHelper.class);

// Agent transformation may itself create lambdas.
private static final ThreadLocal<Boolean> TRANSFORMING = new ThreadLocal<>();

private LambdaTransformerHelper() {}

/**
* @param classBytes the generated lambda class bytes
* @param lambdaClassName internal (slash-separated) name of the generated lambda class
* @param targetClass the class declaring the lambda
* @param interfaceClass the functional interface implemented by the lambda
* @return possibly transformed bytes; the original bytes on any failure
*/
public static byte[] transform(
byte[] classBytes, String lambdaClassName, Class<?> targetClass, Class<?> interfaceClass) {
try {
// Only exact allowlisted interfaces enter the transformer.
if (interfaceClass == null) {
return classBytes;
}
String interfaceName = interfaceClass.getName();
if (LambdaInterfaceNameTrie.apply(interfaceName) != 1) {
return classBytes;
}
LambdaTransformer transformer = LambdaTransformerHolder.get();
if (transformer == null) {
log.debug("Lambda {} skipped: no transformer registered", lambdaClassName);
return classBytes;
}
if (targetClass == null) {
log.debug("Lambda {} skipped: no target class", lambdaClassName);
return classBytes;
}
// Skip lambdas declared by the agent itself to avoid self-instrumentation and recursion.
String targetName = targetClass.getName();
if (targetName.startsWith("datadog.") || targetName.startsWith("net.bytebuddy.")) {
log.debug("Lambda {} skipped: declared by the agent", lambdaClassName);
return classBytes;
}
if (Boolean.TRUE.equals(TRANSFORMING.get())) {
log.debug("Lambda {} skipped: re-entrant transform", lambdaClassName);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The TRANSFORMING re-entrancy guard drops field-injection for any lambda whose class definition is triggered as a side effect of processing another lambda's transform on the same thread (e.g. a lambda captured while defining/loading another lambda's supporting classes). That lambda permanently falls back to RunnableWrapper with only this debug log as a trace.

return classBytes;
}
TRANSFORMING.set(Boolean.TRUE);
try {
byte[] result =
transformer.transform(lambdaClassName, targetClass, classBytes, interfaceName);
if (result == null) {
log.debug("Lambda {} not transformed", lambdaClassName);
return classBytes;
}
return result;
} finally {
TRANSFORMING.set(Boolean.FALSE);
}
} catch (Throwable e) {
log.debug("Lambda {} skipped: {}", lambdaClassName, e.toString());
return classBytes;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package datadog.trace.bootstrap.instrumentation.java.lang.invoke;

/**
* Holds the {@link LambdaTransformer} registered by the agent installer. Lives on the bootstrap
* class path so it is reachable from instrumented {@code java.lang.invoke} code.
*/
public final class LambdaTransformerHolder {
private static volatile LambdaTransformer transformer;

private LambdaTransformerHolder() {}

public static void set(LambdaTransformer transformer) {
LambdaTransformerHolder.transformer = transformer;
}

public static LambdaTransformer get() {
return transformer;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Generates 'LambdaInterfaceNameTrie.java'

# Exact functional interfaces whose generated lambda classes should be sent through the agent's
# matching and transformation pipeline. Keep this list narrow: the lookup runs for every lambda
# linkage in the application.

1 java.lang.Runnable

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This allowlist is duplicated between this trie and each ForLambda instrumenter's runtime lambdaInterface() (and the array built from them). Nothing enforces they stay in sync — forgetting to add a new interface here while adding a new ForLambda instrumenter would make LambdaInterfaceNameTrie.apply return something other than 1, silently no-oping the new instrumentation instead of failing loudly.

Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import static datadog.trace.agent.tooling.bytebuddy.matcher.GlobalIgnoresMatcher.globalIgnoresMatcher;
import static net.bytebuddy.matcher.ElementMatchers.isDefaultFinalizer;

import datadog.environment.JavaVirtualMachine;
import datadog.environment.SystemProperties;
import datadog.trace.agent.tooling.bytebuddy.SharedTypePools;
import datadog.trace.agent.tooling.bytebuddy.iast.TaintableRedefinitionStrategyListener;
Expand All @@ -19,6 +20,9 @@
import datadog.trace.api.telemetry.IntegrationsCollector;
import datadog.trace.bootstrap.FieldBackedContextAccessor;
import datadog.trace.bootstrap.instrumentation.java.concurrent.ExcludeFilter;
import datadog.trace.bootstrap.instrumentation.java.lang.invoke.LambdaTransformer;
import datadog.trace.bootstrap.instrumentation.java.lang.invoke.LambdaTransformerHelper;
import datadog.trace.bootstrap.instrumentation.java.lang.invoke.LambdaTransformerHolder;
import datadog.trace.bootstrap.instrumentation.java.module.JpmsHelper;
import datadog.trace.util.AgentTaskScheduler;
import de.thetaphi.forbiddenapis.SuppressForbidden;
Expand All @@ -35,6 +39,7 @@
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.TimeUnit;
import java.util.function.BooleanSupplier;
import java.util.function.Function;
import net.bytebuddy.ByteBuddy;
import net.bytebuddy.agent.builder.AgentBuilder;
import net.bytebuddy.description.type.TypeDescription;
Expand Down Expand Up @@ -147,7 +152,7 @@ public static ClassFileTransformer installBytebuddyAgent(
agentBuilder =
agentBuilder
.disableClassFormatChanges()
.assureReadEdgeTo(inst, FieldBackedContextAccessor.class)
.assureReadEdgeTo(inst, FieldBackedContextAccessor.class, LambdaTransformerHelper.class)
.with(AgentStrategies.transformerDecorator())
.with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION)
.with(AgentStrategies.rediscoveryStrategy())
Expand Down Expand Up @@ -253,12 +258,84 @@ public void applied(Iterable<String> instrumentationNames) {

InstrumenterState.resetDefaultState();
try {
return transformerBuilder.installOn(inst);
ClassFileTransformer classFileTransformer = transformerBuilder.installOn(inst);
registerLambdaTransformer(classFileTransformer, transformerBuilder.lambdaInterfaces());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Narrow startup race: transformerBuilder.installOn(inst) (which can trigger retransformation) runs before registerLambdaTransformer calls LambdaTransformerHolder.set(...). A lambda linked in that window gets a null transformer in LambdaTransformerHelper.transform and permanently misses injection — there's no retry once the window closes.

return classFileTransformer;
} finally {
SharedTypePools.endInstall();
}
}

/** Registers the installed class-file transformer for generated lambdas. */
private static void registerLambdaTransformer(
final ClassFileTransformer classFileTransformer, final String[] lambdaInterfaces) {
LambdaTransformer transformer =
lambdaInterfaces.length == 0 ? null : newLambdaTransformer(classFileTransformer);
LambdaTransformerHolder.set(filterLambdaTransformer(transformer, lambdaInterfaces));
}

static LambdaTransformer filterLambdaTransformer(
final LambdaTransformer transformer, final String[] lambdaInterfaces) {
if (transformer == null) {
return null;
}
return (className, targetClass, classBytes, interfaceName) -> {
for (String enabledInterface : lambdaInterfaces) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

filterLambdaTransformer does a linear scan over lambdaInterfaces per lambda link, on exactly the hot path the PR's own JMH benchmark (LambdaExecutorBenchmark) measures. CombiningTransformerBuilder already has these as a Set/keyed map before it gets flattened to this array — reusing that instead of a per-call linear scan would avoid the O(n) lookup for every lambda linkage.

if (enabledInterface.equals(interfaceName)) {
return transformer.transform(className, targetClass, classBytes, interfaceName);
}
}
return null;
};
}

/**
* Java 9+ requires the module-aware transformer for injected read edges. Failure must disable
* lambda transformation rather than fall back to the module-less overload.
*/
@SuppressWarnings("unchecked")
private static LambdaTransformer newLambdaTransformer(
final ClassFileTransformer classFileTransformer) {
if (JavaVirtualMachine.isJavaVersionAtLeast(9)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This JDK9+ reflective-loading logic duplicates the existing idiom used elsewhere in this class/AgentStrategies for loading version-specific helper classes reflectively. Worth sharing that helper instead of a second bespoke reflection path.

try {
Function<ClassFileTransformer, LambdaTransformer> factory =
(Function<ClassFileTransformer, LambdaTransformer>)
Instrumenter.class
.getClassLoader()
.loadClass("datadog.trace.agent.tooling.bytebuddy.DDJava9LambdaTransformer")
.getField("FACTORY")
.get(null);
return factory.apply(classFileTransformer);
} catch (Throwable e) {
log.debug("Problem loading Java 9 lambda transformer, disabling lambda field-injection", e);
return null;
}
}
// Avoid invoking the instrumented metafactory while installing its transformer.
return new LambdaTransformer() {
@Override
public byte[] transform(
String slashClassName,
Class<?> targetClass,
byte[] classBytes,
String interfaceClassName) {
TypePoolFacade.beginLambdaTransform(interfaceClassName);
try {
return classFileTransformer.transform(
targetClass.getClassLoader(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Use agent access for class data

Affected applications keep the old wrapper and do not preserve Runnable identity.

Assertion details
  • Input: Link an application Runnable lambda when an active SecurityManager denies getClassLoader or getProtectionDomain access.
  • Expected: The agent must read the class data with agent access rights.
  • Actual: The class access calls throw SecurityException. The catch returns the original bytes and stops lambda instrumentation.

Was this helpful? React 👍 or 👎
🤖 Datadog Autotest · What is Autotest? · @DataDog review to ask questions · Any feedback? Reach out in #autotest · Open Bits AI session

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Accessing the target class’s protection domain can be denied. The code safely catches this and falls back. SecurityManager usage is increasingly rare, and IMHO changing the protection domain handling would be a risky change. I would steer not to fix it

slashClassName,
null,
targetClass.getProtectionDomain(),
classBytes);
} catch (Throwable ignored) {
return null;
} finally {
TypePoolFacade.endLambdaTransform();
}
}
};
}

/** Returns an iterable that combines the original sequence with any discovered extensions. */
private static Iterable<InstrumenterModule> withExtensions(Iterable<InstrumenterModule> initial) {
String extensionsPath = InstrumenterConfig.get().getTraceExtensionsPath();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,22 @@

import static datadog.trace.api.config.TraceInstrumentationConfig.EXPERIMENTAL_DEFER_INTEGRATIONS_UNTIL;
import static datadog.trace.util.AgentThreadFactory.AgentThread.RETRANSFORMER;
import static java.util.Collections.unmodifiableMap;

import datadog.trace.agent.tooling.bytebuddy.matcher.CustomExcludes;
import datadog.trace.agent.tooling.bytebuddy.matcher.ProxyClassIgnores;
import datadog.trace.agent.tooling.bytebuddy.outline.TypePoolFacade;
import datadog.trace.api.InstrumenterConfig;
import datadog.trace.api.time.TimeUtils;
import datadog.trace.util.AgentTaskScheduler;
import java.lang.instrument.Instrumentation;
import java.security.ProtectionDomain;
import java.util.ArrayList;
import java.util.BitSet;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import net.bytebuddy.agent.builder.AgentBuilder;
Expand Down Expand Up @@ -45,13 +49,22 @@ final class CombiningMatcher implements AgentBuilder.RawMatcher {

private final BitSet knownTypesMask;
private final MatchRecorder[] matchers;
private final Map<String, LambdaMatchRecorder[]> lambdaMatchers;

private volatile boolean deferring;

CombiningMatcher(
Instrumentation instrumentation, BitSet knownTypesMask, List<MatchRecorder> matchers) {
Instrumentation instrumentation,
BitSet knownTypesMask,
List<MatchRecorder> matchers,
Map<String, List<LambdaMatchRecorder>> lambdaMatchers) {
this.knownTypesMask = knownTypesMask;
this.matchers = matchers.toArray(new MatchRecorder[0]);
Map<String, LambdaMatchRecorder[]> lambdaMatchersByInterface = new HashMap<>();
lambdaMatchers.forEach(
(name, recorders) ->
lambdaMatchersByInterface.put(name, recorders.toArray(new LambdaMatchRecorder[0])));
this.lambdaMatchers = unmodifiableMap(lambdaMatchersByInterface);

if (DEFER_MATCHING) {
scheduleResumeMatching(instrumentation, InstrumenterConfig.get().deferIntegrationsUntil());
Expand All @@ -75,6 +88,18 @@ public boolean matches(
ids.clear();

long fromTick = InstrumenterMetrics.tick();
String lambdaInterface = TypePoolFacade.lambdaInterface();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A lambda linked while EXPERIMENTAL_DEFER_INTEGRATIONS_UNTIL deferral is active returns false here and permanently skips field-injection: wouldIgnore() (line 193) explicitly excludes names containing / ("don't retransform lambdas"), so the later resumeMatching() retransform sweep never revisits it. That's a permanent, silent miss for any lambda linked during the deferral window, not just a delayed one.

if (null != lambdaInterface) {
LambdaMatchRecorder[] recorders = lambdaMatchers.get(lambdaInterface);
if (null != recorders) {
for (LambdaMatchRecorder recorder : recorders) {
recorder.record(target, classLoader, ids);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

recorder.record(target, classLoader, ids) here isn't wrapped in try/catch, unlike the equivalent loop over regular MatchRecorders a few lines below (line 111-112, which logs and continues on Throwable). An exception from a LambdaMatchRecorder propagates uncaught instead of being handled the same way.

}
}
InstrumenterMetrics.matchType(fromTick);
return !ids.isEmpty();
}

knownTypesIndex.apply(target.getName(), knownTypesMask, ids);
if (ids.isEmpty()) {
InstrumenterMetrics.knownTypeMiss(fromTick);
Expand Down
Loading