Skip to content

Commit 397985f

Browse files
committed
fix(native): preserve JS fallback and chunk boundaries
1 parent f625bb5 commit 397985f

10 files changed

Lines changed: 364 additions & 96 deletions

File tree

.github/CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,7 @@
375375
/packages/dd-trace/src/bootstrap.js @DataDog/lang-platform-js
376376
/packages/dd-trace/src/feature-registry.js @DataDog/lang-platform-js
377377
/packages/dd-trace/src/js_span_processor.js @DataDog/lang-platform-js
378+
/packages/dd-trace/test/js_span_processor.spec.js @DataDog/lang-platform-js
378379
/packages/dd-trace/src/exporters/common/ @DataDog/lang-platform-js
379380
/packages/dd-trace/src/exporters/common/client-library-headers.js @DataDog/lang-platform-js @DataDog/feature-flagging-and-experimentation-sdk
380381
/packages/dd-trace/src/proxy.js @DataDog/lang-platform-js

LICENSE-3rdparty.csv

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@
8181
"semifies","https://github.com/holepunchto/semifies","['Apache-2.0']","['Holepunch Inc']"
8282
"shell-quote","https://github.com/ljharb/shell-quote","['MIT']","['James Halliday']"
8383
"source-map","https://github.com/mozilla/source-map","['BSD-3-Clause']","['Nick Fitzgerald']"
84-
"spark-md5","https://github.com/satazor/js-spark-md5","['(WTFPL OR MIT)']","['André Cruz']"
84+
"spark-md5","npm:spark-md5","[]","[]"
8585
"tlhunter-sorted-set","https://github.com/tlhunter/node-sorted-set","['MIT']","['Thomas Hunter II']"
8686
"tslib","https://github.com/microsoft/tslib","['0BSD']","['Microsoft Corp.']"
8787
"ttl-set","https://github.com/watson/ttl-set","['MIT']","['Thomas Watson']"

packages/dd-trace/src/exporters/native/index.js

