Skip to content

feat(llmobs): emit gen_ai.* APM tags when LLMObs is disabled - #10300

Draft
mz1119 wants to merge 2 commits into
max.zhang/llmobs-gen-ai-apm-tagsfrom
max.zhang/llmobs-gen-ai-apm-tags-when-disabled
Draft

feat(llmobs): emit gen_ai.* APM tags when LLMObs is disabled#10300
mz1119 wants to merge 2 commits into
max.zhang/llmobs-gen-ai-apm-tagsfrom
max.zhang/llmobs-gen-ai-apm-tags-when-disabled

Conversation

@mz1119

@mz1119 mz1119 commented Sep 11, 2026

Copy link
Copy Markdown

What does this PR do?

Emits the gen_ai.* APM attributes when LLM Observability is disabled.

LLMObsPlugin.configure unsubscribed the whole LLMObs layer when DD_LLMOBS_ENABLED was off, so there was no hook left to tag from. It now stays subscribed in that case and runs a reduced path:

  • startgen_ai.operation.name, gen_ai.request.model, gen_ai.provider.name, gen_ai.application.name, gen_ai.conversation.id, from the span register options every integration already builds.
  • asyncEnd → any value an integration can only resolve once the operation finished (token usage, a session id the response carries, a corrected span kind), from a new per-integration getGenAiApmEndTags hook.

An integration opt-out (tracer.use(<name>, { llmobs: false })) still disables the layer entirely.

Motivation

Stacked on #10261, which only covers the LLMObs-enabled path (the span processor tags on finish, and only for registered spans). APM-only users got no searchable model, provider, application, conversation or token usage at all. Matches DataDog/dd-trace-py#20083 and DataDog/dd-trace-java#12450.

Additional Notes

Merge after #10261.

  • The register options cover the scalars for every integration from one call site. Usage has to be read off the response, so it stays per integration as in dd-trace-py: openai, anthropic, google-genai, vertexai, bedrock (from response headers, since the body is only parsed for the LLMObs payload) and Vercel AI's dd-telemetry path. langchain and the Vercel AI telemetry-event path report scalars only for now.
  • Cost in the disabled path is one small object plus a few setTag calls per LLM call. The subscribers that aggregate streamed chunks accumulate token usage and nothing else, so no message-body work happens and ctx.result is left to the LLMObs path.
  • setGenAiApmTags moved to llmobs/gen-ai-tags.js, shared by the span processor and the plugin base so both paths normalize a facet value identically. updateGenAiApmTags writes only the fields present, so a late value cannot re-default a model the start path already resolved. The tagger's camelCase metric switch became METRIC_KEY_ALIASES so the disabled path accepts the spelling integrations extract.

🤖 Generated with Claude Code

The gen_ai.* attributes only reached the APM span through the LLMObs span
processor, which runs on span finish and only for spans the tagger registered.
With DD_LLMOBS_ENABLED unset, `LLMObsPlugin.configure` unsubscribed the whole
LLMObs layer, so APM-only users got no searchable model, provider, application,
conversation or token usage at all.

Keep the LLMObs plugins subscribed when only LLMObs itself is off, and run a
reduced path in that mode:

  start     emits gen_ai.operation.name, gen_ai.request.model,
            gen_ai.provider.name, gen_ai.application.name and
            gen_ai.conversation.id from the span register options every
            integration already builds
  asyncEnd  emits gen_ai.usage.* from the new `getGenAiApmUsageMetrics` hook

An integration opt-out (`tracer.use(<name>, { llmobs: false })`) still disables
the layer entirely.

The register options cover the scalars for every integration from one call site,
so no per-integration tag code is needed; usage has to be read off the response,
so it is per integration as in dd-trace-py: openai, anthropic, google-genai,
vertexai, and bedrock (from its response headers, since the body is only parsed
for the LLMObs payload). The vercel-ai, langchain and claude-agent-sdk usage
paths are entangled with payload construction and report scalars only for now.

The auxiliary subscribers that aggregate streamed chunks stay inert while LLMObs
is disabled, so the reduced path costs one small object and a few setTag calls
per LLM call and never the message-body work.

`setGenAiApmTags` moves into llmobs/gen-ai-tags.js, shared by the span processor
and the plugin base so both paths normalize a facet value the same way, and the
tagger's camelCase metric mapping becomes METRIC_KEY_ALIASES so the disabled
path can accept the spelling integrations extract.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mz1119
mz1119 requested review from a team as code owners September 11, 2026 17:56
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 11, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-11T18:41:38.371739Z 4770cc4 New commits
🔒 Security Review Completed 2026-09-11T18:43:33.614026Z 4770cc4 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@dd-octo-sts

dd-octo-sts Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Overall package size

Self size: 8.89 MB
Deduped: 9.56 MB
No deduping: 9.56 MB

