Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bpf/common/common.c
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@ const otel_span_t *unused_10 __attribute__((unused));
const mongo_go_client_req_t *unused_11 __attribute__((unused));
const dns_req_t *unused_12 __attribute__((unused));
const channel_link_trace_t *unused_13 __attribute__((unused));
const node_span_event_t *unused_14 __attribute__((unused));
20 changes: 20 additions & 0 deletions bpf/common/common.h
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,26 @@ typedef struct otel_span {
u8 _epad[6];
} otel_span_t;

// Manual span emitted by the Node.js span bridge (spanbridge.js): the span
// itself travels as a JSON document smuggled through a sentinel uv_fs_access
// path (see bpf/generictracer/nodejs.c); BPF only adds timing, pid and the
// current request trace context so user space can parent the span under
// OBI's automatic server span.
#define NODE_SPAN_PAYLOAD_MAX_LEN (2048)

typedef struct node_span_event {
u8 type; // Must be first, EVENT_NODE_SPAN
u8 has_parent_ctx;
u8 _pad[2];
u32 payload_len; // bytes of payload actually written (excluding NUL)
u64 end_ktime; // bpf_ktime_get_ns() when the sentinel fired (~span end)
unsigned char parent_trace_id[TRACE_ID_SIZE_BYTES];
unsigned char parent_span_id[SPAN_ID_SIZE_BYTES];
pid_info pid;
unsigned char payload[NODE_SPAN_PAYLOAD_MAX_LEN]; // JSON, see spanbridge.js
u8 _epad[4];
} node_span_event_t;