Lines changed: 43 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ class NativeExporter {
7070
this._prioritySampler = prioritySampler
7171
this._nativeSpans = nativeSpans
7272
this._pendingSpans = []
73+
this._pendingSpanChunks = []
7374

7475
const { url, hostname = defaults.hostname, port } = config
7576
this._url = url || new URL(format({
@@ -192,7 +193,7 @@ class NativeExporter {
192193
#finishUrlUpdateCallbacks () {
193194
if (this.#urlUpdateCallbacks.length === 0) return
194195
if (this.#activeSpans > 0 || this.#flushInFlight) return
195-
if (this._pendingSpans.length > 0) {
196+
if (this._pendingSpanChunks.length > 0) {
196197
this.flush()
197198
return
198199
}
@@ -260,10 +261,16 @@ class NativeExporter {
260261
// eslint-disable-next-line eslint-rules/eslint-log-printf-style
261262
log.debug(() => `Encoding payload: ${formatSpansForDebug(spans)}`)
262263

263-
// Collect spans for batch export
264+
// Collect spans for batch export. `_pendingSpans` remains a flat buffer for
265+
// observability/tests; `_pendingSpanChunks` preserves each SpanProcessor
266+
// export call as a trace chunk. Preserving chunk boundaries matters when a
267+
// delayed child span from an already-exported trace finishes before the
268+
// HTTP timer fires: the legacy writer sends that child as a second chunk,
269+
// not coalesced back into the parent chunk.
264270
for (const span of spans) {
265271
this._pendingSpans.push(span)
266272
}
273+
if (spans.length > 0) this._pendingSpanChunks.push(spans)
267274

268275
const { flushInterval } = this._config
269276

@@ -343,7 +350,7 @@ class NativeExporter {
343350
}
344351

345352
#finishSend () {
346-
if (this._pendingSpans.length > 0) {
353+
if (this._pendingSpanChunks.length > 0) {
347354
this.flush()
348355
} else {
349356
this.#finishFlushCallbacks()
@@ -366,6 +373,7 @@ class NativeExporter {
366373
if (err?.name === 'NativeExporterBuildError') {
367374
this.#disabled = true
368375
this._pendingSpans = []
376+
this._pendingSpanChunks = []
369377
clearTimeout(this.#timer)
370378
this.#timer = undefined
371379
log.error('Native exporter disabled after a fatal build error; no further spans will be sent')
@@ -403,45 +411,47 @@ class NativeExporter {
403411
return
404412
}
405413

406-
if (this._pendingSpans.length === 0) {
414+
if (this._pendingSpanChunks.length === 0) {
407415
this.#finishFlushCallbacks()
408416
return
409417
}
410418

411-
const spans = this._pendingSpans
419+
const spanChunks = this._pendingSpanChunks
412420
this._pendingSpans = []
421+
this._pendingSpanChunks = []
413422

414-
// Group the batch by trace so each prepared chunk is exactly one trace
415-
// (segment). This matters because the pipeline treats a chunk as a single
416-
// segment and stamps trace-level tags (sampling priority, `_dd.p.dm`,
417-
// origin, top_level) onto its local root. A deferred flush can hold many
418-
// traces at once (spans pile up while a send is in flight); lumping them
419-
// into one chunk would stamp only the first and mis-group the rest.
420-
const byTrace = new Map()
421-
for (const span of spans) {
422-
const trace = span.context()._trace
423-
let group = byTrace.get(trace)
424-
if (group === undefined) { group = []; byTrace.set(trace, group) }
425-
group.push(span)
426-
}
427-
423+
// Convert each SpanProcessor export call into one or more native chunks,
424+
// splitting only traces that happen to share one export call. Never group
425+
// spans from different export calls together: those calls are already the
426+
// JS processor's chunk boundaries, and the legacy writer preserves them even
427+
// when flushInterval coalesces HTTP sends.
428428
const groups = []
429-
for (const group of byTrace.values()) {
430-
// The local root leads the chunk so the pipeline treats it as chunk root.
431-
const root = group.find(span => this.#isLocalRoot(span))
432-
const firstIsLocalRoot = root !== undefined
433-
let ordered = group
434-
if (firstIsLocalRoot) {
435-
// Emit this trace's trace-level tags on its own local root.
436-
this.#syncTraceTags(root)
437-
if (group[0] !== root) {
438-
ordered = [root, ...group.filter(span => span !== root)]
429+
for (const spans of spanChunks) {
430+
const byTrace = new Map()
431+
for (const span of spans) {
432+
const trace = span.context()._trace
433+
let group = byTrace.get(trace)
434+
if (group === undefined) { group = []; byTrace.set(trace, group) }
435+
group.push(span)
436+
}
437+
438+
for (const group of byTrace.values()) {
439+
// The local root leads the chunk so the pipeline treats it as chunk root.
440+
const root = group.find(span => this.#isLocalRoot(span))
441+
const firstIsLocalRoot = root !== undefined
442+
let ordered = group
443+
if (firstIsLocalRoot) {
444+
// Emit this trace's trace-level tags on its own local root.
445+
this.#syncTraceTags(root)
446+
if (group[0] !== root) {
447+
ordered = [root, ...group.filter(span => span !== root)]
448+
}
439449
}
450+
groups.push({
451+
spanIds: ordered.map(span => span.context()._nativeSpanId),
452+
firstIsLocalRoot,
453+
})
440454
}
441-
groups.push({
442-
spanIds: ordered.map(span => span.context()._nativeSpanId),
443-
firstIsLocalRoot,
444-
})
445455
}
446456

447457
// prepareChunk is synchronous — extract spans from native storage now.

packages/dd-trace/src/js_span_processor.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,13 @@ const SpanSampler = require('./span_sampler')
1717
const GitMetadataTagger = require('./git_metadata_tagger')
1818
const processTags = require('./process-tags')
1919
const { applyHttpOtelSemantics } = require('./plugins/util/http-otel-semantics')
20+
const { APM_TRACING_ENABLED_KEY } = require('./constants')
2021

2122
const startedSpans = new WeakSet()
2223
const finishedSpans = new WeakSet()
2324

2425
class JsSpanProcessor {
25-
constructor (exporter, prioritySampler, config) {
26+
constructor (exporter, prioritySampler, config, otlpStatsExporter) {
2627
this._exporter = exporter
2728
this._prioritySampler = prioritySampler
2829
this._config = config
@@ -34,6 +35,11 @@ class JsSpanProcessor {
3435
this._processTags = config.DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED
3536
? processTags.serialized
3637
: false
38+
39+
if (!config.isCiVisibility && (config.stats?.DD_TRACE_STATS_COMPUTATION_ENABLED || otlpStatsExporter)) {
40+
const { SpanStatsProcessor } = require('./span_stats')
41+
this._stats = new SpanStatsProcessor(config, otlpStatsExporter)
42+
}
3743
}
3844

3945
sample (span) {
@@ -65,7 +71,11 @@ class JsSpanProcessor {
6571
if (span._duration === undefined) {
6672
active.push(span)
6773
} else {
74+
if (isFirstSpanInChunk && this._config.apmTracingEnabled === false) {
75+
span.context().setTag(APM_TRACING_ENABLED_KEY, 0)
76+
}
6877
const formattedSpan = spanFormat(span, isFirstSpanInChunk, this._processTags)
78+
if (this._stats) this._stats.onSpanFinished(formattedSpan)
6979
isFirstSpanInChunk = false
7080
if (this._config.DD_TRACE_OTEL_SEMANTICS_ENABLED) {
7181
applyHttpOtelSemantics(formattedSpan)

packages/dd-trace/src/native/native_spans.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,7 @@ class NativeSpansInterface {
333333
// Zero out the count header in WASM memory
334334
if (this._wasmMemory.buffer !== this._cqbView.buffer) {
335335
this._cqbView = new DataView(this._wasmMemory.buffer, this._cqbPtr)
336-
this._cqbBytes = new Uint8Array(this._wasmMemory.buffer, this._cqbPtr)
336+
this._cqbBytes = new Uint8Array(this._cqbView.buffer, this._cqbView.byteOffset, this._cqbView.byteLength)
337337
}
338338
this._cqbView.setUint32(0, 0, true)
339339
this._cqbView.setUint32(4, 0, true)
@@ -541,7 +541,7 @@ class NativeSpansInterface {
541541
*/
542542
#refreshViews () {
543543
this._cqbView = new DataView(this._wasmMemory.buffer, this._cqbPtr)
544-
this._cqbBytes = new Uint8Array(this._wasmMemory.buffer, this._cqbPtr)
544+
this._cqbBytes = new Uint8Array(this._cqbView.buffer, this._cqbView.byteOffset, this._cqbView.byteLength)
545545
}
546546

547547
/**

packages/dd-trace/src/opentracing/tracer.js

Lines changed: 88 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ const LogPropagator = require('./propagation/log')
2424
const SpanContext = require('./span_context')
2525

2626
// Lazy-loaded so the libdatadog initialization cost is only paid the first
27-
// time native spans are selected. A missing optional libdatadog install still
28-
// fails through `require('../native')` instead of falling back silently.
27+
// time native spans are selected. A corrupt native install still fails hard;
28+
// an omitted optional @datadog/libdatadog can fall back to JS agent export.
2929
let nativeModule
3030
function getNativeModule () {
3131
if (nativeModule === undefined) {
@@ -34,6 +34,11 @@ function getNativeModule () {
3434
return nativeModule
3535
}
3636

37+
function isMissingLibdatadog (error) {
38+
return error?.code === 'MODULE_NOT_FOUND' &&
39+
/^Cannot find module ['"]@datadog\/libdatadog['"]/.test(String(error.message))
40+
}
41+
3742
const REFERENCE_CHILD_OF = 'child_of'
3843
const REFERENCE_FOLLOWS_FROM = 'follows_from'
3944

@@ -94,63 +99,90 @@ class DatadogTracer {
9499
)
95100
}
96101
this._useJsSpans = false
97-
// Native spans are the only supported APM pipeline. libdatadog is a
98-
// required dependency; if NativeSpansInterface construction fails, that's
99-
// a hard error and we let it propagate to the caller.
100-
const NativeSpansInterface = getNativeModule().NativeSpansInterface
101-
102-
const { url, hostname = defaults.hostname, port } = config
103-
const agentUrl = url || new URL(format({
104-
protocol: 'http:',
105-
hostname,
106-
port,
107-
}))
108-
109-
this._nativeSpans = new NativeSpansInterface({
110-
agentUrl: agentUrl.toString(),
111-
tracerVersion: pkg.version,
112-
lang: 'nodejs',
113-
langVersion: process.version,
114-
// Bun runs on JavaScriptCore; match the legacy agent writer's
115-
// Datadog-Meta-Lang-Interpreter (process.versions.bun ? 'JavaScriptCore' : 'v8').
116-
langInterpreter: process.versions.bun ? 'JavaScriptCore' : (process.jsEngine || 'v8'),
117-
pid: process.pid,
118-
tracerService: config.service,
119-
// Native v0.6 client stats and OTLP trace metrics are mutually exclusive
120-
// (system-tests FR02): when OTLP trace metrics are enabled, config forces
121-
// DD_TRACE_STATS_COMPUTATION_ENABLED=true so the OTLP stats exporter runs,
122-
// but the native concentrator must NOT also ship v0.6 stats. Route stats
123-
// to OTLP only in that case by leaving the native concentrator disabled.
124-
statsEnabled: (config.stats?.DD_TRACE_STATS_COMPUTATION_ENABLED &&
125-
!config.OTEL_TRACES_SPAN_METRICS_ENABLED) || false,
126-
hostname: config.hostname || os.hostname(),
127-
env: config.env || '',
128-
appVersion: config.version || '',
129-
runtimeId: config.tags?.['runtime-id'] || '',
130-
otelSemanticsEnabled: config.DD_TRACE_OTEL_SEMANTICS_ENABLED || false,
131-
// Advertise Datadog-Client-Computed-Stats when we compute stats
132-
// client-side or run in APM-standalone (apmTracingEnabled=false), so the
133-
// agent skips its own APM stats/sampling for these traces.
134-
clientComputedStats: config.stats?.DD_TRACE_STATS_COMPUTATION_ENABLED || config.apmTracingEnabled === false,
135-
})
136-
137-
let otlpStatsExporter
138-
if (config.OTEL_TRACES_SPAN_METRICS_ENABLED) {
139-
const { createOtlpSpanStatsExporter } = require('../opentelemetry/metrics')
140-
otlpStatsExporter = createOtlpSpanStatsExporter(config)
102+
let NativeSpansInterface
103+
try {
104+
NativeSpansInterface = getNativeModule().NativeSpansInterface
105+
} catch (e) {
106+
if (isMissingLibdatadog(e) && config.OTEL_TRACES_EXPORTER !== 'otlp') {
107+
this._useJsSpans = true
108+
this._isCiVisibility = false
109+
let otlpStatsExporter
110+
if (config.OTEL_TRACES_SPAN_METRICS_ENABLED) {
111+
const { createOtlpSpanStatsExporter } = require('../opentelemetry/metrics')
112+
otlpStatsExporter = createOtlpSpanStatsExporter(config)
113+
}
114+
const Exporter = require('../exporters/agent')
115+
this._exporter = new Exporter(config, this._prioritySampler)
116+
this._processor = new JsSpanProcessor(
117+
this._exporter,
118+
this._prioritySampler,
119+
config,
120+
otlpStatsExporter
121+
)
122+
this._url = this._exporter._url
123+
log.warn(
124+
'Native spans unavailable because optional dependency %s is not installed; using JS span pipeline',
125+
'@datadog/libdatadog'
126+
)
127+
} else {
128+
throw e
129+
}
141130
}
142131

143-
this._exporter = new NativeExporter(config, this._prioritySampler, this._nativeSpans)
144-
this._processor = new SpanProcessor(
145-
this._exporter,
146-
this._prioritySampler,
147-
config,
148-
this._nativeSpans,
149-
otlpStatsExporter
150-
)
151-
this._url = agentUrl
132+
if (!this._useJsSpans) {
133+
const { url, hostname = defaults.hostname, port } = config
134+
const agentUrl = url || new URL(format({
135+
protocol: 'http:',
136+
hostname,
137+
port,
138+
}))
152139

153-
log.debug('Native spans mode enabled')
140+
this._nativeSpans = new NativeSpansInterface({
141+
agentUrl: agentUrl.toString(),
142+
tracerVersion: pkg.version,
143+
lang: 'nodejs',
144+
langVersion: process.version,
145+
// Bun runs on JavaScriptCore; match the legacy agent writer's
146+
// Datadog-Meta-Lang-Interpreter (process.versions.bun ? 'JavaScriptCore' : 'v8').
147+
langInterpreter: process.versions.bun ? 'JavaScriptCore' : (process.jsEngine || 'v8'),
148+
pid: process.pid,
149+
tracerService: config.service,
150+
// Native v0.6 client stats and OTLP trace metrics are mutually exclusive
151+
// (system-tests FR02): when OTLP trace metrics are enabled, config forces
152+
// DD_TRACE_STATS_COMPUTATION_ENABLED=true so the OTLP stats exporter runs,
153+
// but the native concentrator must NOT also ship v0.6 stats. Route stats
154+
// to OTLP only in that case by leaving the native concentrator disabled.
155+
statsEnabled: (config.stats?.DD_TRACE_STATS_COMPUTATION_ENABLED &&
156+
!config.OTEL_TRACES_SPAN_METRICS_ENABLED) || false,
157+
hostname: config.hostname || os.hostname(),
158+
env: config.env || '',
159+
appVersion: config.version || '',
160+
runtimeId: config.tags?.['runtime-id'] || '',
161+
otelSemanticsEnabled: config.DD_TRACE_OTEL_SEMANTICS_ENABLED || false,
162+
// Advertise Datadog-Client-Computed-Stats when we compute stats
163+
// client-side or run in APM-standalone (apmTracingEnabled=false), so the
164+
// agent skips its own APM stats/sampling for these traces.
165+
clientComputedStats: config.stats?.DD_TRACE_STATS_COMPUTATION_ENABLED || config.apmTracingEnabled === false,
166+
})
167+
168+
let otlpStatsExporter
169+
if (config.OTEL_TRACES_SPAN_METRICS_ENABLED) {
170+
const { createOtlpSpanStatsExporter } = require('../opentelemetry/metrics')
171+
otlpStatsExporter = createOtlpSpanStatsExporter(config)
172+
}
173+
174+
this._exporter = new NativeExporter(config, this._prioritySampler, this._nativeSpans)
175+
this._processor = new SpanProcessor(
176+
this._exporter,
177+
this._prioritySampler,
178+
config,
179+
this._nativeSpans,
180+
otlpStatsExporter
181+
)
182+
this._url = agentUrl
183+
184+
log.debug('Native spans mode enabled')
185+
}
154186
}
155187

156188
this._propagators = {

0 commit comments

Comments
 (0)