From 0a5ee27b418dd3c55d6a1fd21517b39b315cbc58 Mon Sep 17 00:00:00 2001 From: Bryan English Date: Wed, 8 Jul 2026 11:07:25 -0400 Subject: [PATCH] feat(pipeline): send Datadog-Client-Computed-Stats when enabled Add a `client_computed_stats` parameter to `WasmSpanState::new` and set `builder.set_client_computed_stats()` so the exporter sends the `Datadog-Client-Computed-Stats: true` header. The tracer needs this when it runs in APM-standalone (apmTracingEnabled=false) so the agent skips its own APM stats/sampling. The header is also sent whenever `stats_enabled` is set: computing stats client-side requires telling the agent to skip its own, otherwise the same traces are counted twice. Enabling stats therefore always implies the header, so the flags are OR'd in the binding rather than relying on the caller to keep them in sync. libdatadog's TraceExporterBuilder already supports the flag; the WASM binding just never called it. Adds pipeline tests asserting the header is present when client_computed_stats OR stats_enabled is set, and absent when both are off. --- crates/pipeline/src/lib.rs | 14 ++++++++++ test/pipeline.js | 56 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 28cd78b..54b03e8 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -236,6 +236,7 @@ impl WasmSpanState { env: &str, app_version: &str, runtime_id: &str, + client_computed_stats: bool, ) -> Result { let mut builder = TraceExporterBuilder::::new(); builder @@ -255,6 +256,19 @@ impl WasmSpanState { .set_runtime_id(runtime_id) .enable_agent_rates_payload_version(); + // Advertise `Datadog-Client-Computed-Stats` so the agent skips its own + // APM stats/sampling for these traces. This is required in two cases: + // - `stats_enabled`: we build a StatsCollector and send client-side + // stats, so the agent MUST NOT also compute them (double counting); + // - `client_computed_stats`: set independently for APM-standalone + // (apmTracingEnabled=false), where the agent should skip APM stats + // even though we don't compute them client-side. + // Enabling stats therefore always implies the header, so OR the flags + // rather than relying on the caller to keep them in sync. + if client_computed_stats || stats_enabled { + builder.set_client_computed_stats(); + } + let mut change_queue = vec![0u8; change_queue_size as usize]; let change_buffer = unsafe { ChangeBuffer::from_raw_parts( diff --git a/test/pipeline.js b/test/pipeline.js index da8fda5..cad84e8 100644 --- a/test/pipeline.js +++ b/test/pipeline.js @@ -167,6 +167,7 @@ class NativeSpansInterface { options.env || 'test-env', options.appVersion || '1.0.0', options.runtimeId || '00000000-0000-0000-0000-000000000000', + options.clientComputedStats ?? false, ) // Get pointers into WASM memory for direct buffer access @@ -1016,6 +1017,61 @@ describe('pipeline', { skip }, () => { }) }) + describe('client-computed-stats header', () => { + async function captureTraceHeader (nsOptions) { + const http = require('node:http') + let header + let sawTraces = false + const server = http.createServer((req, res) => { + req.on('data', () => {}) + req.on('end', () => { + if (req.url === '/v0.4/traces') { + sawTraces = true + header = req.headers['datadog-client-computed-stats'] + } + res.writeHead(200, { 'content-type': 'application/json' }) + res.end('{}') + }) + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() + const ns = new NativeSpansInterface({ agentUrl: `http://127.0.0.1:${port}`, ...nsOptions }) + const span = ns.createSpan() + span.name = 'span' + span.service = 'test-service' + span.resource = 'test-resource' + span.type = 'web' + span.duration = 1_000_000n + try { + await ns.flushSpans(span) + return { header, sawTraces } + } finally { + server.closeAllConnections?.() + server.close() + } + } + + it('sends Datadog-Client-Computed-Stats: true when enabled', async () => { + const { header, sawTraces } = await captureTraceHeader({ clientComputedStats: true }) + assert.ok(sawTraces, 'expected a POST to /v0.4/traces') + assert.strictEqual(header, 'true') + }) + + it('omits the header when both flags are disabled', async () => { + const { header, sawTraces } = await captureTraceHeader({ clientComputedStats: false, statsEnabled: false }) + assert.ok(sawTraces, 'expected a POST to /v0.4/traces') + assert.strictEqual(header, undefined) + }) + + it('sends the header when stats are enabled (client-side stats imply it)', async () => { + // Enabling client-side stats without clientComputedStats must still send + // the header, otherwise the agent double-counts APM stats. + const { header, sawTraces } = await captureTraceHeader({ statsEnabled: true, clientComputedStats: false }) + assert.ok(sawTraces, 'expected a POST to /v0.4/traces') + assert.strictEqual(header, 'true') + }) + }) + describe('client-side stats', () => { it('aggregates and flushes stats to /v0.6/stats', async () => { const http = require('node:http')