From d977186f3c5e1a9ffdef7dace5089c26002b1c17 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 25 Sep 2026 12:07:40 -0400 Subject: [PATCH 1/9] Add @StaticLifetime and @Singleton marker annotations @StaticLifetime marks a field that must live for the whole program rather than for its enclosing instance's lifetime (e.g. a cache allocated per-request instead of once). @Singleton is its escape valve, declaring a class has exactly one process-wide instance, so an instance field on it can satisfy @StaticLifetime without being static. Wires a matching check into the perf-review checks doc, mirroring the existing @NoEscape field-storage check. APMLP-1846, APMLP-1847 Co-Authored-By: Claude Sonnet 5 --- .../skills/perf-review/references/checks.md | 5 +- .../datadog/trace/api/function/Singleton.java | 38 +++++++++++ .../trace/api/function/StaticLifetime.java | 64 +++++++++++++++++++ 3 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 internal-api/src/main/java/datadog/trace/api/function/Singleton.java create mode 100644 internal-api/src/main/java/datadog/trace/api/function/StaticLifetime.java diff --git a/.agents/skills/perf-review/references/checks.md b/.agents/skills/perf-review/references/checks.md index abcfbcf073a..a143e3febba 100644 --- a/.agents/skills/perf-review/references/checks.md +++ b/.agents/skills/perf-review/references/checks.md @@ -26,14 +26,15 @@ Format: **pattern** — *expensive when (the interprocedural condition to trace) 6. **FFI / native-boundary crossing on a hot path** *(central to the shared-core effort)* — *a native crossing per-span/per-item (not batched), or transporting strings/objects rather than primitives/IDs* — flag-with-confidence (boundary cost is mechanism-determined; runtime-specific pinning → addendum) — SEV-1/2 (SEV-1 if it blocks/pins under concurrency) — fix: batch (one per flush, not per item); transport interned IDs not strings; keep crossings off the hot/concurrency path. 7. **Escape / allocation-elision defeated** *(Java/Go/.NET/V8 all have a version)* — *a refactor makes a previously-local object escape (stored, returned, captured by a closure, passed to a virtual/non-inlined call) → silent heap allocation on a hot path* — **flag-as-measure** ("may now escape and allocate; verify with an allocation profiler") — SEV-2/3 — fix: keep it local; avoid the escaping store/capture. 8. **`@NoEscape` field-storage violation** — *a field (instance or static, directly or as a generic type argument) declared with an `@NoEscape`-annotated type (`datadog.trace.api.function.NoEscape`), **or** initialized directly from a call to an `@NoEscape`-annotated method — with no comment at the declaration justifying the retention*. The type form marks a concrete type (e.g. `SubSequence`); the method form exists for a return value whose concrete type can't itself carry the annotation (an anonymous class or lambda implementing a JDK interface). The annotation's own javadoc carries a self-contained "Checker contract" section (trigger / not-a-trigger / violation example / compliant example, both forms) written so this can be checked from the diff alone, with no other context needed. The underlying rule is "should", not "must" (RFC-2119 sense): a trigger is a presumptive finding, not an automatic failure — a field with a `// Retained on purpose: `-style comment is compliant. **flag-with-confidence** — SEV-2/3 (SEV-1 if the annotated type/method shares backing storage with something large, per its own javadoc). Current wearers: `SubSequence`, `Maybe` (type form; see J7 below for `SubSequence`'s specific retention-vs-transient discriminator). **No lint enforces this yet — the AI reviewer is the only check, so this stays here (not under deterministic-lint candidates below) until a checker lands and it can migrate down.** +9. **`@StaticLifetime` field-lifetime violation** — *a field annotated `@StaticLifetime` (`datadog.trace.api.function.StaticLifetime`) that is not `static final`, not a `static final ClassValue`, and not an instance field declared on a class annotated `@Singleton` (`datadog.trace.api.function.Singleton`)*. Declaration-scan only, same shape as the `@NoEscape` field-storage check above — it does not attempt to prove a field is actually reused enough to be worth caching, and it does not catch an unannotated per-instance cache; it only guards fields that opt in. `@Singleton`, applied to a class, is a trusted, unverified declaration (v1 posture — the same stance the other perf-contract markers take toward `static final` itself) that the class has exactly one process-wide instance; declaring it satisfies `@StaticLifetime` for that class's instance fields. **flag-with-confidence** — SEV-2/3, motivated by a real production defect (a per-request-constructed cache that never actually amortized anything — see the annotation's own javadoc for the full trigger/accepted-form table). Refines the "per-call cache / expensive-object creation that should be static/once" deterministic-lint candidate below by giving that pattern's opted-in subset an explicit annotation and trigger; the unannotated general case is still that line's job. **No lint enforces this yet — the AI reviewer is the only check, so this stays here (not under deterministic-lint candidates below) until a checker lands and it can migrate down.** ## Deterministic-lint candidates (DON'T spend AI budget — make these real lints) Fixed-signature, mechanically checkable: -- per-call cache / regex / expensive-object creation that should be static/once +- per-call cache / regex / expensive-object creation that should be static/once (the `@StaticLifetime`-annotated subset is checked above; this line covers the still-unannotated general case) - boxing in specific hot APIs - using a string-API where an id-API exists on a hot decorator - the existing convention rules (e.g. don't extract one-shot instrumentation methods to constants) -- *(grows as patterns prove mechanically checkable — migrate them off the AI as they stabilize; the `@NoEscape` field-storage check above belongs here once its checker lands)* +- *(grows as patterns prove mechanically checkable — migrate them off the AI as they stabilize; the `@NoEscape` and `@StaticLifetime` field-storage checks above belong here once their checkers land)* ## Java addendum (JVM-specific — mechanism authored with JIT-developer authority; **calibrate production-priority against your own escalation history**) Refines the universal checks with JVM mechanics. Quarantined here, for the Java audience that has the substrate. diff --git a/internal-api/src/main/java/datadog/trace/api/function/Singleton.java b/internal-api/src/main/java/datadog/trace/api/function/Singleton.java new file mode 100644 index 00000000000..12bdf8a7b61 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/function/Singleton.java @@ -0,0 +1,38 @@ +package datadog.trace.api.function; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a class as having exactly one instance for the life of the process -- constructed once, + * reachable only through a single static field, DI-container registration, or other holder that + * itself guarantees there is no second instance. + * + *