Dependency sizes | name | version | self size | total size | |------|---------|-----------|------------| | import-in-the-middle | 3.4.0 | 127.33 kB | 447.04 kB | | opentracing | 0.14.7 | 194.81 kB | 194.81 kB | | dc-polyfill | 0.1.11 | 25.74 kB | 25.74 kB |

🤖 This report was automatically generated by heaviest-objects-in-the-universe

@datadog-datadog-prod-us1

datadog-datadog-prod-us1 Bot commented Sep 11, 2026

Copy link
Copy Markdown

Tests

🎉 All green!

🧪 All tests passed
❄️ No new flaky tests detected

🎯 Code Coverage (details)
Patch Coverage: 100.00%
Overall Coverage: 98.21% (+0.05%)

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 4770cc4 | Docs | View more details | Give us feedback!

@codecov

codecov Bot commented Sep 11, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.73%. Comparing base (a2e3ef6) to head (4770cc4).
⚠️ Report is 21 commits behind head on max.zhang/llmobs-gen-ai-apm-tags.

Additional details and impacted files
@@                         Coverage Diff                          @@
##           max.zhang/llmobs-gen-ai-apm-tags   #10300      +/-   ##
====================================================================
+ Coverage                             98.68%   98.73%   +0.05%     
====================================================================
  Files                                  1006     1009       +3     
  Lines                                158274   158869     +595     
  Branches                                 74       74              
====================================================================
+ Hits                                 156188   156856     +668     
+ Misses                                 2086     2013      -73     
Flag Coverage Δ
ai-guard 65.83% <97.27%> (+0.64%) ⬆️
apm-capabilities 63.71% <49.18%> (+<0.01%) ⬆️
apm-integrations 81.51% <96.87%> (+0.04%) ⬆️
appsec 58.93% <96.87%> (+0.02%) ⬆️
debugger 69.93% <96.87%> (+0.12%) ⬆️
instrumentation 53.91% <96.87%> (+0.25%) ⬆️
llmobs 79.33% <100.00%> (+0.20%) ⬆️
master-coverage ?
openfeature 66.48% <96.87%> (+0.07%) ⬆️
platform 69.39% <96.87%> (+0.07%) ⬆️
profiling 66.84% <96.87%> (+0.07%) ⬆️
serverless 65.98% <83.68%> (+0.24%) ⬆️
test-optimization 82.63% <96.87%> (-0.21%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 807380d523

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/dd-trace/src/llmobs/plugins/base.js
Comment thread packages/dd-trace/src/llmobs/plugins/anthropic/index.js Outdated
Comment thread packages/dd-trace/src/llmobs/plugins/base.js
Comment thread packages/dd-trace/src/llmobs/plugins/claude-agent-sdk/index.js Outdated
@pr-commenter

pr-commenter Bot commented Sep 11, 2026

Copy link
Copy Markdown

Benchmarks

Benchmark execution time: 2026-09-11 18:47:33

Comparing candidate commit 4770cc4 in PR branch max.zhang/llmobs-gen-ai-apm-tags-when-disabled with baseline commit a2e3ef6 in branch max.zhang/llmobs-gen-ai-apm-tags.

📊 Benchmarking dashboard

Found 0 performance improvements and 0 performance regressions! Performance is the same for 2305 metrics, 8 unstable metrics.

Explanation

This is an A/B test comparing a candidate commit's performance against that of a baseline commit. Performance changes are noted in the tables below as:

  • 🟩 = significantly better candidate vs. baseline
  • 🟥 = significantly worse candidate vs. baseline

We compute a confidence interval (CI) over the relative difference of means between metrics from the candidate and baseline commits, considering the baseline as the reference.

If the CI is entirely outside the configured SIGNIFICANT_IMPACT_THRESHOLD (or the deprecated UNCONFIDENCE_THRESHOLD), the change is considered significant.

Feel free to reach out to #apm-benchmarking-platform on Slack if you have any questions.

More details about the CI and significant changes

You can imagine this CI as a range of values that is likely to contain the true difference of means between the candidate and baseline commits.

CIs of the difference of means are often centered around 0%, because often changes are not that big:

---------------------------------(------|---^--------)-------------------------------->
                              -0.6%    0%  0.3%     +1.2%
                                 |          |        |
         lower bound of the CI --'          |        |
sample mean (center of the CI) -------------'        |
         upper bound of the CI ----------------------'

As described above, a change is considered significant if the CI is entirely outside the configured SIGNIFICANT_IMPACT_THRESHOLD (or the deprecated UNCONFIDENCE_THRESHOLD).

For instance, for an execution time metric, this confidence interval indicates a significantly worse performance:

----------------------------------------|---------|---(---------^---------)---------->
                                       0%        1%  1.3%      2.2%      3.1%
                                                  |   |         |         |
       significant impact threshold --------------'   |         |         |
                      lower bound of CI --------------'         |         |
       sample mean (center of the CI) --------------------------'         |
                      upper bound of CI ----------------------------------'

Unstable benchmarks

These benchmarks have a confidence interval too wide to call a change; treat them as noise rather than signal.

scenario:appsec-iast-no-vulnerability-iast-enabled-default-config-20

  • unstable max_rss_usage [-15.296MB; +18.886MB] or [-6.690%; +8.260%]

scenario:debugger-line-probe-with-snapshot-minimal-24

  • unstable max_rss_usage [-9.744MB; +15.012MB] or [-4.365%; +6.725%]

scenario:debugger-line-probe-with-snapshot-minimal-26

  • unstable max_rss_usage [-11.534MB; +15.134MB] or [-4.633%; +6.079%]

scenario:encoders-0.4-immediate-flush-20

  • unstable max_rss_usage [-4.906MB; +12.729MB] or [-6.525%; +16.930%]

scenario:fs-subscribed-26

  • unstable execution_time [-49.307ms; +77.724ms] or [-3.910%; +6.164%]

scenario:llmobs-encode-unicode-mixed-20

  • unstable max_rss_usage [-4.879MB; +11.553MB] or [-6.390%; +15.129%]

scenario:plugin-mongodb-core-plain-find-26

  • unstable execution_time [-103.244ms; +162.222ms] or [-5.039%; +7.918%]
  • unstable throughput [-254057.126op/s; +162674.930op/s] or [-6.296%; +4.032%]

The reduced path read everything from the span register options at start, so any
value an integration only resolves later was missing or wrong:

- Vercel AI's dd-telemetry path carries the model in the span attributes, not in
  the register options, so model-backed spans reported the `custom` fallback.
- Anthropic and google-genai build the streamed response in their own chunk
  subscriber, which the reduced path had inert, so streamed calls reported no
  `gen_ai.usage.*` at all.
- The claude-agent-sdk query span learns its session id from the resolved
  stream, so the root agent span never got `gen_ai.conversation.id`.
- A claude-agent-sdk tool span that launches a subagent is promoted from `tool`
  to `agent` once the nested step records its id, which the reduced path skipped,
  so those wrappers reported `gen_ai.operation.name=tool`.

Generalize the end hook from `getGenAiApmUsageMetrics` to `getGenAiApmEndTags`,
returning any `gen_ai.*` value resolved once the operation finished, including a
corrected span kind. `updateGenAiApmTags` writes only the fields present, so a
late update cannot re-default a model the start path already resolved.

The two chunk subscribers now accumulate token usage while LLMObs is disabled and
nothing else, leaving `ctx.result` to the LLMObs path since the google-genai
tracing plugin reads it for `google_genai.response.model`. Anthropic's usage
merge is shared with the enabled aggregation rather than duplicated.

Reported by Codex on #10300.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4770cc466b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +79 to +81
getGenAiApmEndTags (ctx) {
const response = ctx.result?.data
return { metrics: response && this._extractMetrics(response) }

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 Carry the resolved OpenAI model into end tags

When LLMObs is disabled and OpenAI returns a model name different from the request value (for example, an alias resolves to a dated model version), this end hook updates only usage, so gen_ai.request.model retains the less accurate request model. The enabled path explicitly replaces it with response.model at lines 110–113; include that value in these end tags so the new APM-only path reports the same resolved model.

Useful? React with 👍 / 👎.

spanKind: 'llm',
modelName: request.params.modelId.toLowerCase(),
modelProvider: 'amazon_bedrock',
metrics: extractTokens({ tokensFromHeaders, usage: {} }),

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 Read Converse usage from the response

When LLMObs is disabled for Bedrock converse or converseStream, this always passes an empty body-usage object and relies on token-count headers. The recorded Converse responses contain usage only in response.usage or the stream's metadata.usage event and do not carry the x-amzn-bedrock-*-token-count headers, so these spans emit every gen_ai.usage.* metric as zero. Preserve lightweight Converse stream usage and use the already-deserialized response usage for non-stream calls.

Useful? React with 👍 / 👎.

processedKey = REASONING_OUTPUT_TOKENS_METRIC_KEY
break
}
const processedKey = METRIC_KEY_ALIASES[key] ?? key

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 Restrict metric aliases to own keys

When a public LLMObs.annotate() call supplies a valid custom metric named constructor, toString, valueOf, or __proto__, this lookup resolves an inherited property from Object.prototype instead of falling back to the original metric name. The resulting event stores the value under a coerced function/object string rather than the user-provided key, whereas the previous switch preserved arbitrary custom names; use an own-property check or a null-prototype alias map.

Useful? React with 👍 / 👎.

@mz1119
mz1119 marked this pull request as draft September 11, 2026 18:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant