Skip to content

Node.js manual span capture#2661

Open
NimrodAvni78 wants to merge 7 commits into
open-telemetry:mainfrom
coralogix:nimrodavni78/node-manual-spans
Open

Node.js manual span capture#2661
NimrodAvni78 wants to merge 7 commits into
open-telemetry:mainfrom
coralogix:nimrodavni78/node-manual-spans

Conversation

@NimrodAvni78

@NimrodAvni78 NimrodAvni78 commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Part of #2660

Summary

Captures spans that Node.js applications create through @opentelemetry/api
(tracer.startSpan / startActiveSpan / span.end) when the app has no
OpenTelemetry SDK registered
. Without an SDK those API calls return
non-recording no-ops and the telemetry is silently lost — this feature makes
them real and exports them through OBI's normal traces pipeline, correlated
with OBI's existing traces.

It's the Node.js analog of OBI's existing Go manual-span support
(bpf/gotracer/go_sdk.c), which uprobes the no-op global-API tracer and backs
off when a real SDK is present. Since JIT-compiled JavaScript can't be uprobed
(anonymous executable mappings, no inode to attach to), the capture happens
in-process via a tiny injected JS bridge; only the transport back to OBI is
eBPF.

Opt-in: nodejs.manual_spans: true (OTEL_EBPF_NODEJS_MANUAL_SPANS,
default false), on top of the existing nodejs.enabled.

How it works

customer app (@opentelemetry/api, no SDK)
   │  tracer.startSpan() / span.end()
   ▼
spanbridge.js ──── injected over the V8 inspector alongside fdextractor.js
   │               (SIGUSR1 → CDP Runtime.evaluate). Registers a minimal,
   │               dependency-free TracerProvider + AsyncLocalStorage context
   │               manager into globalThis[Symbol.for('opentelemetry.js.api.1')].
   ▼
fs.accessSync('/dev/null/obi-span/<json>')   ── sentinel uv_fs_access() path
   ▼
obi_uv_fs_access uprobe (bpf/generictracer/nodejs.c)
   │  new '-span/' branch → node_span_event_t; stamps bpf_ktime_get_ns() + pid
   │  + the current request's trace context from traces_ctx_v1 (kept fresh by
   │  fdextractor.js's async-context sentinels)
   ▼
events ringbuf → EVENT_NODE_SPAN (21)
   ▼
ReadNodeSpanEventIntoSpan → request.Span{Type: EventTypeManualSpan}
   ▼
existing traces exporter (unchanged)
  • Zero customer action / restart: delivered through OBI's existing Node
    inspector injector, so it works on already-running processes (tracers
    acquired before injection go live — the bridge sets the delegate on every
    already-loaded @opentelemetry/api copy via require.cache, since
    pre-acquired ProxyTracers resolve through that, not the global registry).
  • No native artifacts: the bridge is one dependency-free JS file; transport
    reuses the sentinel-syscall channel fdextractor.js already uses — no addon
    to build/ship, no new eBPF attach machinery.
  • Correlation with automatic spans: BPF stamps the in-flight request
    context at span-end, so bridge-root spans parent under OBI's automatic server
    span (specifically its "processing" sub-span), and nested manual spans keep
    their own parent chain — all on one trace. Orphan manual spans (no
    surrounding request) export with a bridge-generated trace ID.
  • Never fights an app SDK: the bridge stays completely inert (doesn't even
    create the API registry) if a provider/context manager is already registered
    or any @opentelemetry/sdk-* is loaded; the app's own registration and
    exporters are untouched. The existing OTLP-export overlap detection applies
    on top.
  • Non-intrusive by default: runs in the customer's process and is silent —
    no stdout/stderr. OTEL_EBPF_NODEJS_SPAN_BRIDGE_DEBUG=1 opts into stderr
    diagnostics (why it stayed inert, when it activated, unexpected errors) for
    troubleshooting an injection.

