Skip to content

Commit 25364a4

Browse files
committed
fix(native-spans): restore the parent commit's payload composition
Two commits ago I attributed a batch of plugin failures to sending the whole flush as one multi-trace payload, and switched back to one request per trace chunk. That diagnosis was wrong: the parent commit already batched, and it was green. Sending per chunk then broke the Azure Functions integration tests, which assert `payload.length === 2` and so require both traces in a single payload. The actual cause was the post-send drain. `#finishSend` had been gated on "is something waiting on this send", which let chunks accumulate across a send window instead of going out as soon as the previous send resolved. That changes how many traces a payload carries, and `traces[0]` consumers — the plugin test agent among them — depend on the payload holding the trace they just produced. rhea, for instance, read `traces[0][0]` and got `amqp.send` where it expected `amqp.receive`. So: restore batching, and restore the unconditional drain along with it. `#flushRequested` goes away with the gate; the oversized-payload split now relies on `#finishSend` draining the remainder, which it does. The gate was a throughput optimization (it stopped a 2s batch from degenerating into one request per round trip when spans trickle in during a send). Worth revisiting, but not at the cost of changing what a payload contains.
1 parent 8cb759d commit 25364a4

4 files changed

Lines changed: 158 additions & 157 deletions

File tree

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

Lines changed: 51 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,6 @@ function formatSpansForDebug (spans) {
6161
class NativeExporter {
6262
#timer
6363
#flushInFlight = false
64-
// An explicit flush() arrived while a send was in flight, so the send's
65-
// completion must drain rather than wait for the next batching timer.
66-
#flushRequested = false
6764
#firstFlushSent = false
6865
#flushCallbacks = []
6966
#activeSpans = 0
@@ -194,21 +191,20 @@ class NativeExporter {
194191
* Mirror the deleted OtlpHttpTraceExporter's per-request telemetry counters.
195192
* No-op on the agent path.
196193
*
197-
* The deleted JS exporter's `export()` was invoked per trace chunk and issued
198-
* one HTTP request each, tagged with that chunk's span count.
199-
* `flushSpansGrouped` also sends one request per chunk, so emit once per group
200-
* with that group's span count: a single per-flush increment would under-count
201-
* attempts by the number of chunks and would turn `spans:` into an unbounded
202-
* whole-flush total (the telemetry namespace map never evicts keys).
194+
* `attempts`/`successes` measure export *pushes*, so they are incremented once
195+
* per HTTP request. The deleted JS exporter's `export()` was invoked per trace
196+
* chunk and issued one request each, so its `spans:` tag was that chunk's span
197+
* count; `flushSpansGrouped` coalesces the whole flush into one multi-chunk
198+
* request, so the equivalent tag is the payload's total span count.
203199
*
204200
* @param {string} metric `otel.traces_export_attempts` or `..._successes`
205201
* @param {Array<{spanIds: Uint8Array[]}>} groups Groups in this flush
206202
*/
207203
#recordOtlpTelemetry (metric, groups) {
208204
if (this.#otlpTelemetryTags === null) return
209-
for (const group of groups) {
210-
tracerMetrics.count(metric, [...this.#otlpTelemetryTags, `spans:${group.spanIds.length}`]).inc(1)
211-
}
205+
let spans = 0
206+
for (const group of groups) spans += group.spanIds.length
207+
tracerMetrics.count(metric, [...this.#otlpTelemetryTags, `spans:${spans}`]).inc(1)
212208
}
213209

214210
/**
@@ -465,33 +461,17 @@ class NativeExporter {
465461
}
466462

467463
#finishSend () {
468-
// Only drain eagerly when something is actually waiting on this send.
469-
// Draining unconditionally defeated flushInterval entirely: any span that
470-
// finished inside a send window triggered another send the moment the
471-
// previous one resolved, turning a 2s batch into one request per round trip.
472-
const waiting = this.#flushRequested ||
473-
this.#flushCallbacks.length > 0 ||
474-
this.#urlUpdateCallbacks.length > 0
475-
this.#flushRequested = false
476-
477-
if (this._pendingSpanChunks.length > 0 && waiting) {
464+
// Drain unconditionally. Gating this on "is something waiting" lets chunks
465+
// accumulate across a send window, which changes how many traces a payload
466+
// carries - and `traces[0]` consumers (the plugin test agent among them)
467+
// depend on a payload holding the trace they just produced.
468+
if (this._pendingSpanChunks.length > 0) {
478469
this.flush()
479470
return
480471
}
481472

482473
this.#finishFlushCallbacks()
483474
this.#finishUrlUpdateCallbacks()
484-
485-
// An explicit flush() during the send cleared the batching timer; re-arm it
486-
// so spans buffered in the meantime still go out on the normal interval.
487-
const { flushInterval } = this._config
488-
if (this._pendingSpanChunks.length > 0 && flushInterval > 0 && this.#timer === undefined) {
489-
this.#timer = setTimeout(() => {
490-
this.flush()
491-
this.#timer = undefined
492-
}, flushInterval)
493-
this.#timer.unref?.()
494-
}
495475
}
496476

497477
#handleSendError (err) {
@@ -552,7 +532,6 @@ class NativeExporter {
552532
// on this to observe spans that finished while a previous payload was still
553533
// being sent.
554534
if (this.#flushInFlight) {
555-
this.#flushRequested = true
556535
return
557536
}
558537

@@ -561,12 +540,35 @@ class NativeExporter {
561540
return
562541
}
563542

564-
// Each chunk becomes its own HTTP request (see flushSpansGrouped), so payload
565-
// size is bounded by one trace and there is nothing to split here. The
566-
// soft-limit trigger in `export()` still bounds how much is buffered.
567-
const spanChunks = this._pendingSpanChunks
568-
this._pendingSpans = []
569-
this._pendingSpanChunks = []
543+
// One flush is one HTTP request, so cap what a single payload carries. The
544+
// soft-limit trigger in `export()` bounds how much is buffered while idle, but
545+
// it cannot bound this: sends are serialized, so while one is in flight
546+
// `flush()` returns early and `_pendingSpanChunks` keeps growing for the whole
547+
// round trip. Take whole chunks up to the limit and leave the rest, which
548+
// `#finishSend` drains as soon as this send resolves.
549+
let spanChunks
550+
if (this._pendingSpans.length > SOFT_LIMIT_SPANS) {
551+
let taken = 0
552+
let i = 0
553+
// Never split a chunk - chunk boundaries are the processor's trace
554+
// boundaries. Always take at least one, even if it alone exceeds the limit.
555+
while (i < this._pendingSpanChunks.length &&
556+
(taken === 0 || taken + this._pendingSpanChunks[i].length <= SOFT_LIMIT_SPANS)) {
557+
taken += this._pendingSpanChunks[i].length
558+
i++
559+
}
560+
spanChunks = this._pendingSpanChunks.slice(0, i)
561+
this._pendingSpanChunks = this._pendingSpanChunks.slice(i)
562+
// `_pendingSpans` is the in-order concatenation of the chunks, so the
563+
// remainder is exactly the tail past what this payload took.
564+
this._pendingSpans = this._pendingSpans.slice(taken)
565+
// The remainder ships from `#finishSend`, which drains whatever is still
566+
// pending as soon as this send resolves.
567+
} else {
568+
spanChunks = this._pendingSpanChunks
569+
this._pendingSpans = []
570+
this._pendingSpanChunks = []
571+
}
570572

571573
// Convert each SpanProcessor export call into one or more native chunks,
572574
// splitting only traces that happen to share one export call. Never group
@@ -575,12 +577,11 @@ class NativeExporter {
575577
// when flushInterval coalesces HTTP sends.
576578
const groups = this.#groupsFromSpanChunks(spanChunks, true)
577579

578-
// `flushSpansGrouped` sends one request per trace chunk, so count per chunk:
579-
// a single per-flush increment reported 1/N of the real request volume and
580-
// left `.requests` on a different scale from `.errors`, which is per-attempt.
581-
for (let i = 0; i < groups.length; i++) {
582-
runtimeMetrics.increment(`${METRIC_PREFIX}.requests`, true)
583-
}
580+
// `flushSpansGrouped` stages every chunk synchronously and issues exactly one
581+
// HTTP request for the whole flush, so `.requests`/`.responses` are counted
582+
// once here - the same per-request scale as `.errors` and as the legacy
583+
// AgentWriter's `_sendPayload`.
584+
runtimeMetrics.increment(`${METRIC_PREFIX}.requests`, true)
584585
this.#recordOtlpTelemetry('otel.traces_export_attempts', groups)
585586
// Self-guarded (`integrationsAlreadyRan`), so the repeat cost is one boolean.
586587
// Without this the on-by-default `INTEGRATIONS LOADED` startup line never
@@ -597,8 +598,9 @@ class NativeExporter {
597598
this.#firstFlushSent = true
598599
firstFlushChannel.publish()
599600
}
600-
// One request per trace chunk, sequentially, preserving the legacy writer's
601-
// one-trace-per-payload shape that `traces[0]` consumers rely on.
601+
// One request carrying one chunk per trace: `prepareChunk` appends to a
602+
// native chunk Vec and `sendPreparedChunk` drains all of it into a single
603+
// multi-trace payload, which is the shape the legacy AgentWriter sent.
602604
let sendGrouped
603605
try {
604606
sendGrouped = this._nativeSpans.flushSpansGrouped(groups)
@@ -610,9 +612,7 @@ class NativeExporter {
610612
sendGrouped
611613
.then((response) => {
612614
this.#flushInFlight = false
613-
for (let i = 0; i < groups.length; i++) {
614-
runtimeMetrics.increment(`${METRIC_PREFIX}.responses`, true)
615-
}
615+
runtimeMetrics.increment(`${METRIC_PREFIX}.responses`, true)
616616
this.#recordOtlpTelemetry('otel.traces_export_successes', groups)
617617
// The agent's response carries per-service sampling rates. Feed them
618618
// back into the priority sampler so adaptive (agent-driven) sampling

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

Lines changed: 44 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1209,47 +1209,65 @@ class NativeSpansInterface {
12091209
* local root. Passing many traces as one chunk would lump distinct trace_ids
12101210
* together and stamp only the first — corrupting sampling/grouping under load.
12111211
*
1212-
* One request per group, sequentially. The native `prepared_spans` Vec does
1213-
* accumulate (libdatadog-nodejs #159) and `sendPreparedChunk` would drain all of
1214-
* it into a single multi-trace payload, so batching the whole flush into one
1215-
* request is possible - but it changes what a consumer sees per payload, and
1216-
* `traces[0]` consumers (the test agent among them) rely on one trace per
1217-
* payload, which is also what the legacy AgentWriter produced at the flush
1218-
* intervals these paths run with. Sending per group also keeps a failed send
1219-
* from taking unrelated traces down with it.
1212+
* `prepareChunk` appends to a native chunk Vec and `sendPreparedChunk` drains
1213+
* all of it into a single multi-trace request (libdatadog-nodejs #159), which
1214+
* is the same shape the legacy writer sends. So stage the whole flush, then
1215+
* send once: one HTTP request per flush carrying one chunk per trace.
1216+
*
1217+
* Staging synchronously also matters for correctness. `prepareChunk` is what
1218+
* drains the change buffer and removes spans from the WASM map, so with no
1219+
* await between groups nothing can finish into a half-staged flush, and the
1220+
* string table can be evicted exactly once at a provably drained point.
12201221
*
12211222
* @param {Array<{spanIds: Uint8Array[], firstIsLocalRoot: boolean}>} groups
12221223
* @returns {Promise<string>} The agent response body, or a no-op marker
12231224
*/
12241225
flushSpansGrouped (groups) {
12251226
// Drain all pending ops (creates, tags, per-trace sampling/trace tags) once
1226-
// up front so every chunk prepared below sees a fully-applied span map.
1227+
// up front so every chunk staged below sees a fully-applied span map.
12271228
this.flushChangeQueue()
12281229

1229-
const pending = []
1230-
for (const group of groups) {
1231-
if (group.spanIds?.length) pending.push(group)
1230+
let staged = 0
1231+
try {
1232+
for (const group of groups) {
1233+
if (!group.spanIds?.length) continue
1234+
if (this.#prepareGroup(group)) staged++
1235+
}
1236+
} catch (e) {
1237+
// prepareChunk may throw partway through (`flush_chunk` errors on an
1238+
// absent span id), after consuming some of the change queue or growing
1239+
// WASM memory. Reset JS-side queue state and refresh views so the next
1240+
// caller starts from a known-good baseline. Groups staged before the
1241+
// throw stay staged and ship with the next flush - they are real spans we
1242+
// wanted to send, so delaying beats dropping them.
1243+
this.resetChangeQueue()
1244+
this.#checkDetach()
1245+
log.error('Error preparing spans to flush:', e)
1246+
return Promise.reject(e)
12321247
}
12331248

1234-
if (pending.length === 0) {
1235-
this.#evictStringTable(true)
1236-
return Promise.resolve('no spans to flush')
1237-
}
1249+
// Safe here and only here: `prepareChunk` drained the change buffer and
1250+
// resolved every interned id into the staged spans, and nothing can have
1251+
// queued an op since (staging above is synchronous). `#evictStringTable(true)`
1252+
// also resets `_stringIdCounter`, so running it while ops are queued - e.g.
1253+
// from a `.finally()` after the async send - would re-issue live ids to
1254+
// different strings and silently mis-tag exported spans.
1255+
this.#evictStringTable(true)
12381256

1239-
// Sequential: `sendPreparedChunk` also guards against async re-entrancy, and
1240-
// staging the next chunk while one is in flight would put it in the same
1241-
// payload as the current one.
1242-
let chain = Promise.resolve('no spans to flush')
1243-
for (let i = 0; i < pending.length; i++) {
1244-
const group = pending[i]
1245-
const isLast = i === pending.length - 1
1246-
chain = chain.then(() => this.#prepareAndSend(group, isLast))
1257+
if (staged === 0) return Promise.resolve('no spans to flush')
1258+
1259+
const send = this._state.sendPreparedChunk()
1260+
this.#sendInFlight = send
1261+
const clearSend = () => {
1262+
if (this.#sendInFlight === send) this.#sendInFlight = null
12471263
}
1264+
send.then(clearSend, clearSend)
12481265

1249-
return chain
1266+
return send
12501267
.catch(e => {
12511268
// A send failure is a *network* fault for the already-serialized chunks;
1252-
// those are lost, which is expected on a transient agent outage.
1269+
// `sendPreparedChunk` took them out of the native Vec before sending, so
1270+
// they are lost, which is expected on a transient agent outage.
12531271
//
12541272
// Crucially, do NOT resetChangeQueue() here. sendPreparedChunk is async,
12551273
// so by the time this rejection lands, ops for *other* spans (including
@@ -1270,55 +1288,6 @@ class NativeSpansInterface {
12701288
})
12711289
}
12721290

1273-
/**
1274-
* Stage one trace's chunk and send it. Rejects if staging throws, after
1275-
* restoring JS-side queue state.
1276-
*
1277-
* @param {{spanIds: Uint8Array[], firstIsLocalRoot: boolean}} group
1278-
* @param {boolean} isLast Whether this is the final group of the flush
1279-
* @returns {Promise<string>}
1280-
*/
1281-
#prepareAndSend (group, isLast) {
1282-
// Every group after the first runs in a `.then()` after an HTTP response, so
1283-
// spans that finished during that send have queued ops. `prepareChunk` calls
1284-
// `flush_change_buffer` internally, which zeroes the WASM header without
1285-
// touching our `_cqbIndex`/`_cqbCount` — drain through our own path first so
1286-
// the two stay in sync (same hazard `setMetaStruct` documents). A no-op when
1287-
// the queue is already empty.
1288-
this.flushChangeQueue()
1289-
1290-
let staged
1291-
try {
1292-
staged = this.#prepareGroup(group)
1293-
} catch (e) {
1294-
// prepareChunk may throw partway through, after consuming some of the
1295-
// change queue or growing WASM memory. Reset JS-side queue state and
1296-
// refresh views so the next caller starts from a known-good baseline.
1297-
this.resetChangeQueue()
1298-
this.#checkDetach()
1299-
log.error('Error preparing spans to flush:', e)
1300-
return Promise.reject(e)
1301-
}
1302-
1303-
// Evict only here, and only on the last group: `prepareChunk` has just
1304-
// drained the change buffer, so no queued op can still reference an id we
1305-
// are about to evict — and `#evictStringTable(true)` also resets
1306-
// `_stringIdCounter`, so running it while ops are queued (e.g. from a
1307-
// `.finally()` after the async send) would re-issue live ids to different
1308-
// strings and silently mis-tag exported spans.
1309-
if (isLast) this.#evictStringTable(true)
1310-
1311-
if (!staged) return Promise.resolve('no spans to flush')
1312-
1313-
const send = this._state.sendPreparedChunk()
1314-
this.#sendInFlight = send
1315-
const clearSend = () => {
1316-
if (this.#sendInFlight === send) this.#sendInFlight = null
1317-
}
1318-
send.then(clearSend, clearSend)
1319-
return send
1320-
}
1321-
13221291
// Note: sample() is not available in the WASM pipeline module.
13231292
// Sampling is handled by the JS-side priority sampler.
13241293
}

0 commit comments

Comments
 (0)