Skip to content

feat(otel-thread-ctx): Node.js OTEP-4947 thread-context writer#9210

Open
szegedi wants to merge 7 commits into
masterfrom
otel-thread-context-writer
Open

feat(otel-thread-ctx): Node.js OTEP-4947 thread-context writer#9210
szegedi wants to merge 7 commits into
masterfrom
otel-thread-context-writer

Conversation

@szegedi

@szegedi szegedi commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a Node.js writer for the OpenTelemetry Thread Local Context Record (OTEP-4947), letting out-of-process readers (typically eBPF profilers) sample the active trace/span ID and a small attribute payload with no cooperation from the tracer at read time. Gated behind DD_TRACE_OTEL_CTX_ENABLED (default off).

The writer itself lives in @datadog/pprof (5.16.0+); this branch wires it into the tracer and publishes the accompanying OTEP-4719 process context via libdatadog-nodejs (0.12.1+).

Relevant PRs in other repos that this PR builds upon:

open-telemetry/opentelemetry-specification/pull/4947 OTel Thread Context Record specification
polarsignals/custom-labels/pull/16 ref implementation, has a good description of Node.js specifics
DataDog/pprof-nodejs/pull/347 pprof-nodejs implementation
DataDog/pprof-nodejs/pull/366 follow-up to above
DataDog/libdatadog/pull/2162 libdatadog improvements to OTel Process Context this PR needs
DataDog/libdatadog-nodejs/pull/135 libdatadog-nodejs bindings
DataDog/libdatadog-nodejs/pull/153 some more libdatadog-nodejs bindings

A note on future work: unifying CPU profiler context and OTel thread context

There's lots of similarities in span-related context management between the new writer in otel-thread-ctx.js and the in-process CPU profiler in profiler/wall.js. Two of the commits in this PR extract common functionality (storage-channels.js and web-tags-cache.js) for both, these are elaborated more on below. It would be possible to implement this so that wall.js no longer maintains its own context data, but always uses the OTel context instead. Java and PHP profilers already do this. For us, the biggest blocker is that OTel context record relies on Async Context Frame, and the CPU profiler still needs to support Node.js 22-23 where it's off by default, so we can't unify before our lowest supported version is 24 where ACF is on by default.

It also has some extra runtime cost, but that's only a minor aspect. wall.js currently establishes its context very cheaply by only retaining references to span-related objects, and deferring string conversion until profile serialization, so it only happens for those contexts (~6k of them/minute) that were captured with samples. In contrast, utf-encoded string data need to be written into the OTel thread context immediately for each created span, since we don't know when it will be captured by an external eBPF reader.

We will likely still do the unification, especially if both are enabled by default so the OTel context record is generated anyhow. We can either do the unification during dd-trace-js 6.x cycle but then wall.js will be more complex as it'll have to handle both kinds of contexts, or defer until 7.x next year when the minimum supported Node.js version will be 24 so we can drop wall.js own context and just use OTel.

What's in this branch (commit by commit):

Bump @datadog/pprof to 5.16.0 and @datadog/libdatadog to 0.12.1

pprof 5.16.0 is the first release containing the writer bits this branch depends on; libdatadog 0.12.1 is a bug-fix bump over 0.12.0.

Extract storage-channels module from wall profiler

Pull the dd-trace:storage:enter / :before / dd-trace:span:finish / :tags:update diagnostic-channel wiring out of the wall profiler into a shared packages/dd-trace/src/storage-channels.js, so both the wall profiler and the new thread-context writer can subscribe to the same normalized activation stream. No functional change to the wall profiler.

Extract shared web-tags cache from wall profiler

The OTel thread context writer will need to walk the started-spans chain per span to find the nearest web-server ancestor, just like wall profiler does. For thi reason, we extract the functionality into packages/dd-trace/src/web-tags-cache.js: a single Symbol on the span, one lazy walk per span, and a dd-trace:web-tags:resolved diagnostics channel that fires once per span at the moment a previously-empty answer transitions to a real value via dd-trace:span:tags:update.

Add OTEP-4947 thread context writer