Changes

  • pkg/internal/nodejs/spanbridge.js — the injected bridge: minimal
    TracerProvider/Tracer/Span + AsyncLocalStorage context manager, span→JSON
    serialization with bounded fields, sentinel transport, opt-in diagnostics.
  • pkg/internal/nodejs/{nodejs.go,injector.go} — embed the bridge and
    evaluate it after fdextractor.js when the flag is set. Fixes a pre-existing
    injector bug: gorilla's default 4 KB websocket write buffer fragmented larger
    scripts, which the Node inspector rejects (close 1006) — enlarged the
    dialer buffers so each script is a single frame.
  • bpf/generictracer/nodejs.c — new -span/ sentinel branch →
    node_span_event_t on the ringbuf.
  • bpf/common/{common.h,event_defs.h,common.c}node_span_event_t,
    EVENT_NODE_SPAN (21), BTF anchor.
  • pkg/ebpf/common/{common.go,node_otel_transform.go} — ringbuf dispatch +
    decoder producing the same EventTypeManualSpan shape as the Go path
    (attributes encoded identically to tracesgen.SpanAttr; OTel-JS
    SpanStatusCode mapped to Go codes.Code, which are inverted).
  • pkg/ebpf/instrumenter.go — fixes a pre-existing double-attach: "node"
    and "libuv.so" both fall back to the executable, so uv_fs_access was
    uprobed twice and every sentinel fired twice; added per-module
    (symbol, program) dedup.
  • pkg/obi/config.go + regenerated devdocs/config/{config-schema.json,CONFIG.md}
    NodeJSConfig.ManualSpans.
  • devdocs/nodejs-manual-spans.md — full architecture, constraints, and
    diagnostics doc.

Testing

  • make docker-generate (BPF compiles amd64+arm64), go vet, golangci-lint,
    clang-format, make check-config-schema — all clean.
  • Decoder unit tests: pkg/ebpf/common/node_otel_transform_test.go
    (re-anchoring, attribute encoding, status mapping, timing, error paths).
  • Integration test internal/test/integration/traces_node_manual_test.go
    (wired into TestSuite_NodeJS): a /manual route on the Node test server
    creates nested manual spans via @opentelemetry/api with no SDK; the test
    asserts via Jaeger that they're captured, carry their attributes/status, nest
    correctly, and re-anchor onto OBI's automatic trace. Full suite passes on a
    Linux VM including weaver schema validation.

Known limitations

  • Attributes: ≤16 per span, key ≤64 / string value ≤256 chars; array/object
    values are stringified (matches the Go path's otel_attribute_t budgets); on
    payload overflow (~1900 B) attributes/events are dropped so the core span
    still ships.
  • Span events carry names only; links and traceState aren't forwarded.
  • eBPF client spans made inside a manual span are currently siblings, not
    children, of the manual span (they parent via the fd-correlation map, not
    traces_ctx_v1). Go-parity nesting is implemented on a follow-up branch.
  • An app that loads an OTel SDK after injection isn't covered by the
    inertness guard.

Validation

@NimrodAvni78
NimrodAvni78 requested a review from a team as a code owner July 14, 2026 13:05
@NimrodAvni78 NimrodAvni78 changed the title grpc-gateway Node.js manual span capture Jul 14, 2026
@codecov

codecov Bot commented Jul 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 77.91411% with 36 lines in your changes missing coverage. Please review.
✅ Project coverage is 69.64%. Comparing base (63c8a62) to head (f8f84eb).
⚠️ Report is 16 commits behind head on main.

Files with missing lines Patch % Lines
pkg/ebpf/common/node_otel_transform.go 71.66% 27 Missing and 7 partials ⚠️
pkg/internal/nodejs/injector.go 81.81% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2661      +/-   ##
==========================================
- Coverage   70.17%   69.64%   -0.53%     
==========================================
  Files         353      354       +1     
  Lines       48848    49252     +404     
==========================================
+ Hits        34277    34304      +27     
- Misses      12442    12814     +372     
- Partials     2129     2134       +5     
Flag Coverage Δ
integration-test 50.53% <72.79%> (+4.54%) ⬆️
integration-test-arm 26.89% <22.08%> (+0.21%) ⬆️
integration-test-vm-5.15-lts 27.24% <19.11%> (-0.17%) ⬇️
integration-test-vm-6.18-lts 26.71% <19.11%> (-0.77%) ⬇️
k8s-integration-test 34.69% <15.44%> (-0.48%) ⬇️
oats-test 35.10% <15.44%> (-0.01%) ⬇️
unittests 64.94% <78.67%> (+0.23%) ⬆️

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.

@grcevski grcevski left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What a great idea! This is really cool. I left bunch of comments, I think the really interesting issue is the registration of the SDK, I don't know what the OTel SDK does and if they do check not to overwrite the registry. If they overwrite than we are good, if not then we should try to figure out if we add more safety checks.

Comment thread bpf/generictracer/nodejs.c Outdated
}

