Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,94 @@

✅ `public String[] triggerClasses() { return new String[]{"com.example.Foo"}; }`

### Before hand-writing method advice, check for an official interception SPI

Before instrumenting a client library by matching and advising its methods directly, check whether the library — or an official companion library in its ecosystem — exposes a first-class **interception / listener SPI**: a listener interface, an interceptor-registration hook, or a proxy/wrapper factory built for observability. Many client libraries provide one, and it is almost always the better hook point than hand-written method advice.

Examples of such SPIs:

- **R2DBC** → `r2dbc-proxy` (an official R2DBC project) exposes `ProxyExecutionListener` / `ProxyMethodExecutionListener`, invoked around every query via `beforeQuery(QueryExecutionInfo)` / `afterQuery(QueryExecutionInfo)`. You install it by wrapping the `ConnectionFactory`.
- **gRPC** → `ClientInterceptor` / `ServerInterceptor`.
- **Kafka** → `ProducerInterceptor` / `ConsumerInterceptor` (`interceptor.classes`).
- **JAX-RS / JDBC / many others** → filter, interceptor, or proxy-driver registration hooks.

**Why prefer the SPI over hand-written advice, especially for reactive/async clients:** a listener SPI is designed around the library's own execution lifecycle and delivers a single well-defined start/end (and error/**cancel**) callback per logical operation, with the operation's metadata (query text, connection info, status, timing) already assembled for you. Hand-written advice on a reactive method must instead re-implement that lifecycle by wrapping the returned `Publisher`/`Mono`/`Flux` and tracking subscribe/complete/error/**cancel** by hand — this is easy to get subtly wrong. A wrapper that finishes the span only on `onComplete`/`onError` will **leak every span that gets cancelled** (reactive pipelines routinely cancel upstreams — e.g. `take(1)`, timeouts, `DiscardOnCancel` operators), so the span is created but never finished and never exported. It is also easy to wrap at the wrong granularity and emit many spans per logical query. The library's listener already handles all of this.

**Do this without forcing a new runtime dependency on the user.** Using a library's listener SPI does NOT require adding it as a normal (`implementation`/`api`) dependency:

- Declare the interception library **`compileOnly`** so it is not put on the application's runtime classpath, and inject your listener implementation and any glue via the module's `helperClassNames()` (the same mechanism used for decorators and other injected helpers). The listener classes travel inside the agent, not the user's app.

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 Bundle the proxy classes before injecting helpers

For an R2DBC application that has only r2dbc-spi and a driver, declaring r2dbc-proxy as compileOnly leaves ProxyMethodExecutionListener, ProxyConfig, and ProxyConnectionFactory out of both the application classpath and the agent's instrumentation JAR. helperClassNames() injects only the explicitly packaged helper bytecode; it does not make compile-only dependency classes travel with the agent, so defining TraceProxyListener fails on its missing interface and the transformation produces no tracing. The dependency must be bundled/shaded and injected, or the integration must explicitly require that the application already provides it; extraDependency only fixes the muzzle test classpath.

AGENTS.md reference: AGENTS.md:L35-L45

Useful? React with 👍 / 👎.

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.

P1 Package the proxy classes before helper injection

An integration that follows this guidance does not load for R2DBC users who do not install r2dbc-proxy.

Assertion details
  • Input: Create an integration with r2dbc-proxy as compileOnly, then run it in an application that has only r2dbc-spi and a driver.
  • Expected: Bundle or shade the proxy classes. Otherwise, state that the application must provide the proxy library at runtime.
  • Actual: compileOnly does not package r2dbc-proxy. Helper injection adds only the listed helper bytecode. The helper cannot load when the application does not include the proxy library.

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

- If helper injection is impractical for a given SPI, the alternative is to **shade/bundle** the interception library's classes into the instrumentation rather than depend on it at runtime.
- The `compileOnly` interception library must also be added to muzzle's classpath via `extraDependency` (see the worked example below) — otherwise muzzle validation fails with "missing class" for every type the listener/wrap-helper classes reference from it.

Reach for hand-written method advice when no such SPI exists, or when the SPI cannot express what you need to capture. When one does exist and fits, prefer it — and note the choice (and the `compileOnly`/injection approach) in the PR so a reviewer sees the dependency was considered.

**Worked example — hooking a registration/factory point instead of the query methods (R2DBC + `r2dbc-proxy`):**

1. **Advice target is the factory, not the query.** Hook `io.r2dbc.spi.ConnectionFactories.find(ConnectionFactoryOptions)` — NOT `Statement.execute()` / `Batch.execute()`. Replace the returned `ConnectionFactory` using `@Advice.AssignReturned.ToReturned` on method exit:

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 Target the ConnectionFactory-returning method

For R2DBC 1.0, ConnectionFactories.find(ConnectionFactoryOptions) returns Optional<ConnectionFactory>, while this advice binds the original return and supplies the replacement as ConnectionFactory. Byte Buddy cannot map either value to the target method's Optional return type, so transformation fails whenever this recipe is followed. Hook get(ConnectionFactoryOptions), as the same guide later recommends, or preserve and replace the Optional value.

AGENTS.md reference: AGENTS.md:L35-L45

Useful? React with 👍 / 👎.

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.

P1 Target the method that returns ConnectionFactory

An integration that follows this example fails during bytecode transformation.

Assertion details
  • Input: Apply the shown advice to R2DBC 1.0 ConnectionFactories.find.
  • Expected: Target ConnectionFactories.get(ConnectionFactoryOptions), which returns ConnectionFactory, or preserve the Optional type.
  • Actual: ConnectionFactories.find(ConnectionFactoryOptions) returns Optional<ConnectionFactory>. The shown advice binds and returns ConnectionFactory, so Byte Buddy cannot map the return type.
Suggested change
1. **Advice target is the factory, not the query.** Hook `io.r2dbc.spi.ConnectionFactories.find(ConnectionFactoryOptions)` — NOT `Statement.execute()` / `Batch.execute()`. Replace the returned `ConnectionFactory` using `@Advice.AssignReturned.ToReturned` on method exit:
1. **Advice target is the factory, not the query.** Hook `io.r2dbc.spi.ConnectionFactories.get(ConnectionFactoryOptions)` — NOT `Statement.execute()` / `Batch.execute()`. Replace the returned `ConnectionFactory` using `@Advice.AssignReturned.ToReturned` on method exit:

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


```java
@Advice.OnMethodExit(suppress = Throwable.class, inline = false)

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 Badge Remove inline=false from the production recipe

Anyone copying this worked example is told to commit inline = false, contradicting both docs/add_new_instrumentation.md:423 and this skill's own references/advice-class.md:122, which require removing it after debugging because it replaces inlined advice with an additional method call. Remove the flag from the canonical example so generated instrumentation can satisfy the mandatory production checklist.

AGENTS.md reference: AGENTS.md:L41-L45

Useful? React with 👍 / 👎.

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.

P2 Remove inline=false from the production example

Generated integrations add avoidable call overhead to each advised factory lookup.

Assertion details
  • Input: Copy the canonical advice example into an instrumentation module.
  • Expected: Use the default inlined advice in the production example.
  • Actual: The production example sets inline = false. This adds a method call and conflicts with the repository rule to remove this option after debugging.
Suggested change
@Advice.OnMethodExit(suppress = Throwable.class, inline = false)
@Advice.OnMethodExit(suppress = Throwable.class)

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

@Advice.AssignReturned.ToReturned
public static ConnectionFactory wrap(
@Advice.Return ConnectionFactory factory,
@Advice.Argument(0) ConnectionFactoryOptions options) {
return R2dbcTracingSupport.wrapConnectionFactory(factory, options);
}
```

Do NOT use a writable `@Advice.Return(readOnly = false) Publisher<...> ...` here — binding a reactive-streams interface to a writable return against a concrete `Mono`/`Flux` result fails Byte Buddy transformation. `@Advice.AssignReturned.ToReturned` sidesteps this because it substitutes the value rather than mutating a typed slot.

2. **Wrap helper installs the listener** (injected via `helperClassNames()`, `r2dbc-proxy` declared `compileOnly`):

```java
public static ConnectionFactory wrapConnectionFactory(
ConnectionFactory factory, ConnectionFactoryOptions options) {
ProxyConfig cfg = new ProxyConfig();
cfg.addListener(new TraceProxyListener(options));
return ProxyConnectionFactory.builder(factory, cfg).build();
}
```

3. **Listener drives the span lifecycle** — implements `ProxyMethodExecutionListener`; stash the span on the query's own value store (the interception library already carries one per call — do not add a separate `ContextStore` keyed on the driver object):

```java
public class TraceProxyListener implements ProxyMethodExecutionListener {

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.

P1 Use the query listener interface

The generated listener cannot compile, so the proposed instrumentation cannot build.

Assertion details
  • Input: Compile the shown listener against the pinned r2dbc-proxy 1.1.0 API.
  • Expected: Implement ProxyExecutionListener and update all related interface references in the guidance.
  • Actual: beforeQuery and afterQuery belong to ProxyExecutionListener. They do not belong to ProxyMethodExecutionListener, so the shown overrides do not compile.

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

@Override
public void beforeQuery(QueryExecutionInfo qei) {
Comment on lines +95 to +97

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 Implement the query listener interface

With the pinned r2dbc-proxy API, beforeQuery and afterQuery are callbacks on ProxyExecutionListener; ProxyMethodExecutionListener is not the query-listener contract shown here. Consequently, the generated class cannot validly override these methods via the declared interface and the worked example fails to compile. Implement ProxyExecutionListener for this lifecycle, or use the version-specific query-listener interface if the dependency is changed.

AGENTS.md reference: AGENTS.md:L35-L45

Useful? React with 👍 / 👎.

AgentSpan span = startSpan(...);
DECORATE.afterStart(span);
DECORATE.onStatement(span, qei);
qei.getValueStore().put(SPAN_KEY, span);
}
@Override
public void afterQuery(QueryExecutionInfo qei) {
AgentSpan span = qei.getValueStore().get(SPAN_KEY, AgentSpan.class);
if (qei.getThrowable() != null) DECORATE.onError(span, qei.getThrowable());
DECORATE.beforeFinish(span);
span.finish();
}
}
```

`beforeQuery`/`afterQuery` fire once per logical operation and `r2dbc-proxy` itself owns completion/error/**cancel** — you do not hand-roll a `Publisher` wrapper to catch those.

4. **Muzzle needs an `extraDependency` for the `compileOnly` interception library.** Muzzle only puts the module's pinned primary dependency (here, `r2dbc-spi`) on its validation classpath by default. Since the wrap helper and listener classes reference the interception library's own types directly (`ProxyConnectionFactory`, `ProxyMethodExecutionListener`, `QueryExecutionInfo`, ...), muzzle reports them as "missing class" unless you add the interception library explicitly:

```groovy
muzzle {
pass {
group = "io.r2dbc"
module = "r2dbc-spi"
versions = "[1.0.0.RELEASE,)"
extraDependency 'io.r2dbc:r2dbc-proxy:1.1.0.RELEASE'
}
}
```

This is the same `extraDependency` directive used elsewhere for a module's secondary compile-time dependency — it is not R2DBC-specific.

Net result: 1 advice (factory hook) + 1 wrap helper + 1 listener class — smaller than the method-advice alternative (which needs per-method advice on both `Statement.execute()` and `Batch.execute()`, plus a hand-rolled cancel-safe `Publisher` wrapper) and correct by construction on cancellation. The same shape applies to any other SPI in the examples list above: hook the registration/interceptor-installation point, not the client's own operational methods.

### Before writing a new module, scan for an existing one

Before creating `dd-java-agent/instrumentation/$framework/$framework-$version/`, check whether `dd-java-agent/instrumentation/$framework/` already exists and what's in it.
Expand Down Expand Up @@ -123,7 +211,7 @@ A helper class is appropriate when multiple instrumentation classes share the sa
For database-client integrations (`DatabaseClientDecorator` / `DBTypeProcessingDatabaseClientDecorator`), capture connection metadata (host, port, db name, user) at **connection-establishment** time and cache it in a `ContextStore` keyed on the connection object — not lazily on the first query. The canonical pattern is a dedicated instrumentation on the connect/factory method:

- **JDBC** — `dd-java-agent/instrumentation/jdbc/DriverInstrumentation.java` hooks `Driver.connect(url, props)` and populates `InstrumentationContext.get(Connection.class, DBInfo.class)` at open time. Statement advice then reads the already-cached `DBInfo`.
- **Reactive drivers with an async connect** — the equivalent connect point is the connection FACTORY, not the connection object. For R2DBC, `io.r2dbc.spi.ConnectionFactoryOptions` is the only place host/port/database/user are exposed as structured data; `io.r2dbc.spi.ConnectionMetadata` (on the live `Connection`) exposes ONLY product name/version. **But `ConnectionFactory.create()` is a zero-argument SPI method returning a `Publisher<? extends Connection>` — the options are NOT available at `create()`.** Capture them earlier, where the factory is built: hook `ConnectionFactories.get(ConnectionFactoryOptions)` (or the provider-construction path) and store the options in a `ContextStore<ConnectionFactory, ConnectionFactoryOptions>`; then, in advice on `create()`, read the stored options for that factory and thread them onto the asynchronously-emitted `Connection` (a second context store keyed on the returned `Connection`). Hooking only `Connection.createStatement()` + `ConnectionMetadata` CANNOT populate `db.name`/`peer.hostname`/`db.user`/port. (OpenTelemetry's R2DBC instrumentation does exactly this options→factory→connection threading; it is a good reference.)
- **Reactive drivers with an async connect** — the equivalent connect point is the connection FACTORY, not the connection object. For R2DBC, `io.r2dbc.spi.ConnectionFactoryOptions` is the only place host/port/database/user are exposed as structured data; `io.r2dbc.spi.ConnectionMetadata` (on the live `Connection`) exposes ONLY product name/version. **But `ConnectionFactory.create()` is a zero-argument SPI method returning a `Publisher<? extends Connection>` — the options are NOT available at `create()`.** Capture them earlier, where the factory is built: hook `ConnectionFactories.get(ConnectionFactoryOptions)` (or the provider-construction path) and store the options in a `ContextStore<ConnectionFactory, ConnectionFactoryOptions>`; then, in advice on `create()`, read the stored options for that factory and thread them onto the asynchronously-emitted `Connection` (a second context store keyed on the returned `Connection`). Hooking only `Connection.createStatement()` + `ConnectionMetadata` CANNOT populate `db.name`/`peer.hostname`/`db.user`/port. (OpenTelemetry's R2DBC instrumentation does exactly this options→factory→connection threading; it is a good reference.) Note also — per "Before hand-writing method advice, check for an official interception SPI" above — that R2DBC has `r2dbc-proxy`, whose `ProxyMethodExecutionListener` surfaces this connection/query metadata through a listener and handles the reactive lifecycle for you; prefer it over hand-wrapping the `create()`/`execute()` publishers where it fits.

Why eager-at-connect beats lazy-per-query: lazy extraction (e.g. `statement.getConnection().getMetaData().getURL()` on first execute) works for plain JDBC but (a) pays the extraction cost on every connection's first query instead of amortizing at pool-open, and (b) silently yields nothing when the metadata is not reachable from the object the query advice happens to hold — which is exactly what happens for reactive drivers whose statement/connection objects don't carry the factory options.

Expand Down
Loading