After the first three preparatory commits, this is the actual new functionality.

  • packages/dd-trace/src/otel-thread-ctx.js: the writer. Subscribes to storage-channels; on each storage:enter, builds (or reuses) a ThreadContext from @datadog/pprof.otelThreadCtx for the active span, populates trace/span IDs plus a positional attribute array (index 0 = datadog.local_root_span_id, then datadog.trace_endpoint for web-server spans, datadog.thread_name, datadog.thread_id), and installs it via context.enter(). Handles span-drift (re-installs the same cached context when we switch spans and back) and span-finish (clears the writer if the record is still ours to avoid leaking stale state past enterWith-style activation). Late endpoint discovery is handled via the shared web-tags cache (see below): the writer subscribes to webTagsCache.resolvedCh and appends the endpoint attribute in place when the shared cache signals a transition.
  • packages/dd-trace/src/proxy.js: gated require('./otel-thread-ctx').start() after profiler init.
  • Config wiring: DD_TRACE_OTEL_CTX_ENABLED added to supported-configurations.json; the generated .d.ts picks it up.
  • scripts/docker/: a test:docker:otel-thread-ctx harness that builds inside node:24-bookworm (the writer is Linux+AsyncContextFrame-only; macOS dev machines fall through to the harness).
  • Test suite (packages/dd-trace/test/otel-thread-ctx.spec.js, 20 cases): start() gate matrix, on-enter build/skip/drift, span-finish clear-vs-leave, tags-update endpoint append, and the process-context helper.

Publish OTEP-4947 process-context metadata via process discovery

For OTel thread context record to work correctly, information also needs to be published in the process context.

Sets up the OTEP-4719 process context so an out-of-process reader can decode the on-the-wire records. Adds getThreadLocalMetadata() in otel-thread-ctx.js — pulls the snapshot from @datadog/pprof.otelThreadCtx.getProcessContextAttributes (schema-version string, attribute key map, V8 layout constants) and reshapes it into the napi ThreadLocalMetadata form. tracer_metadata.js passes it as the last positional arg to processDiscovery.TracerMetadata(...). Returns undefined if pprof is missing → publishes without a threadlocal block.

Platform / runtime scope

  • Linux + AsyncContextFrame only: the OTEP-4947 reader contract is ELF-TLSDESC, only meaningful on Linux; the writer requires V8's AsyncContextFrame (default in Node 24+; behind --experimental-async-context-frame on 22/23). On any other environment, start() logs and returns false — no runtime cost.
  • Optional dep: @datadog/pprof is an optionalDependency; if it isn't installed, the writer stays inert and no threadlocal block is published in the process context.

Test plan

  • yarn test:otel-thread-ctx — 20/20 pass
  • yarn mocha --timeout 60000 packages/dd-trace/test/tracer_metadata.spec.js — 11/11 pass
  • yarn mocha --timeout 60000 packages/dd-trace/test/profiling/profilers/wall.spec.js — 32/32 pass (unchanged from master after the shared-cache extraction)

Jira: PROF-15220

@dd-octo-sts

dd-octo-sts Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Overall package size

Self size: 7.5 MB
Deduped: 8.17 MB
No deduping: 8.17 MB

Dependency sizes | name | version | self size | total size | |------|---------|-----------|------------| | import-in-the-middle | 3.3.2 | 124.41 kB | 440.65 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-prod-us1-6

datadog-prod-us1-6 Bot commented Jul 3, 2026

Copy link
Copy Markdown

Pipelines  Tests

⚠️ Warnings

🚦 4 Pipeline jobs failed

Platform | integration-guardrails (22.0.0)   View in Datadog   GitHub Actions

All Green | all-green   View in Datadog   GitHub Actions

DataDog/apm-reliability/dd-trace-js | Ubuntu base image check   View in Datadog   GitLab

View all 4 failed jobs.

ℹ️ Info

No other issues found (see more)

🧪 All tests passed
❄️ No new flaky tests detected

🔄 Datadog retried 1 test - 1 passed on retry View in Datadog

🎯 Code Coverage (details)
Patch Coverage: 100.00%
Overall Coverage: 98.43% (+0.01%)

Useful? React with 👍 / 👎

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 57fef18 | Docs | Datadog PR Page | Give us feedback!

@codecov

codecov Bot commented Jul 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.34%. Comparing base (1161d9d) to head (57fef18).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #9210      +/-   ##
==========================================
- Coverage   98.42%   98.34%   -0.09%     
==========================================
  Files         939      942       +3     
  Lines      126531   126945     +414     
  Branches    10760    10781      +21     