ev->type = EVENT_NODE_SPAN;
ev->_pad[0] = 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Any reason we are setting these _pad bytes to 0? I don't think anything uses them.

Comment thread bpf/generictracer/nodejs.c Outdated
if (buf[k_delim_offset] == '-') {
// Manual span: /dev/null/obi-span/<json>
if (buf[k_variant_offset] == 's') {
if (!valid_pid(pid_tgid)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actually looking at this code I wonder why we don't actually check for valid_pid right at the start of the function, like elsewhere. I suppose we wouldn't be injecting the agent into a process we are not instrumenting, but it's a maybe similar security concern as we had for Java. Someone can add something to a node app and mimic what our agent does and we'd happily read the data. I think so far it didn't matter it's only adding internal thread correlation, so it's good you added the check, but I'd say let's pull it up at the front.

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.

yes you are right, moving it up front

Comment thread pkg/ebpf/common/node_otel_transform.go
Comment thread pkg/ebpf/instrumenter.go
mod, ok := modules[instrumentedIno]
if ok {
mod.probes = append(mod.probes, pMap)
if filtered := dedupModuleProbes(mod.probes, pMap); len(filtered) > 0 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Interesting, nice catch. So same symbol exists in two different modules from the same process? What an edge case.

Comment thread pkg/ebpf/instrumenter.go
// "libuv.so") resolve to the same file — typically through the
// instrument-the-executable fallback — and would otherwise attach the same
// program twice at the same offset, duplicating every event it emits.
func dedupModuleProbes(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do you mind adding a unit test for this?

const existing = g[API_KEY];
// An SDK (or another agent) already registered a provider or context
// manager — stay inert rather than fight it.
if (existing && (existing.trace || existing.context)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do you know if the OpenTelemetry SDK currently uses similar check for double loading? I wonder the loading of the SDK happens delayed, us setting registry.trace and registry.context prevents the app from actually using OTel SDK? I'm thinking of OTel instrumented services.

If this is a real situation, I think maybe we need to write some code in the nodejs.go that will inspect the depdendencies file and see the application requires the SDK, not the API. If it has the SDK maybe it's unsafe to do this injection?

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 the exact same thing

  if (!allowOverride && api[type]) {
    // already registered an API of this type
    const err = new Error(
      `@opentelemetry/api: Attempted duplicate registration of API: ${type}`
    );
    diag.error(err.stack || err.message);
    return false;
  }

the requires cache is stronger then the depdendencies file, depdendencies file says "is it declared as a dependency?", requires cache says "is the imported by the application?", but after looking deeper both seem not correct enough.
Applications can depend and import the otel sdk but end up not starting the instrumentations (lets say via OTEL_SDK_DISABLED=true or some other runtime condition), so this will provide a false negetive.

What ill implement instead is a step-aside yield, which will basicly give up our instrumentation in the case another instrumentation tries to register. I think this is the least intrusive solution

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Oh I like this approach. I think I know what you mean. Great idea!

Comment thread pkg/internal/nodejs/spanbridge.js Outdated
scope: this._scope,
};
let payload = JSON.stringify(rec);
if (payload.length > MAX_PAYLOAD) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think Javascript strings can be UTF-16, therefore this lenght size lies about the true byte size here. Web search tells me the byte size can be found with this if (Buffer.byteLength(payload, 'utf8') > MAX_PAYLOAD). Can we use Buffer.byteLength or that needs a dependency?

Alternatively, maybe we handle partial json in the userspace code?

ValLength uint16
Vtype uint8
Reserved uint8
Key [32]uint8

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Are these supposed to be the ATTR_KEY and ATTR_VALUE from the spanbridge.js? If so, I think your sizes are double in the JS code.

const MAX_ATTR_KEY_LEN = 64;
const MAX_ATTR_VALUE_LEN = 256;

@grcevski grcevski left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM! Very cool!

Comment thread Makefile

@mmat11 mmat11 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

lgtm! left one comment about CI testing

@NimrodAvni78
NimrodAvni78 force-pushed the nimrodavni78/node-manual-spans branch from 3ab8cdd to e9c8c65 Compare July 15, 2026 17:35
@mmat11

mmat11 commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Markdown linter is failing, can be merged after it's been fixed

@MrAlias MrAlias left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The approach is promising, but the SDK takeover and correlation regressions should block merging.

apiObj[method] = wrapped;
};

for (const key of Object.keys(require.cache ?? {})) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Only API copies already present in require.cache have their setters wrapped. If OBI injects before an app lazily loads @opentelemetry/api and its SDK, that later copy calls an unwrapped setGlobalTracerProvider; it sees the bridge's global trace registration and rejects the app provider as a duplicate. The app's exporter never takes ownership, and spans continue through OBI.

The handoff needs to cover API copies loaded after injection. One viable direction is to extract this wiring into a helper and invoke it from a module-load hook before the SDK receives the API exports; because that hook is process-wide, it also needs to compose safely with existing loader patches. Please add an injection-before-first-API-load regression case on Node 18, 20, and 22.

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.

Thats a great edge case, will do!

ev->end_ktime = bpf_ktime_get_ns();
task_pid(&ev->pid);

const obi_ctx_info_t *octx = obi_ctx__get(pid_tgid);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

obi_ctx__get is not guaranteed to describe the callback ending this span. fdextractor.js emits a -ctx/ sentinel only when its AsyncLocalStorage store has an incoming FD; callbacks created outside a request emit nothing. If a background timer fires while a request is still in flight, this lookup therefore returns the last request context and incorrectly parents the background span into that trace.

Can we have the no-store async-hook path send an explicit context-clear signal and delete this map entry before node-span events rely on it? A regression test with a background span overlapping a slow request would cover this.

Comment thread pkg/internal/nodejs/spanbridge.js
NimrodAvni78 and others added 3 commits July 15, 2026 23:11
Co-authored-by: Tyler Yahn <MrAlias@users.noreply.github.com>
@NimrodAvni78
NimrodAvni78 requested a review from MrAlias July 16, 2026 10:09

@MrAlias MrAlias left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The CommonJS handoff fixes the prior late-load issue, but native ESM still lets the bridge block a customer's SDK from registering.

// covers CommonJS require() only; an api pulled in purely through the native
// ESM loader is not intercepted here.
try {
const Module = require('module');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This still only covers CommonJS. Native ESM imports never reach Module._load; if OBI injects before an ESM app imports @opentelemetry/api, that API copy never gets its setter wrapped. Its later setGlobalTracerProvider sees the bridge's registry.trace, rejects the app provider as a duplicate, and the app's exporter never takes over.

The handoff needs an ESM-safe path, or the bridge needs to remain inert where that path cannot be made safe. Please add a native-ESM injection-before-first-API-load regression on Node 18, 20, and 22.

happens once per process; apps started after OBI are picked up by
discovery as usual.
- **Late-registering SDKs** are handled by the step-aside (see Guards): the
bridge yields and the app's SDK takes over. The residual gap is an

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This no longer describes the implementation: Module._load now wires API copies loaded by CommonJS require(), while native ESM remains the unhandled case. Please document those two cases explicitly, and remove the later claim that no require hook or module wrapping is used.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants