Skip to content

Commit 4e329b3

Browse files
IlyasShabiBridgeAR
authored andcommitted
fix(standalone): stamp _dd.apm.enabled on every exported chunk (#9483)
1 parent 4b3a3b6 commit 4e329b3

6 files changed

Lines changed: 147 additions & 88 deletions

File tree

integration-tests/appsec/standalone-asm.spec.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@ describe('Standalone ASM', () => {
4545
}
4646
}
4747

48+
function isLateOutboundSpan (span) {
49+
return span.name === 'http.request' && span.meta['http.url']?.endsWith('/intake/v2/events')
50+
}
51+
52+
function isLateOutboundRoot (span) {
53+
return span.resource === 'GET /late-outbound'
54+
}
55+
4856
describe('enabled', () => {
4957
beforeEach(async () => {
5058
agent = await new FakeAgent().start()
@@ -81,6 +89,45 @@ describe('Standalone ASM', () => {
8189
})
8290
})
8391

92+
it('should add _dd.apm.enabled tag to delayed local child chunks', async () => {
93+
const seenGroups = []
94+
const groups = await agent.collectGroups({
95+
trigger: () => curl(`${proc.url}/late-outbound`),
96+
predicate: group => {
97+
seenGroups.push(group.map(span => ({
98+
name: span.name,
99+
resource: span.resource,
100+
url: span.meta['http.url'],
101+
apmEnabled: span.metrics['_dd.apm.enabled'],
102+
})))
103+
104+
return group.some(isLateOutboundRoot) || group.some(isLateOutboundSpan)
105+
},
106+
expectedCount: 2,
107+
}).catch(error => {
108+
error.message += `\nSeen groups: ${inspect(seenGroups, { depth: null })}`
109+
throw error
110+
})
111+
112+
const rootGroup = groups.find(group => group.some(isLateOutboundRoot))
113+
const outboundGroup = groups.find(group => group.some(isLateOutboundSpan))
114+
const rootSpan = rootGroup?.find(isLateOutboundRoot)
115+
const outboundSpan = outboundGroup?.find(isLateOutboundSpan)
116+
117+
// Two distinct chunks: parent flushes first, delayed child flushes later.
118+
assert.notStrictEqual(rootGroup, undefined)
119+
assert.notStrictEqual(outboundGroup, undefined)
120+
assert.notStrictEqual(outboundGroup, rootGroup)
121+
assert.ok(groups.indexOf(rootGroup) < groups.indexOf(outboundGroup))
122+
assert.strictEqual(String(outboundSpan.parent_id), String(rootSpan.span_id))
123+
124+
// Load-bearing: the delayed child chunk must carry the billing marker,
125+
// even though its parent is a local (non-remote) span.
126+
assert.strictEqual(rootSpan.metrics['_dd.apm.enabled'], 0)
127+
assert.strictEqual(outboundGroup[0], outboundSpan)
128+
assert.strictEqual(outboundSpan.metrics['_dd.apm.enabled'], 0)
129+
})
130+
84131
it('should keep fifth req because RateLimiter allows 1 req/min', async () => {
85132
const promise = curlAndAssertMessage(agent, proc, ({ headers, payload }) => {
86133
assert.strictEqual(headers['datadog-client-computed-stats'], 'yes')

integration-tests/standalone-asm/index.js

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,14 @@ tracer.init(options)
2323

2424
const crypto = require('crypto')
2525
const http = require('http')
26+
const net = require('net')
2627

2728
const express = require('express')
2829
const app = express()
2930

3031
const valueToHash = 'iast-showcase-demo'
32+
let delayedOutboundPort
33+
let server
3134

3235
async function makeRequest (url) {
3336
return new Promise((resolve, reject) => {
@@ -66,6 +69,20 @@ app.get('/vulnerableHash', (req, res) => {
6669
res.status(200).send(result)
6770
})
6871

72+
app.get('/late-outbound', (req, res) => {
73+
const url = `http://localhost:${delayedOutboundPort}/intake/v2/events`
74+
const activeSpan = tracer.scope().active()
75+
const rootSpan = activeSpan?.context()._trace.started[0] || activeSpan
76+
77+
setTimeout(() => {
78+
tracer.scope().activate(rootSpan, () => {
79+
makeRequest(url).catch(() => {})
80+
})
81+
}, 250)
82+
83+
res.status(200).send('late-outbound')
84+
})
85+
6986
app.get('/propagation-with-event', async (req, res) => {
7087
tracer.appsec.trackCustomEvent('custom-event')
7188

@@ -109,7 +126,17 @@ app.get('/propagation-after-drop-and-call-sdk', async (req, res) => {
109126
res.status(200).send(`drop-and-call-sdk ${sdkRes}`)
110127
})
111128

112-
const server = http.createServer(app).listen(0, () => {
113-
const port = (/** @type {import('net').AddressInfo} */ (server.address())).port
114-
process.send?.({ port })
129+
const delayedOutboundServer = net.createServer(socket => {
130+
socket.once('data', () => {
131+
socket.end('HTTP/1.1 202 Accepted\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok')
132+
})
133+
})
134+
135+
delayedOutboundServer.listen(0, () => {
136+
delayedOutboundPort = (/** @type {import('net').AddressInfo} */ (delayedOutboundServer.address())).port
137+
138+
server = http.createServer(app).listen(0, () => {
139+
const port = (/** @type {import('net').AddressInfo} */ (server.address())).port
140+
process.send?.({ port })
141+
})
115142
})

packages/dd-trace/src/span_processor.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const SpanSampler = require('./span_sampler')
66
const GitMetadataTagger = require('./git_metadata_tagger')
77
const processTags = require('./process-tags')
88
const { applyHttpOtelSemantics } = require('./plugins/util/http-otel-semantics')
9+
const { APM_TRACING_ENABLED_KEY } = require('./constants')
910

1011
const startedSpans = new WeakSet()
1112
const finishedSpans = new WeakSet()
@@ -54,12 +55,16 @@ class SpanProcessor {
5455
this._gitMetadataTagger.tagGitMetadata(spanContext)
5556

5657
let isFirstSpanInChunk = true
58+
const stampApmDisabled = this._config.apmTracingEnabled === false
5759

5860
for (const span of started) {
5961
if (span._duration === undefined) {
6062
active.push(span)
6163
} else {
6264
const formattedSpan = spanFormat(span, isFirstSpanInChunk, this._processTags)
65+
if (isFirstSpanInChunk && stampApmDisabled) {
66+
formattedSpan.metrics[APM_TRACING_ENABLED_KEY] = 0
67+
}
6368
isFirstSpanInChunk = false
6469
// Span stats read Datadog HTTP tag names from the formatted span, so
6570
// record them before the OTel rename — an export-only transform.

packages/dd-trace/src/standalone/index.js

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,38 +2,24 @@
22

33
const { channel } = require('dc-polyfill')
44
const { USER_KEEP } = require('../../../../ext/priority')
5-
const { APM_TRACING_ENABLED_KEY } = require('../constants')
65
const TraceSourcePrioritySampler = require('./tracesource_priority_sampler')
76
const { hasTraceSourcePropagationTag } = require('./tracesource')
87

9-
const startCh = channel('dd-trace:span:start')
108
const extractCh = channel('dd-trace:span:extract')
119

1210
/**
1311
* @param {import('../config/config-base')} config - Tracer configuration
1412
*/
1513
function configure (config) {
16-
if (startCh.hasSubscribers) startCh.unsubscribe(onSpanStart)
1714
if (extractCh.hasSubscribers) extractCh.unsubscribe(onSpanExtract)
1815

1916
if (config.apmTracingEnabled !== false) return
2017

21-
startCh.subscribe(onSpanStart)
2218
extractCh.subscribe(onSpanExtract)
2319

2420
return new TraceSourcePrioritySampler(config.env)
2521
}
2622

27-
function onSpanStart ({ span, fields }) {
28-
const context = span.context?.()
29-
if (!context) return
30-
31-
const { parent } = fields
32-
if (!parent || parent._isRemote) {
33-
context.setTag(APM_TRACING_ENABLED_KEY, 0)
34-
}
35-
}
36-
3723
function onSpanExtract ({ spanContext = {} }) {
3824
if (!spanContext._trace?.tags || !spanContext._sampling) return
3925

packages/dd-trace/test/span_processor.spec.js

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ const proxyquire = require('proxyquire')
99

1010
require('./setup/core')
1111

12+
const { APM_TRACING_ENABLED_KEY } = require('../src/constants')
13+
1214
describe('SpanProcessor', () => {
1315
let prioritySampler
1416
let processor
@@ -233,6 +235,67 @@ describe('SpanProcessor', () => {
233235
sinon.assert.calledWith(spanFormat.getCall(3), finishedSpan, false, processor._processTags)
234236
})
235237

238+
it('should add APM disabled marker to first span in a chunk when APM tracing is disabled', () => {
239+
config.apmTracingEnabled = false
240+
config.flushMinSpans = 2
241+
const processor = new SpanProcessor(exporter, prioritySampler, config)
242+
const firstFormatted = { metrics: {} }
243+
const secondFormatted = { metrics: {} }
244+
spanFormat.onFirstCall().returns(firstFormatted)
245+
spanFormat.onSecondCall().returns(secondFormatted)
246+
trace.started = [activeSpan, finishedSpan, finishedSpan]
247+
trace.finished = [finishedSpan, finishedSpan]
248+
249+
processor.process(finishedSpan)
250+
251+
assert.strictEqual(firstFormatted.metrics[APM_TRACING_ENABLED_KEY], 0)
252+
assert.ok(!Object.hasOwn(secondFormatted.metrics, APM_TRACING_ENABLED_KEY))
253+
sinon.assert.calledWith(exporter.export, [firstFormatted, secondFormatted])
254+
})
255+
256+
it('should add APM disabled marker to every chunk when a delayed child flushes alone', () => {
257+
// Reproduces the standalone-ASM billing regression: the entry span flushes
258+
// in one chunk, then a long-lived child (e.g. delayed http.request) flushes
259+
// later in its own chunk. Both chunks must carry _dd.apm.enabled:0.
260+
config.apmTracingEnabled = false
261+
const processor = new SpanProcessor(exporter, prioritySampler, config)
262+
const parentFormatted = { metrics: {} }
263+
const childFormatted = { metrics: {} }
264+
spanFormat.onFirstCall().returns(parentFormatted)
265+
spanFormat.onSecondCall().returns(childFormatted)
266+
267+
const parentSpan = { ...finishedSpan }
268+
const childSpan = { ...finishedSpan }
269+
trace.started = [parentSpan]
270+
trace.finished = [parentSpan]
271+
272+
processor.process(parentSpan)
273+
274+
assert.strictEqual(parentFormatted.metrics[APM_TRACING_ENABLED_KEY], 0)
275+
sinon.assert.calledWith(exporter.export, [parentFormatted])
276+
277+
trace.started = [childSpan]
278+
trace.finished = [childSpan]
279+
280+
processor.process(childSpan)
281+
282+
assert.strictEqual(childFormatted.metrics[APM_TRACING_ENABLED_KEY], 0)
283+
sinon.assert.calledWith(exporter.export.secondCall, [childFormatted])
284+
})
285+
286+
it('should not add APM disabled marker when APM tracing is enabled', () => {
287+
config.apmTracingEnabled = true
288+
const processor = new SpanProcessor(exporter, prioritySampler, config)
289+
const formattedSpan = { metrics: {} }
290+
spanFormat.returns(formattedSpan)
291+
trace.started = [finishedSpan]
292+
trace.finished = [finishedSpan]
293+
294+
processor.process(finishedSpan)
295+
296+
assert.ok(!Object.hasOwn(formattedSpan.metrics, APM_TRACING_ENABLED_KEY))
297+
})
298+
236299
describe('with DD_TRACE_OTEL_SEMANTICS_ENABLED', () => {
237300
function formattedHttpSpan () {
238301
return {

packages/dd-trace/test/standalone/index.spec.js

Lines changed: 2 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ const standalone = require('../../src/standalone')
1414
const DatadogSpan = require('../../src/opentracing/span')
1515

1616
const {
17-
APM_TRACING_ENABLED_KEY,
1817
SAMPLING_MECHANISM_APPSEC,
1918
DECISION_MAKER_KEY,
2019
TRACE_SOURCE_PROPAGATION_KEY,
@@ -24,7 +23,6 @@ const TextMapPropagator = require('../../src/opentracing/propagation/text_map')
2423
const TraceState = require('../../src/opentracing/propagation/tracestate')
2524
const TraceSourcePrioritySampler = require('../../src/standalone/tracesource_priority_sampler')
2625

27-
const startCh = channel('dd-trace:span:start')
2826
const extractCh = channel('dd-trace:span:extract')
2927

3028
describe('Disabled APM Tracing or Standalone', () => {
@@ -49,33 +47,26 @@ describe('Disabled APM Tracing or Standalone', () => {
4947
afterEach(() => { sinon.restore() })
5048

5149
describe('configure', () => {
52-
let startChSubscribe
53-
let startChUnsubscribe
5450
let extractChSubscribe
5551
let extractChUnsubscribe
5652

5753
beforeEach(() => {
58-
startChSubscribe = sinon.stub(startCh, 'subscribe')
59-
startChUnsubscribe = sinon.stub(startCh, 'unsubscribe')
6054
extractChSubscribe = sinon.stub(extractCh, 'subscribe')
6155
extractChUnsubscribe = sinon.stub(extractCh, 'unsubscribe')
6256
})
6357

64-
it('should subscribe to start span if apmTracing disabled', () => {
58+
it('should subscribe to extract if apmTracing disabled', () => {
6559
standalone.configure(config)
6660

67-
sinon.assert.calledOnce(startChSubscribe)
6861
sinon.assert.calledOnce(extractChSubscribe)
6962
})
7063

71-
it('should not subscribe to start span if apmTracing enabled', () => {
64+
it('should not subscribe to extract if apmTracing enabled', () => {
7265
config.apmTracingEnabled = true
7366

7467
standalone.configure(config)
7568

76-
sinon.assert.notCalled(startChSubscribe)
7769
sinon.assert.notCalled(extractChSubscribe)
78-
sinon.assert.notCalled(startChUnsubscribe)
7970
sinon.assert.notCalled(extractChUnsubscribe)
8071
})
8172

@@ -120,66 +111,6 @@ describe('Disabled APM Tracing or Standalone', () => {
120111
})
121112
})
122113

123-
describe('onStartSpan', () => {
124-
it('should not add _dd.apm.enabled tag when standalone is disabled', () => {
125-
config.apmTracingEnabled = true
126-
standalone.configure(config)
127-
128-
const span = new DatadogSpan(tracer, processor, prioritySampler, {
129-
operationName: 'operation',
130-
})
131-
132-
assert.ok(!span.context().hasTag(APM_TRACING_ENABLED_KEY))
133-
})
134-
135-
it('should add _dd.apm.enabled tag when standalone is enabled', () => {
136-
standalone.configure(config)
137-
138-
const span = new DatadogSpan(tracer, processor, prioritySampler, {
139-
operationName: 'operation',
140-
})
141-
142-
assert.ok(
143-
span.context().hasTag(APM_TRACING_ENABLED_KEY),
144-
`Available keys: ${inspect(Object.keys(span.context().getTags()))}`
145-
)
146-
})
147-
148-
it('should not add _dd.apm.enabled tag in child spans with local parent', () => {
149-
standalone.configure(config)
150-
151-
const parent = new DatadogSpan(tracer, processor, prioritySampler, {
152-
operationName: 'operation',
153-
})
154-
155-
assert.strictEqual(parent.context().getTag(APM_TRACING_ENABLED_KEY), 0)
156-
157-
const child = new DatadogSpan(tracer, processor, prioritySampler, {
158-
operationName: 'operation',
159-
parent,
160-
})
161-
162-
assert.ok(!child.context().hasTag(APM_TRACING_ENABLED_KEY))
163-
})
164-
165-
it('should add _dd.apm.enabled tag in child spans with remote parent', () => {
166-
standalone.configure(config)
167-
168-
const parent = new DatadogSpan(tracer, processor, prioritySampler, {
169-
operationName: 'operation',
170-
})
171-
172-
parent._isRemote = true
173-
174-
const child = new DatadogSpan(tracer, processor, prioritySampler, {
175-
operationName: 'operation',
176-
parent,
177-
})
178-
179-
assert.strictEqual(child.context().getTag(APM_TRACING_ENABLED_KEY), 0)
180-
})
181-
})
182-
183114
describe('onSpanExtract', () => {
184115
it('should reset priority if _dd.p.ts not present', () => {
185116
standalone.configure(config)

0 commit comments

Comments
 (0)