==========================================
+ Hits       124541   124843     +302     
- Misses       1990     2102     +112     
Flag Coverage Δ
aiguard 57.85% <100.00%> (+<0.01%) ⬆️
aiguard-integration 56.26% <72.00%> (+<0.01%) ⬆️
apm-bucket-0 57.70% <100.00%> (+<0.01%) ⬆️
apm-bucket-1 63.76% <100.00%> (-0.01%) ⬇️
apm-bucket-2 62.79% <100.00%> (-0.01%) ⬇️
apm-bucket-3 60.15% <100.00%> (+<0.01%) ⬆️
apm-capabilities-tracing 64.33% <80.07%> (+0.14%) ⬆️
apm-integrations-aerospike 56.71% <100.00%> (+<0.01%) ⬆️
apm-integrations-confluentinc-kafka-javascript 61.67% <100.00%> (+<0.01%) ⬆️
apm-integrations-couchbase 57.14% <100.00%> (-0.01%) ⬇️
apm-integrations-http 62.83% <100.00%> (-0.01%) ⬇️
apm-integrations-kafkajs 62.22% <100.00%> (+<0.01%) ⬆️
apm-integrations-next 59.25% <100.00%> (+<0.01%) ⬆️
apm-integrations-prisma 58.73% <100.00%> (+<0.01%) ⬆️
appsec 73.00% <100.00%> (-0.03%) ⬇️
appsec-express_fastify_graphql 70.48% <100.00%> (-0.01%) ⬇️
appsec-integration 51.44% <72.00%> (+<0.01%) ⬆️
appsec-kafka_ldapjs_lodash 64.03% <100.00%> (-0.01%) ⬇️
appsec-mongodb-core_mongoose_mysql 67.76% <100.00%> (-0.01%) ⬇️
appsec-next 57.56% <100.00%> (+<0.01%) ⬆️
appsec-node-serialize_passport_postgres 67.44% <100.00%> (-0.01%) ⬇️
appsec-sourcing_stripe_template 65.77% <100.00%> (-0.01%) ⬇️
debugger 65.02% <100.00%> (-0.01%) ⬇️
instrumentations-bucket-0 51.68% <100.00%> (+<0.01%) ⬆️
instrumentations-bucket-1 60.26% <100.00%> (+<0.01%) ⬆️
instrumentations-bucket-10 61.84% <100.00%> (+<0.01%) ⬆️
instrumentations-bucket-11 56.73% <100.00%> (+<0.01%) ⬆️
instrumentations-bucket-12 52.00% <100.00%> (+<0.01%) ⬆️
instrumentations-bucket-13 52.05% <100.00%> (+<0.01%) ⬆️
instrumentations-bucket-2 53.34% <100.00%> (+<0.01%) ⬆️
instrumentations-bucket-3 53.64% <100.00%> (+<0.01%) ⬆️
instrumentations-bucket-4 59.26% <100.00%> (+<0.01%) ⬆️
instrumentations-bucket-5 50.04% <100.00%> (+<0.01%) ⬆️
instrumentations-bucket-6 60.98% <100.00%> (+<0.01%) ⬆️
instrumentations-bucket-7 58.41% <100.00%> (+<0.01%) ⬆️
instrumentations-bucket-8 59.49% <100.00%> (+<0.01%) ⬆️
instrumentations-bucket-9 52.06% <100.00%> (+<0.01%) ⬆️
instrumentations-instrumentation-couchbase 51.11% <100.00%> (+<0.01%) ⬆️
instrumentations-instrumentation-zlib 51.68% <100.00%> (+<0.01%) ⬆️
instrumentations-integration-esbuild 34.16% <0.00%> (-0.01%) ⬇️
llmobs-ai_anthropic_bedrock 62.62% <100.00%> (-0.01%) ⬇️
llmobs-bucket-1 61.88% <100.00%> (+<0.01%) ⬆️
llmobs-openai 62.75% <100.00%> (-0.01%) ⬇️
llmobs-sdk 66.27% <100.00%> (-0.01%) ⬇️
llmobs-vertex-ai 59.23% <100.00%> (+<0.01%) ⬆️
master-coverage 98.34% <100.00%> (?)
openfeature 54.02% <72.00%> (+<0.01%) ⬆️
openfeature-unit 52.79% <100.00%> (+<0.01%) ⬆️
platform-core_esbuild_instrumentations-misc 40.41% <100.00%> (+<0.01%) ⬆️
platform-integration 61.53% <72.00%> (+<0.01%) ⬆️
platform-shimmer_unit-guardrails_webpack 39.11% <100.00%> (+<0.01%) ⬆️
plugins-bucket-0 57.11% <100.00%> (+<0.01%) ⬆️
plugins-bucket-1 54.47% <72.00%> (+0.01%) ⬆️
plugins-bucket-11 62.34% <100.00%> (+0.44%) ⬆️
plugins-bucket-17 ?
plugins-bucket-18 62.13% <100.00%> (-0.51%) ⬇️
plugins-bucket-19 60.15% <100.00%> (-1.59%) ⬇️
plugins-bucket-20 62.11% <100.00%> (-2.27%) ⬇️
plugins-bucket-4 58.65% <100.00%> (+<0.01%) ⬆️
plugins-bullmq_cassandra_cookie 61.85% <100.00%> (+<0.01%) ⬆️
plugins-cookie-parser_crypto_dd-trace-api 56.81% <100.00%> (+<0.01%) ⬆️
plugins-fetch_fs_generic-pool 58.87% <100.00%> (-0.05%) ⬇️
plugins-google-cloud-pubsub_grpc_handlebars 64.80% <100.00%> (-0.01%) ⬇️
plugins-hapi_hono_ioredis 60.32% <100.00%> (+<0.01%) ⬆️
plugins-jest_knex_langgraph 55.67% <100.00%> (?)
plugins-jest_langgraph_ldapjs ?
plugins-ldapjs_light-my-request_limitd-client 58.57% <100.00%> (?)
plugins-light-my-request_limitd-client_lodash ?
plugins-lodash_mariadb_memcached 58.08% <100.00%> (?)
plugins-mariadb_memcached_mercurius ?
plugins-moleculer_mongodb_mongodb-core 62.01% <100.00%> (?)
plugins-mongodb_mongodb-core_mongoose ?
plugins-mongoose_multer_mysql 59.10% <100.00%> (?)
plugins-multer_mysql_mysql2 ?
plugins-mysql2_nats_node-serialize 60.81% <100.00%> (?)
plugins-nats_node-serialize_opensearch ?
plugins-opensearch_passport-http_pino 59.63% <100.00%> (?)
plugins-passport-http_pino_postgres ?
plugins-postgres_process_pug 58.35% <100.00%> (?)
plugins-process_pug_redis ?
plugins-redis_router_sequelize 62.14% <100.00%> (?)
plugins-test-and-upstream-rhea_undici_url 61.73% <100.00%> (?)
plugins-undici_url_valkey ?
plugins-valkey_vm_winston 58.09% <100.00%> (?)
plugins-vm_winston_ws ?
plugins-ws 59.67% <100.00%> (?)
profiling 62.36% <100.00%> (+0.09%) ⬆️
serverless-aws-sdk-aws-sdk 55.13% <100.00%> (+<0.01%) ⬆️
serverless-aws-sdk-bedrockruntime 54.82% <100.00%> (+<0.01%) ⬆️
serverless-aws-sdk-client 56.49% <100.00%> (+<0.01%) ⬆️
serverless-aws-sdk-dynamodb 55.73% <100.00%> (-0.01%) ⬇️
serverless-aws-sdk-eventbridge 49.44% <100.00%> (+<0.01%) ⬆️
serverless-aws-sdk-kinesis 59.43% <100.00%> (+<0.01%) ⬆️
serverless-aws-sdk-lambda 57.47% <100.00%> (+<0.01%) ⬆️
serverless-aws-sdk-s3 55.67% <100.00%> (+<0.01%) ⬆️
serverless-aws-sdk-serverless-peer-service ?
serverless-aws-sdk-sns 60.23% <100.00%> (+<0.01%) ⬆️
serverless-aws-sdk-sqs ?
serverless-aws-sdk-stepfunctions ?
serverless-aws-sdk-util 51.56% <100.00%> (+<0.01%) ⬆️
serverless-bucket-0 54.36% <72.00%> (+0.01%) ⬆️
serverless-bucket-1 59.34% <100.00%> (+<0.01%) ⬆️
test-optimization-cucumber 71.83% <100.00%> (-0.01%) ⬇️
test-optimization-cypress 65.85% <72.00%> (+0.11%) ⬆️
test-optimization-jest 73.28% <100.00%> (-0.02%) ⬇️
test-optimization-mocha 73.49% <100.00%> (+0.07%) ⬆️
test-optimization-playwright-playwright-atr 60.40% <72.00%> (+0.03%) ⬆️
test-optimization-playwright-playwright-efd 60.59% <72.00%> (+0.03%) ⬆️
test-optimization-playwright-playwright-final-status 60.56% <72.00%> (+0.03%) ⬆️
test-optimization-playwright-playwright-impacted-tests 60.29% <72.00%> (+0.19%) ⬆️
test-optimization-playwright-playwright-reporting 61.58% <72.00%> (-0.09%) ⬇️
test-optimization-playwright-playwright-test-management 61.05% <72.00%> (-0.07%) ⬇️
test-optimization-playwright-playwright-test-span 60.31% <72.00%> (-0.03%) ⬇️
test-optimization-selenium 59.85% <72.00%> (-0.13%) ⬇️
test-optimization-testopt 58.46% <72.00%> (+0.08%) ⬆️
test-optimization-vitest 70.25% <100.00%> (+0.04%) ⬆️

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.

