@@ -61,9 +61,6 @@ function formatSpansForDebug (spans) {
6161class 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
0 commit comments