This exists as the escape valve for {@link StaticLifetime}: a field can legitimately live on + * an instance, not as {@code static}, if that instance itself is guaranteed to be a process-wide + * singleton. Without this annotation, {@code @StaticLifetime} would have no way to accept a + * legitimate cache held on a singleton-scoped instance without also accepting one held on an + * ordinary, possibly-repeatedly-constructed instance -- which is exactly the defect shape it exists + * to catch. + * + *

This is a documentation-and-tooling marker; it changes no behavior. + * + *

v1 posture: trusted declaration, not independently verified. This is the same stance + * the other perf-contract annotations take toward {@code static final} itself -- declared, not + * proven. Verifying it for real (a single construction site, or an instance reachable only via one + * static/DI-registered path) is a call-site/construction-graph problem, out of scope for v1. + * Annotating a class that is, in fact, constructed more than once defeats every guarantee + * downstream checks (starting with {@link StaticLifetime}) build on top of this annotation -- apply + * it with the same care as any other unverified perf-contract claim. + * + *

On a class ({@link ElementType#TYPE}): every instance field of this class may serve as + * the process-wide holder {@link StaticLifetime} requires, because the class itself is guaranteed + * to have at most one instance. + */ +@Documented +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.TYPE) +public @interface Singleton {} diff --git a/internal-api/src/main/java/datadog/trace/api/function/StaticLifetime.java b/internal-api/src/main/java/datadog/trace/api/function/StaticLifetime.java new file mode 100644 index 00000000000..81d7f9942b8 --- /dev/null +++ b/internal-api/src/main/java/datadog/trace/api/function/StaticLifetime.java @@ -0,0 +1,64 @@ +package datadog.trace.api.function; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a field that must live for the whole program, not merely for the lifetime of its enclosing + * instance -- typically a cache or other expensive object whose entire value comes from being + * amortized across many uses, which a per-instance field can silently defeat if that instance + * itself doesn't live for the whole program. + * + *

The name deliberately echoes Rust's {@code 'static} lifetime rather than the Java keyword + * {@code static} itself: the property this enforces is "lives for the whole program," and a {@link + * java.lang.ClassValue}-backed holder satisfies that without carrying the literal {@code static} + * modifier. + * + *

Motivating defect: a cache constructed per-request, as an instance field on a per-request + * object, instead of once as a {@code static} field -- so the cache was allocated and thrown away + * on every request and never actually amortized anything. It compiled, ran, and passed tests while + * quietly defeating the entire point of caching -- the same silent-failure shape as the other + * perf-contract annotations in this package. + * + *

This is a documentation-and-tooling marker; it changes no behavior. It exists to telegraph the + * constraint to readers and to give a future checker something to verify. The discipline it names + * is not yet enforced; hold to it by hand until the checker lands. + * + *

On a field ({@link ElementType#FIELD}): the field's value must actually live for the + * whole program, not just for as long as its enclosing instance happens to. + * + *

Checker contract. The rule below is written to be machine-checkable -- by a future + * static checker, or in the meantime by an AI reviewer -- without needing to read this class's + * prose above. This is a declaration-scan check: it looks at how the field is declared, not at + * whether it is, in practice, reused enough to be worth its cost, and it does not attempt to find + * unannotated per-instance caches -- it only guards fields that opt in. + * + *

    + *
  • Accepted (satisfies the contract): a {@code static final} field of the cache / + * expensive-object type; a {@code static final} {@link java.lang.ClassValue}{@code } used + * for a per-class one-shot computation; or an instance field, on a class annotated {@link + * Singleton}, whose enclosing instance is thereby guaranteed to be process-wide. + *
  • Trigger (violation): an instance field of the annotated type on a class that is + * not annotated {@link Singleton} -- even if, in practice, the class is only ever + * constructed once per logical "session". This check is declaration-local; it does not + * attempt to prove single construction dynamically, so a plain instance field never satisfies + * the contract on its own. + *
  • Violation example: {@code private final DDCache cache = + * DDCaches.newFixedSizeCache(128);} as an instance field of a class constructed per-request, + * per-call, or otherwise more than once for the life of the process. + *
  • Compliant example (static): {@code private static final DDCache CACHE = + * DDCaches.newFixedSizeCache(128);} + *
  • Compliant example (singleton-scoped instance): {@code @Singleton class Registry { + * private final DDCache cache = DDCaches.newFixedSizeCache(128); }} + *
  • Out of scope (v1): proving a cache is reused enough to be worth its allocation cost; + * catching a non-static cache that isn't annotated with this marker. Widen this contract only + * once a real case proves it insufficient, rather than guessing ahead of one. + *
+ */ +@Documented +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.FIELD) +public @interface StaticLifetime {} From 88d3229a9c6a64afe74ca176db3b40e9ce440121 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 25 Sep 2026 12:21:06 -0400 Subject: [PATCH 2/9] Add Checker contract section to @Singleton javadoc Splits narrative vs. machine-checkable rule, matching the convention established by @NoEscape and @StaticLifetime. Co-Authored-By: Claude Sonnet 5 --- .../src/main/java/datadog/trace/api/function/Singleton.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal-api/src/main/java/datadog/trace/api/function/Singleton.java b/internal-api/src/main/java/datadog/trace/api/function/Singleton.java index 12bdf8a7b61..ede3d796e10 100644 --- a/internal-api/src/main/java/datadog/trace/api/function/Singleton.java +++ b/internal-api/src/main/java/datadog/trace/api/function/Singleton.java @@ -31,6 +31,12 @@ *

On a class ({@link ElementType#TYPE}): every instance field of this class may serve as * the process-wide holder {@link StaticLifetime} requires, because the class itself is guaranteed * to have at most one instance. + * + *

Checker contract. This annotation has no violation condition of its own in v1 -- there + * is nothing to flag on the class itself, since the declaration is trusted rather than verified. + * Its only role in automated review is as an input fact to {@link StaticLifetime}'s checker: a + * field otherwise flagged by that check is accepted instead when its enclosing class carries this + * annotation. See {@link StaticLifetime}'s own Checker contract section for the full rule. */ @Documented @Retention(RetentionPolicy.CLASS) From e98399f1353331bba5a97fca4070a8a5ca2145a4 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 25 Sep 2026 12:26:19 -0400 Subject: [PATCH 3/9] Annotate WebFlux route cache with @StaticLifetime Spot-checks the annotation against the real defect it was modeled on: PR #12561 (commit e6b875623e) made this exact DDCache field static after it lived per-instance, unshared, for ~4 years. Serves as the first real call site for @StaticLifetime. Co-Authored-By: Claude Sonnet 5 --- .../springwebflux/server/RouteOnSuccessOrError.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dd-java-agent/instrumentation/spring/spring-webflux/spring-webflux-5.0/src/main/java/datadog/trace/instrumentation/springwebflux/server/RouteOnSuccessOrError.java b/dd-java-agent/instrumentation/spring/spring-webflux/spring-webflux-5.0/src/main/java/datadog/trace/instrumentation/springwebflux/server/RouteOnSuccessOrError.java index 09b0627eb1a..3f8d68a97bd 100644 --- a/dd-java-agent/instrumentation/spring/spring-webflux/spring-webflux-5.0/src/main/java/datadog/trace/instrumentation/springwebflux/server/RouteOnSuccessOrError.java +++ b/dd-java-agent/instrumentation/spring/spring-webflux/spring-webflux-5.0/src/main/java/datadog/trace/instrumentation/springwebflux/server/RouteOnSuccessOrError.java @@ -4,6 +4,7 @@ import datadog.trace.api.cache.DDCache; import datadog.trace.api.cache.DDCaches; +import datadog.trace.api.function.StaticLifetime; import datadog.trace.bootstrap.instrumentation.api.AgentSpan; import java.util.function.Consumer; import java.util.function.Function; @@ -31,6 +32,9 @@ public class RouteOnSuccessOrError implements Consumer> { .trim()) .replaceAll(""); + // Shared across requests; per-instance would silently defeat the cache (fixed by 12561, see + // datadog.trace.api.function.StaticLifetime for the general pattern this guards against). + @StaticLifetime private static final DDCache PARSED_ROUTE_CACHE = DDCaches.newFixedSizeCache(64); private final RouterFunction routerFunction; From 4acee829ec6a409634a76d16dbb1d34739f97a10 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 25 Sep 2026 12:30:21 -0400 Subject: [PATCH 4/9] Annotate UTF8 tag/value caches with @StaticLifetime TAG_CACHE/VALUE_CACHE in TraceMapperV0_4 and KEY_CACHE/VALUE_CACHE in OtlpCommonProto are already static final -- marking them adds compliant-example coverage alongside RouteOnSuccessOrError's violation-turned-compliant case. Co-Authored-By: Claude Sonnet 5 --- .../datadog/trace/common/writer/ddagent/TraceMapperV0_4.java | 3 +++ .../java/datadog/trace/core/otlp/common/OtlpCommonProto.java | 3 +++ 2 files changed, 6 insertions(+) diff --git a/dd-trace-core/src/main/java/datadog/trace/common/writer/ddagent/TraceMapperV0_4.java b/dd-trace-core/src/main/java/datadog/trace/common/writer/ddagent/TraceMapperV0_4.java index cbdcbd41433..e27933fbce3 100644 --- a/dd-trace-core/src/main/java/datadog/trace/common/writer/ddagent/TraceMapperV0_4.java +++ b/dd-trace-core/src/main/java/datadog/trace/common/writer/ddagent/TraceMapperV0_4.java @@ -11,6 +11,7 @@ import datadog.communication.serialization.msgpack.MsgPackWriter; import datadog.trace.api.Config; import datadog.trace.api.TagMap; +import datadog.trace.api.function.StaticLifetime; import datadog.trace.bootstrap.instrumentation.api.InstrumentationTags; import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; import datadog.trace.common.writer.Payload; @@ -27,11 +28,13 @@ import okhttp3.RequestBody; public final class TraceMapperV0_4 implements TraceMapper { + @StaticLifetime static final SimpleUtf8Cache TAG_CACHE = Config.get().getTagNameUtf8CacheSize() > 0 ? new SimpleUtf8Cache(Config.get().getTagNameUtf8CacheSize()) : null; + @StaticLifetime static final GenerationalUtf8Cache VALUE_CACHE = Config.get().getTagValueUtf8CacheSize() > 0 ? new GenerationalUtf8Cache(Config.get().getTagValueUtf8CacheSize()) diff --git a/dd-trace-core/src/main/java/datadog/trace/core/otlp/common/OtlpCommonProto.java b/dd-trace-core/src/main/java/datadog/trace/core/otlp/common/OtlpCommonProto.java index 88fc102a335..57783b89134 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/otlp/common/OtlpCommonProto.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/otlp/common/OtlpCommonProto.java @@ -14,6 +14,7 @@ import datadog.communication.serialization.SimpleUtf8Cache; import datadog.communication.serialization.StreamingBuffer; import datadog.trace.api.Config; +import datadog.trace.api.function.StaticLifetime; import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; import datadog.trace.bootstrap.otel.common.OtelInstrumentationScope; import java.nio.ByteBuffer; @@ -34,12 +35,14 @@ private OtlpCommonProto() {} public static final int I32_WIRE_TYPE = 5; // use same cache approach for attribute keys as TraceMapperV0_4 + @StaticLifetime private static final SimpleUtf8Cache KEY_CACHE = Config.get().getTagNameUtf8CacheSize() > 0 ? new SimpleUtf8Cache(Config.get().getTagNameUtf8CacheSize()) : null; // use same cache approach for attribute values as TraceMapperV0_4 + @StaticLifetime private static final GenerationalUtf8Cache VALUE_CACHE = Config.get().getTagValueUtf8CacheSize() > 0 ? new GenerationalUtf8Cache(Config.get().getTagValueUtf8CacheSize()) From 982b12aa7eeea93eed754996afe0c0b8300d82d9 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 25 Sep 2026 14:01:30 -0400 Subject: [PATCH 5/9] Revert "Annotate WebFlux route cache with @StaticLifetime" Holding off on applying @StaticLifetime to real cache fields until @SuppressPerfContract (dougqh/perf-contract-suppress, PR #12647) lands -- want the exemption mechanism in place before widening the annotated surface. Co-Authored-By: Claude Sonnet 5 --- .../springwebflux/server/RouteOnSuccessOrError.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/dd-java-agent/instrumentation/spring/spring-webflux/spring-webflux-5.0/src/main/java/datadog/trace/instrumentation/springwebflux/server/RouteOnSuccessOrError.java b/dd-java-agent/instrumentation/spring/spring-webflux/spring-webflux-5.0/src/main/java/datadog/trace/instrumentation/springwebflux/server/RouteOnSuccessOrError.java index 3f8d68a97bd..09b0627eb1a 100644 --- a/dd-java-agent/instrumentation/spring/spring-webflux/spring-webflux-5.0/src/main/java/datadog/trace/instrumentation/springwebflux/server/RouteOnSuccessOrError.java +++ b/dd-java-agent/instrumentation/spring/spring-webflux/spring-webflux-5.0/src/main/java/datadog/trace/instrumentation/springwebflux/server/RouteOnSuccessOrError.java @@ -4,7 +4,6 @@ import datadog.trace.api.cache.DDCache; import datadog.trace.api.cache.DDCaches; -import datadog.trace.api.function.StaticLifetime; import datadog.trace.bootstrap.instrumentation.api.AgentSpan; import java.util.function.Consumer; import java.util.function.Function; @@ -32,9 +31,6 @@ public class RouteOnSuccessOrError implements Consumer> { .trim()) .replaceAll(""); - // Shared across requests; per-instance would silently defeat the cache (fixed by 12561, see - // datadog.trace.api.function.StaticLifetime for the general pattern this guards against). - @StaticLifetime private static final DDCache PARSED_ROUTE_CACHE = DDCaches.newFixedSizeCache(64); private final RouterFunction routerFunction; From 0848e09635f653f1916bc3aeb6534e8b85f0e258 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Fri, 25 Sep 2026 14:01:36 -0400 Subject: [PATCH 6/9] Revert "Annotate UTF8 tag/value caches with @StaticLifetime" Same reason as the WebFlux route-cache revert: hold off until @SuppressPerfContract lands. Co-Authored-By: Claude Sonnet 5 --- .../datadog/trace/common/writer/ddagent/TraceMapperV0_4.java | 3 --- .../java/datadog/trace/core/otlp/common/OtlpCommonProto.java | 3 --- 2 files changed, 6 deletions(-) diff --git a/dd-trace-core/src/main/java/datadog/trace/common/writer/ddagent/TraceMapperV0_4.java b/dd-trace-core/src/main/java/datadog/trace/common/writer/ddagent/TraceMapperV0_4.java index e27933fbce3..cbdcbd41433 100644 --- a/dd-trace-core/src/main/java/datadog/trace/common/writer/ddagent/TraceMapperV0_4.java +++ b/dd-trace-core/src/main/java/datadog/trace/common/writer/ddagent/TraceMapperV0_4.java @@ -11,7 +11,6 @@ import datadog.communication.serialization.msgpack.MsgPackWriter; import datadog.trace.api.Config; import datadog.trace.api.TagMap; -import datadog.trace.api.function.StaticLifetime; import datadog.trace.bootstrap.instrumentation.api.InstrumentationTags; import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; import datadog.trace.common.writer.Payload; @@ -28,13 +27,11 @@ import okhttp3.RequestBody; public final class TraceMapperV0_4 implements TraceMapper { - @StaticLifetime static final SimpleUtf8Cache TAG_CACHE = Config.get().getTagNameUtf8CacheSize() > 0 ? new SimpleUtf8Cache(Config.get().getTagNameUtf8CacheSize()) : null; - @StaticLifetime static final GenerationalUtf8Cache VALUE_CACHE = Config.get().getTagValueUtf8CacheSize() > 0 ? new GenerationalUtf8Cache(Config.get().getTagValueUtf8CacheSize()) diff --git a/dd-trace-core/src/main/java/datadog/trace/core/otlp/common/OtlpCommonProto.java b/dd-trace-core/src/main/java/datadog/trace/core/otlp/common/OtlpCommonProto.java index 57783b89134..88fc102a335 100644 --- a/dd-trace-core/src/main/java/datadog/trace/core/otlp/common/OtlpCommonProto.java +++ b/dd-trace-core/src/main/java/datadog/trace/core/otlp/common/OtlpCommonProto.java @@ -14,7 +14,6 @@ import datadog.communication.serialization.SimpleUtf8Cache; import datadog.communication.serialization.StreamingBuffer; import datadog.trace.api.Config; -import datadog.trace.api.function.StaticLifetime; import datadog.trace.bootstrap.instrumentation.api.UTF8BytesString; import datadog.trace.bootstrap.otel.common.OtelInstrumentationScope; import java.nio.ByteBuffer; @@ -35,14 +34,12 @@ private OtlpCommonProto() {} public static final int I32_WIRE_TYPE = 5; // use same cache approach for attribute keys as TraceMapperV0_4 - @StaticLifetime private static final SimpleUtf8Cache KEY_CACHE = Config.get().getTagNameUtf8CacheSize() > 0 ? new SimpleUtf8Cache(Config.get().getTagNameUtf8CacheSize()) : null; // use same cache approach for attribute values as TraceMapperV0_4 - @StaticLifetime private static final GenerationalUtf8Cache VALUE_CACHE = Config.get().getTagValueUtf8CacheSize() > 0 ? new GenerationalUtf8Cache(Config.get().getTagValueUtf8CacheSize()) From c6b03bb84d424dbe0904d6b004704a15574dd66d Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 28 Sep 2026 16:41:12 -0400 Subject: [PATCH 7/9] Close StaticLifetime checker-contract gaps: require final, static+non-final Per Bits AI review: the contract accepted "static" fields and singleton-scoped instance fields without requiring final, so a mutable field of either shape could pass while still being reassignable to a fresh, cold instance at runtime -- defeating the amortization guarantee the annotation exists to protect, just via a write instead of via scope. Add an explicit mutability trigger and require final in the Accepted description for both shapes. Co-Authored-By: Claude Sonnet 5 --- .../trace/api/function/StaticLifetime.java | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/api/function/StaticLifetime.java b/internal-api/src/main/java/datadog/trace/api/function/StaticLifetime.java index 81d7f9942b8..d524ae33af0 100644 --- a/internal-api/src/main/java/datadog/trace/api/function/StaticLifetime.java +++ b/internal-api/src/main/java/datadog/trace/api/function/StaticLifetime.java @@ -39,23 +39,38 @@ *

    *
  • Accepted (satisfies the contract): a {@code static final} field of the cache / * expensive-object type; a {@code static final} {@link java.lang.ClassValue}{@code } used - * for a per-class one-shot computation; or an instance field, on a class annotated {@link - * Singleton}, whose enclosing instance is thereby guaranteed to be process-wide. - *
  • Trigger (violation): an instance field of the annotated type on a class that is - * not annotated {@link Singleton} -- even if, in practice, the class is only ever + * for a per-class one-shot computation; or a {@code final} instance field, on a class + * annotated {@link Singleton}, whose enclosing instance is thereby guaranteed to be + * process-wide. {@code final} is required in both the static and singleton-scoped-instance + * shapes: a reassignable field can be replaced with a fresh instance at any point, silently + * discarding everything amortized in the old one -- the same defeat this annotation exists to + * catch, just triggered by a write instead of by scope. + *
  • Trigger (violation, scope): an instance field of the annotated type on a class that + * is not annotated {@link Singleton} -- even if, in practice, the class is only ever * constructed once per logical "session". This check is declaration-local; it does not * attempt to prove single construction dynamically, so a plain instance field never satisfies * the contract on its own. - *
  • Violation example: {@code private final DDCache cache = + *
  • Trigger (violation, mutability): a {@code static} field of the annotated type, or an + * instance field on a class annotated {@link Singleton}, that is not also {@code final} -- + * reassignable, so nothing stops a fresh instance from silently replacing the amortized one + * at runtime, regardless of scope. + *
  • Violation example (scope): {@code private final DDCache cache = * DDCaches.newFixedSizeCache(128);} as an instance field of a class constructed per-request, * per-call, or otherwise more than once for the life of the process. + *
  • Violation example (mutability): {@code private static DDCache cache = + * DDCaches.newFixedSizeCache(128);} -- {@code static} but not {@code final}, so any code with + * write access can swap in a fresh, cold cache and discard everything amortized in the old + * one. *
  • Compliant example (static): {@code private static final DDCache CACHE = * DDCaches.newFixedSizeCache(128);} *
  • Compliant example (singleton-scoped instance): {@code @Singleton class Registry { * private final DDCache cache = DDCaches.newFixedSizeCache(128); }} *
  • Out of scope (v1): proving a cache is reused enough to be worth its allocation cost; - * catching a non-static cache that isn't annotated with this marker. Widen this contract only - * once a real case proves it insufficient, rather than guessing ahead of one. + * catching a non-static cache that isn't annotated with this marker; a {@code final} field + * whose referent is itself internally mutable/replaceable (e.g. wraps its state in an {@code + * AtomicReference} it swaps) -- {@code final} is checked on the field, not transitively + * through the object graph. Widen this contract only once a real case proves it insufficient, + * rather than guessing ahead of one. *
*/ @Documented From afb73499a8f1cf84b528d652675f1526e32375a6 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Mon, 28 Sep 2026 16:44:03 -0400 Subject: [PATCH 8/9] Fix StaticLifetime's ClassValue paragraph to match its own checker contract Per review: the intro implied a ClassValue-backed holder need not be static itself, contradicting the Accepted list below it, which requires "static final ClassValue". Clarify that it's the per-Class values ClassValue computes that get a free process-wide lifetime, not the holder field referencing the ClassValue instance -- that still has to be static final. Co-Authored-By: Claude Sonnet 5 --- .../datadog/trace/api/function/StaticLifetime.java | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/internal-api/src/main/java/datadog/trace/api/function/StaticLifetime.java b/internal-api/src/main/java/datadog/trace/api/function/StaticLifetime.java index d524ae33af0..4982f5390dc 100644 --- a/internal-api/src/main/java/datadog/trace/api/function/StaticLifetime.java +++ b/internal-api/src/main/java/datadog/trace/api/function/StaticLifetime.java @@ -13,9 +13,13 @@ * itself doesn't live for the whole program. * *

The name deliberately echoes Rust's {@code 'static} lifetime rather than the Java keyword - * {@code static} itself: the property this enforces is "lives for the whole program," and a {@link - * java.lang.ClassValue}-backed holder satisfies that without carrying the literal {@code static} - * modifier. + * {@code static} itself: the property this enforces is "lives for the whole program." A {@link + * java.lang.ClassValue} is the motivating case for that distinction -- each per-{@code Class} value + * it computes lives for the whole program without itself being declared in a {@code static} field + * anywhere, because {@code ClassValue} does its own process-wide caching internally. The holder + * field that points at the {@code ClassValue} instance still needs to be {@code static final} -- + * see the checker contract below -- it's only the individual per-{@code Class} values inside it + * that get their process-wide lifetime for free. * *

Motivating defect: a cache constructed per-request, as an instance field on a per-request * object, instead of once as a {@code static} field -- so the cache was allocated and thrown away From 4ef7e11dbe10beafa2ef471fddc52dd8e30f1489 Mon Sep 17 00:00:00 2001 From: Douglas Q Hawkins Date: Tue, 29 Sep 2026 17:05:19 -0400 Subject: [PATCH 9/9] Move StaticLifetime/Singleton into datadog.perfcontract and meta-annotate with @PerfContract Relocates both annotations out of the legacy datadog.trace.api.function package into the new datadog.perfcontract package (introduced by #12647), alongside PerfContract/SuppressPerfContract, and adds @PerfContract so tooling can discover them as perf-contract markers the same way it already discovers Strategy/StrategyConsumer/NoEscape. No other code references either annotation, so no import-site updates are needed. Co-Authored-By: Claude Sonnet 5 --- .../{trace/api/function => perfcontract}/Singleton.java | 7 ++++--- .../api/function => perfcontract}/StaticLifetime.java | 5 +++-- 2 files changed, 7 insertions(+), 5 deletions(-) rename internal-api/src/main/java/datadog/{trace/api/function => perfcontract}/Singleton.java (90%) rename internal-api/src/main/java/datadog/{trace/api/function => perfcontract}/StaticLifetime.java (98%) diff --git a/internal-api/src/main/java/datadog/trace/api/function/Singleton.java b/internal-api/src/main/java/datadog/perfcontract/Singleton.java similarity index 90% rename from internal-api/src/main/java/datadog/trace/api/function/Singleton.java rename to internal-api/src/main/java/datadog/perfcontract/Singleton.java index ede3d796e10..ba28bf73442 100644 --- a/internal-api/src/main/java/datadog/trace/api/function/Singleton.java +++ b/internal-api/src/main/java/datadog/perfcontract/Singleton.java @@ -1,4 +1,4 @@ -package datadog.trace.api.function; +package datadog.perfcontract; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; @@ -21,8 +21,8 @@ *

This is a documentation-and-tooling marker; it changes no behavior. * *

v1 posture: trusted declaration, not independently verified. This is the same stance - * the other perf-contract annotations take toward {@code static final} itself -- declared, not - * proven. Verifying it for real (a single construction site, or an instance reachable only via one + * the other perf-contract markers take toward {@code static final} itself -- declared, not proven. + * Verifying it for real (a single construction site, or an instance reachable only via one * static/DI-registered path) is a call-site/construction-graph problem, out of scope for v1. * Annotating a class that is, in fact, constructed more than once defeats every guarantee * downstream checks (starting with {@link StaticLifetime}) build on top of this annotation -- apply @@ -39,6 +39,7 @@ * annotation. See {@link StaticLifetime}'s own Checker contract section for the full rule. */ @Documented +@PerfContract @Retention(RetentionPolicy.CLASS) @Target(ElementType.TYPE) public @interface Singleton {} diff --git a/internal-api/src/main/java/datadog/trace/api/function/StaticLifetime.java b/internal-api/src/main/java/datadog/perfcontract/StaticLifetime.java similarity index 98% rename from internal-api/src/main/java/datadog/trace/api/function/StaticLifetime.java rename to internal-api/src/main/java/datadog/perfcontract/StaticLifetime.java index 4982f5390dc..4da418c863c 100644 --- a/internal-api/src/main/java/datadog/trace/api/function/StaticLifetime.java +++ b/internal-api/src/main/java/datadog/perfcontract/StaticLifetime.java @@ -1,4 +1,4 @@ -package datadog.trace.api.function; +package datadog.perfcontract; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; @@ -25,7 +25,7 @@ * object, instead of once as a {@code static} field -- so the cache was allocated and thrown away * on every request and never actually amortized anything. It compiled, ran, and passed tests while * quietly defeating the entire point of caching -- the same silent-failure shape as the other - * perf-contract annotations in this package. + * perf-contract markers. * *

This is a documentation-and-tooling marker; it changes no behavior. It exists to telegraph the * constraint to readers and to give a future checker something to verify. The discipline it names @@ -78,6 +78,7 @@ * */ @Documented +@PerfContract @Retention(RetentionPolicy.CLASS) @Target(ElementType.FIELD) public @interface StaticLifetime {}