typedef struct channel_link_trace {
u8 type; // Must be first
u8 _pad[7];
Expand Down
1 change: 1 addition & 0 deletions bpf/common/event_defs.h
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@
#define EVENT_GO_RUNTIME_METRICS 17
#define EVENT_GO_CHANNEL_LINK 18
#define EVENT_JVM_MEM_POOL_GC 19
#define EVENT_NODE_SPAN 20
92 changes: 80 additions & 12 deletions bpf/generictracer/nodejs.c
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
#include <bpfcore/bpf_helpers.h>
#include <bpfcore/bpf_tracing.h>

#include <common/common.h>
#include <common/ringbuf.h>
#include <common/strings.h>
#include <common/tracing.h>

Expand All @@ -15,14 +17,19 @@
#include <maps/fd_to_connection.h>
#include <maps/nodejs_fd_map.h>

#include <pid/pid.h>

#include <shared/obi_ctx.h>

enum {
k_delim_offset = 13,
k_variant_offset = 14,
k_fd1_offset = 14,
k_fd2_offset = 18,
k_ctx_fd_offset = 18,
k_max_fd_digits = 4
k_max_fd_digits = 4,
// strlen("/dev/null/obi-span/") — the JSON span payload starts here
k_span_payload_offset = 19,
};

static __always_inline int handle_async_switch(char *buf, const u64 pid_tgid) {
Expand Down Expand Up @@ -51,6 +58,54 @@ static __always_inline int handle_async_switch(char *buf, const u64 pid_tgid) {
return 0;
}

// Manual span emitted by the injected span bridge (spanbridge.js):
// /dev/null/obi-span/<json>
// The JSON document (name, ids, duration, attributes...) is copied verbatim
// into a node_span_event_t; user space parses it (ReadNodeSpanEventIntoSpan).
// We stamp the event with bpf_ktime_get_ns() (the sentinel fires inside
// span.end(), so this is the span end time in the same monotonic domain the
// rest of the pipeline uses) and with the current request trace context from
// traces_ctx_v1 — maintained by the async-context sentinels above — so the
// span can be parented under OBI's automatic server span.
static __always_inline int handle_node_span(const char *path, const u64 pid_tgid) {
node_span_event_t *ev = bpf_ringbuf_reserve(&events, sizeof(node_span_event_t), 0);
if (!ev) {
return 0;
}

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.

ev->_pad[1] = 0;
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.

if (octx) {
ev->has_parent_ctx = 1;
bpf_memcpy(ev->parent_trace_id, (void *)octx->trace_id, TRACE_ID_SIZE_BYTES);
bpf_memcpy(ev->parent_span_id, (void *)octx->span_id, SPAN_ID_SIZE_BYTES);
} else {
ev->has_parent_ctx = 0;
bpf_memset(ev->parent_trace_id, 0, TRACE_ID_SIZE_BYTES);
bpf_memset(ev->parent_span_id, 0, SPAN_ID_SIZE_BYTES);
}

const long len = bpf_probe_read_user_str(
ev->payload, NODE_SPAN_PAYLOAD_MAX_LEN, path + k_span_payload_offset);
if (len <= 1) { // empty or unreadable payload
bpf_ringbuf_discard(ev, 0);
return 0;
}

ev->payload_len = (u32)(len - 1); // exclude the NUL terminator

bpf_dbg_printk("nodejs_manual_span: len=%d", ev->payload_len);

bpf_ringbuf_submit(ev, get_flags());

return 0;
}

static __always_inline int handle_fd_correlation(char *buf, const u64 pid_tgid) {
u32 fd1 = 0;
u32 fd2 = 0;
Expand All @@ -77,24 +132,30 @@ int BPF_KPROBE(obi_uv_fs_access, void *loop, void *req, const char *path) {
(void)loop;
(void)req;

// the obi nodejs agent (fdextractor.js) passes signals to the ebpf layer
// by invoking uv_fs_access() with a fake path. Two formats are used:
// the obi nodejs agents (fdextractor.js, spanbridge.js) pass signals to
// the ebpf layer by invoking uv_fs_access() with a fake path. Three
// formats are used:
//
// 1. fd pair correlation (outgoing -> incoming):
// /dev/null/obi/<fd1><fd2> — each fd is a left-zero-padded 4-digit number
//
// 2. async context switch (before-hook fires before each JS callback):
// /dev/null/obi-ctx/<fd> — 4-digit incoming fd for the current async context
//
// Both paths share the prefix "/dev/null/obi" (13 chars). The character at
// position 13 distinguishes the two formats:
// '/' -> format 1 (fd pair)
// '-' -> format 2 (context switch, "-ctx/" follows)
// 3. manual span end (spanbridge.js):
// /dev/null/obi-span/<json> — serialized manual span, variable length
//
// All paths share the prefix "/dev/null/obi" (13 chars). The characters at
// positions 13-14 distinguish the formats:
// '/' -> format 1 (fd pair)
// '-', 'c' -> format 2 (context switch, "-ctx/" follows)
// '-', 's' -> format 3 (manual span, "-span/" follows)
static const char prefix[] = "/dev/null/obi";
static const u8 prefix_size = sizeof(prefix) - 1;

// Buffer sized to hold the longest path + null terminator.
// Both formats are exactly 22 characters long.
// Buffer sized to hold the longest fixed-size path + null terminator.
// Formats 1 and 2 are exactly 22 characters long; format 3 is read
// directly from user memory in handle_node_span.
char buf[] = "/dev/null/obi/00000000";

if (bpf_probe_read_user(buf, sizeof(buf), path) != 0) {
Expand All @@ -107,10 +168,17 @@ int BPF_KPROBE(obi_uv_fs_access, void *loop, void *req, const char *path) {

const u64 pid_tgid = bpf_get_current_pid_tgid();

// Async context switch: /dev/null/obi-ctx/XXXX
// Fires from the async_hooks 'before' callback in fdextractor.js to refresh
// traces_ctx_v1 before each JS callback.
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

return 0;
}
return handle_node_span(path, pid_tgid);
}
// Async context switch: /dev/null/obi-ctx/XXXX
// Fires from the async_hooks 'before' callback in fdextractor.js to
// refresh traces_ctx_v1 before each JS callback.
return handle_async_switch(buf, pid_tgid);
}
// fd pair correlation: /dev/null/obi/<fd1><fd2>
Expand Down
1 change: 1 addition & 0 deletions devdocs/config/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,7 @@ ReverseDNS is currently experimental. It is kept disabled by default and will be
| YAML Path | Type | Env Var | Default | Values | Deprecated | Description |
|---|---|---|---|---|---|---|
| `nodejs.enabled` | `boolean` | `OTEL_EBPF_NODEJS_ENABLED` | `true` | | | |
| `nodejs.manual_spans` | `boolean` | `OTEL_EBPF_NODEJS_MANUAL_SPANS` | `false` | | | Injects the span bridge (spanbridge.js) into Node.js processes, capturing spans the application creates through the OpenTelemetry API when no OpenTelemetry SDK is registered. |

## `otel_metrics_export`

Expand Down
6 changes: 6 additions & 0 deletions devdocs/config/config-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2123,6 +2123,12 @@
"type": "boolean",
"default": true,
"x-env-var": "OTEL_EBPF_NODEJS_ENABLED"
},
"manual_spans": {
"type": "boolean",
"description": "Injects the span bridge (spanbridge.js) into Node.js processes, capturing spans the application creates through the OpenTelemetry API when no OpenTelemetry SDK is registered.",
"default": false,
"x-env-var": "OTEL_EBPF_NODEJS_MANUAL_SPANS"
}
},
"type": "object"
Expand Down
Loading
Loading