@pr-commenter

pr-commenter Bot commented Jul 3, 2026

Copy link
Copy Markdown

Benchmarks

Benchmark execution time: 2026-07-13 11:54:01

Comparing candidate commit 275b832 in PR branch otel-thread-context-writer with baseline commit adf5f1c in branch master.

📊 Benchmarking dashboard

Found 0 performance improvements and 0 performance regressions! Performance is the same for 2315 metrics, 43 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-appsec-enabled-24

  • unstable execution_time [-207930.419µs; +209217.952µs] or [-7.855%; +7.903%]

scenario:appsec-appsec-enabled-26

  • unstable execution_time [-236007.358µs; +237431.725µs] or [-9.255%; +9.311%]

scenario:appsec-appsec-enabled-with-attacks-24

  • unstable execution_time [-161451.330µs; +160270.196µs] or [-5.232%; +5.193%]

scenario:appsec-appsec-enabled-with-attacks-26

  • unstable execution_time [-177.035ms; +137.715ms] or [-6.159%; +4.791%]

scenario:appsec-control-20

  • unstable execution_time [-120.689ms; +135.573ms] or [-7.329%; +8.232%]

scenario:appsec-control-24

  • unstable execution_time [-113536.228µs; +113595.595µs] or [-9.196%; +9.201%]

scenario:appsec-control-26

  • unstable execution_time [-119.607ms; +133.402ms] or [-9.715%; +10.835%]

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

  • unstable execution_time [-13.326ms; +16.317ms] or [-5.182%; +6.345%]

scenario:appsec-iast-with-vulnerability-control-20

  • unstable execution_time [-25.304ms; +29.833ms] or [-4.618%; +5.445%]

scenario:child_process-file-args-24

  • unstable execution_time [-20.911ms; +33.194ms] or [-4.395%; +6.977%]
  • unstable throughput [-137230.506op/s; +90525.498op/s] or [-6.284%; +4.145%]

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

  • unstable cpu_user_time [-274.511ms; +587.852ms] or [-3.444%; +7.375%]
  • unstable execution_time [-311.127ms; +632.311ms] or [-3.592%; +7.301%]
  • unstable instructions [-2.2G instructions; +4.5G instructions] or [-3.419%; +6.969%]
  • unstable throughput [-264.094op/s; +131.002op/s] or [-7.116%; +3.530%]

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

  • unstable cpu_user_time [-2038.107ms; +3229.061ms] or [-21.783%; +34.511%]
  • unstable execution_time [-2035.664ms; +3249.139ms] or [-20.171%; +32.196%]
  • unstable instructions [-18.5G instructions; +29.2G instructions] or [-23.641%; +37.293%]
  • unstable max_rss_usage [-5.640MB; +10.572MB] or [-3.549%; +6.651%]
  • unstable throughput [-711.423op/s; +445.957op/s] or [-21.891%; +13.723%]

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

  • unstable instructions [-2734.4M instructions; +3937.0M instructions] or [-4.179%; +6.017%]

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

  • unstable cpu_user_time [-3384.325ms; +3751.007ms] or [-33.305%; +36.913%]
  • unstable execution_time [-3436.046ms; +3757.123ms] or [-31.508%; +34.453%]
  • unstable instructions [-29.9G instructions; +32.9G instructions] or [-35.224%; +38.733%]
  • unstable max_rss_usage [-10459.373KB; +11686.573KB] or [-6.486%; +7.247%]
  • unstable throughput [-740.990op/s; +679.416op/s] or [-23.896%; +21.911%]

scenario:debugger-line-probe-without-snapshot-24

  • unstable cpu_user_time [-2.045s; +4.055s] or [-22.475%; +44.560%]
  • unstable execution_time [-2.111s; +4.141s] or [-21.507%; +42.197%]
  • unstable instructions [-17.3G instructions; +34.7G instructions] or [-23.093%; +46.483%]
  • unstable max_rss_usage [-8.290MB; +17.876MB] or [-5.201%; +11.216%]
  • unstable throughput [-1074.456op/s; +584.014op/s] or [-31.559%; +17.154%]

scenario:debugger-line-probe-without-snapshot-26

  • unstable cpu_user_time [-2.755s; +0.464s] or [-27.740%; +4.668%]
  • unstable execution_time [-2.771s; +0.442s] or [-26.027%; +4.156%]
  • unstable instructions [-24.7G instructions; +4.1G instructions] or [-29.756%; +4.979%]
  • unstable throughput [-113.227op/s; +602.278op/s] or [-3.607%; +19.189%]

scenario:dogstatsd-with-tags-20

  • unstable cpu_user_time [-369.276ms; +298.730ms] or [-7.645%; +6.185%]
  • unstable execution_time [-367.641ms; +296.020ms] or [-7.488%; +6.029%]
  • unstable throughput [-100547.269op/s; +126109.234op/s] or [-5.885%; +7.382%]

scenario:plugin-claude-agent-sdk-compact-stream-scan-26

  • unstable cpu_usage_percentage [-5.572%; +6.176%]

scenario:plugin-graphql-long-with-depth-on-max-20

  • unstable cpu_user_time [-736.347ms; +705.922ms] or [-5.740%; +5.503%]
  • unstable execution_time [-735.364ms; +710.942ms] or [-5.610%; +5.423%]
  • unstable throughput [-3.261op/s; +3.401op/s] or [-5.312%; +5.539%]

scenario:plugin-pg-service-20

  • unstable execution_time [-155.314ms; +40.133ms] or [-9.478%; +2.449%]

@szegedi
szegedi force-pushed the otel-thread-context-writer branch 5 times, most recently from 797fe55 to d608888 Compare July 8, 2026 08:43
@szegedi
szegedi marked this pull request as ready for review July 8, 2026 09:23
@szegedi
szegedi requested review from a team as code owners July 8, 2026 09:23
@szegedi
szegedi requested review from BridgeAR and wconti27 and removed request for a team July 8, 2026 09:23

@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: d608888464

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

