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')