// the result on the span.
function getCachedWebTags (span) {
const cached = getCache(span)
if (cached.resolved) return cached.webTags

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 Invalidate cached misses when parent web tags resolve

When a child span is entered before its HTTP parent receives route/resource tags, this cache stores resolved=true with webTags === undefined for the child. A later dd-trace:span:tags:update on the parent only publishes resolvedCh for the parent span, so re-entering the already-cached child hits this fast path and never re-walks to pick up the now-resolved endpoint; the OTel thread-context record (and wall-profiler context through the shared cache) will keep missing endpoints for common flows where routing tags are set after downstream spans start.

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.

This is a valid issue, although it's been pre-existing for very long time (it existed before in wall.js code before we extracted it into web-tags-cache.js. This is rather non-trivial to fix, we need to either not-cache-negative-answers (extra walks each activation), or track a parent-children reverse map to invalidate on parent transition. Given the complexity, I find it acceptable as a corner case, but I can file a JIRA issue to keep track of it.

Comment thread packages/dd-trace/src/otel-thread-ctx.js Outdated
// wins; subsequent calls are no-ops regardless of their argument. In
// practice all callers observe the same global ACF state.
function ensureChannelsActivated (asyncContextFrameEnabled) {
if (channelsActivated) return

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 Allow later callers to request non-ACF hooks

If DD_TRACE_OTEL_CTX_ENABLED initializes these channels first on an ACF-capable runtime, this flag is set after only the enterWith wrapper is installed. A later wall-profiler start in auto mode with DD_PROFILING_ASYNC_CONTEXT_FRAME_ENABLED=false calls ensureChannelsActivated(false), but returns here before installing the async_hooks.before publisher and run() wrapper that non-ACF profiling relies on, so code-hotspot/endpoint contexts stop updating for that configuration.

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.

This is true, but the scenario is quite unrealistic as it requires the combination of DD_TRACE_OTEL_CTX_ENABLED=1 DD_PROFILING_ASYNC_CONTEXT_FRAME_ENABLED=0 DD_PROFILING_ENABLED=auto. I'll file a JIRA as a low-priority follow-up.

Comment thread packages/dd-trace/src/tracer_metadata.js
szegedi added a commit that referenced this pull request Jul 8, 2026
…times

Addresses Codex review point #4 on #9210: getThreadLocalMetadata would
happily return a payload on macOS/Windows or on Node without an active
AsyncContextFrame, which would cause libdatadog to advertise a
threadlocal block that no writer is actually producing. Gate the
function on the same platform + ACF conditions start() already checks,
so callers see 'no threadlocal block'.
szegedi added a commit that referenced this pull request Jul 8, 2026
Addresses Codex review point #2 on #9210: an OTEP-4947 record can only
carry the first-written value for each attribute key, and HTTP plugins
routinely set 'http.method' up front and add 'http.route' (plus
'resource.name') later once routing has resolved. The writer previously
committed the endpoint on first activation, so a request to '/users/:id'
was recorded as 'GET' forever.

Introduce an isEndpointFinal(tags) heuristic and only write the endpoint
when the tag bag looks stable — either 'resource.name' is set, or both
'http.method' and 'http.route' are. Otherwise mark the context as
needsEndpoint and re-check on every 'dd-trace:span:tags:update' fire
(switched from webTagsCache.resolvedCh, which only fires on presence
transitions and would miss content-only updates).
@szegedi
szegedi force-pushed the otel-thread-context-writer branch from 50b56d7 to eb0ec07 Compare July 8, 2026 11:12
szegedi added a commit that referenced this pull request Jul 10, 2026
Addresses Codex review point #2 on #9210: HTTP plugins routinely set
'http.method' up front and add 'http.route' (plus 'resource.name')
later once routing has resolved. The writer previously committed the
endpoint on first activation, so an out-of-process reader sampling
mid-request saw a bare 'GET' as the endpoint for a call to
'/users/:id'.

OTEP-4947 duplicates are last-wins, so a later appendAttributes would
overwrite the interim value for readers that decode the record in
full — but a sampling reader can still observe the incomplete value.
Introduce an isEndpointFinal(tags) heuristic and only write the
endpoint when the tag bag looks stable — either 'resource.name' is
set, or both 'http.method' and 'http.route' are. Otherwise mark the
context as needsEndpoint and re-check on every
'dd-trace:span:tags:update' fire (switched from
webTagsCache.resolvedCh, which only fires on presence transitions and
would miss content-only updates).
@szegedi
szegedi force-pushed the otel-thread-context-writer branch 2 times, most recently from 7a1f597 to 1a19d66 Compare July 13, 2026 11:36
szegedi added 4 commits July 13, 2026 13:42
pprof 5.16.0 is the first release containing the OTEP-4947 thread-context
writer (ThreadContext class, getContext/clearContext, and the
getProcessContextAttributes helper this branch depends on).
libdatadog 0.12.1 is a bug-fix bump over 0.12.0.
Move the dd-trace storage diagnostics channels (storage:enter,
storage:before, span:finish, span:tags:update), the legacy-storage
enterWith/run shimmer, and the getActiveSpan helper out of
profiling/profilers/wall.js into a new packages/dd-trace/src/storage-channels.js.

No behavior change for the wall profiler. The extraction is to let a
forthcoming OTEP-4947 thread-context writer reuse the same channel
infrastructure without duplicating the shimmer or pulling in the
profiler module.

Adjust wall.spec.js's proxyquire stubs to replace the new module
instead of mocking datadog-core directly.
The wall profiler and the OTEP-4947 thread-context will both
walk each span's started-spans chain to find the nearest web-server
ancestor, each caching the answer under its own Symbol. Move the walk
and cache into packages/dd-trace/src/web-tags-cache.js.

- getCachedWebTags(span): lazy parent-chain walk, cached on a shared
Symbol.
- onSpanTagsUpdated(span): call from a tagsUpdate subscriber; if the
walk previously came up empty and the span is now a web-server span,
promote its tags into the cache. Returns true iff the cache
transitioned from undefined to a real value — a signal to consumers
that they should snapshot the new value into whatever they built
while the answer was undefined.

wall.js still snapshots webTags into its per-sample ProfilingContext
because label generation reads it from the sample-context ref (not from
the span); its tagsUpdate handler now refreshes that snapshot only when
the shared cache signals a transition.

No functional change. CODEOWNERS gets the new file scoped to
@DataDog/profiling-js.
New module packages/dd-trace/src/otel-thread-ctx.js that mirrors the
active trace ID, span ID and current endpoint into a thread-local
OTEP-4947 record. An out-of-process eBPF reader discovers the record
via the otel_thread_ctx_nodejs_v1 TLS symbol exported by the
@datadog/pprof addon.

Highlights:

- Gates on Linux + AsyncContextFrame (Node 24+ default, or Node 22/23
  with --experimental-async-context-frame). isACFActive from
  datadog-core/src/storage is the single source of truth for that
  check.
- One ThreadContext is allocated per span the first time it's
  activated and cached on the span via a Symbol slot. Re-entries in
  any async-context frame re-install the same wrap via setContext;
  `getContext() === cachedContext` is the JS-reference identity
  check that replaces any byte-level comparison (same allocation-
  churn fix as the wall profiler in dd-trace-js#8638).
- The record always carries the local root span ID (16-char hex,
  index 0 by libdatadog convention), thread name and thread id
  (stable per-thread, computed once at module load). The endpoint
  attribute is appended in place when a web-server ancestor is
  discovered via the span:tags:update channel.
- On span:finish, if the writer's record currently belongs to the
  finishing span, setContext(undefined) is called so an out-of-
  process reader doesn't keep seeing a finished span as the active
  thread context (matters in enterWith-style sticky activation).

Activation gate:

- DD_TRACE_OTEL_CTX_ENABLED (boolean, default false) is registered
  in supported-configurations.json as implementation A, exposed as
  config.DD_TRACE_OTEL_CTX_ENABLED. proxy.js calls
  otel-thread-ctx.start() iff the flag is set; the module itself
  starts in a no-op state otherwise.

Tests + docker rig:

- packages/dd-trace/test/otel-thread-ctx.spec.js covers start()
  gating, enter/skip behavior, span-drift round-trip, span-finish,
  and the late-tags append path with the wire-record attribute
  layout (15 cases).
- scripts/docker/{Dockerfile,run-otel-thread-ctx-spec.sh} build a
  node:24-bookworm image and run the spec against the locally built
  sibling pprof-nodejs; driven by `npm run test:docker:otel-thread-ctx`.

Forthcoming follow-up: publishing the corresponding
threadlocal.attribute_key_map via process discovery, which requires
bumping @DataDog/libdatadog.
szegedi added 2 commits July 13, 2026 13:42
Sets up the OTEP-4719 process context so an out-of-process reader can decode
the on-the-wire records the thread-context writer emits. The metadata is
published through libdatadog-nodejs's process-discovery napi crate: bumped
here to 0.12.0, which exposes the ThreadLocalMetadata substruct with the full
'threadlocal.*' block (attribute key map, schema-version string, and extra
KeyValues for reader-side layout constants).

The pieces:

- Add getThreadLocalMetadata() in otel-thread-ctx.js. Pulls the process-context
  snapshot from @datadog/pprof (its otelThreadCtx.getProcessContextAttributes
  is the source of truth for the schema-version string and V8 layout constants
  the reader needs) and reshapes it into the napi ThreadLocalMetadata form:
  { attributeKeys, schemaVersion, extraAttributes: [{ key, intValue|stringValue }] }.
  Returns undefined when @datadog/pprof isn't installed or doesn't expose the
  helper.

- Wire tracer_metadata.js to pass the substruct (or undefined) as the last
  positional arg to processDiscovery.TracerMetadata(...), replacing the flat
  threadlocalAttributeKeys array. Gated on the same DD_TRACE_OTEL_CTX_ENABLED
  flag that activates the writer.

- Bump the @DataDog/libdatadog optionalDependency from 0.10.0 to 0.12.0.
The spec is technically already picked up by the top-level 'packages/dd-trace/test/*.spec.js'
glob in test:trace:core:ci (apm-capabilities.yml), but the file is
profiling-team-owned per CODEOWNERS. Adding a dedicated step in
profiling.yml gives the owning team direct failure visibility in their
own workflow.
@szegedi
szegedi force-pushed the otel-thread-context-writer branch from 1a19d66 to 275b832 Compare July 13, 2026 11:42
Comment on lines +3414 to +3420
"DD_TRACE_OTEL_CTX_ENABLED": [
{
"implementation": "A",
"type": "boolean",
"default": "false"
}
],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Fingers crossed we can enable by default (on supported node.js versions) soon ;)

Comment on lines +60 to +66
// Positional attribute layout. The local root span ID stays at index 0 by
// convention (mirrors libdatadog's libdd-otel-thread-ctx, where
// `local_root_span_id` is always the first entry in
// `threadlocal.attribute_key_map`), encoded as a 16-character lowercase
// hex string. Endpoint, thread name, and thread id follow. Adding more
// means assigning the next index and updating ATTRIBUTE_KEYS
// accordingly.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Minor: Since we're supplying a threadlocal.attribute_key_map I'm not sure if libdatadog will still prepend the local root span id, might be worth checking

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.

It does prepend it. At least, the version we built libdatadog-nodejs process_discovery crate still did, if this changes in a later libdatadog release, we should update accordingly. I know there's some talk of retiring local root span id?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If it worked when you did it, that's fine!

I know there's some talk of retiring local root span id

Yes, it's on my todo list to experiment a bit, but I'll share it loudly if it looks like we're going in that direction so yeah for now let's keep it.

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.

2 participants