Skip to content

Commit b054df3

Browse files
BridgeARpabloerhard
authored andcommitted
feat(aws-sdk): link batch SQS receives to every producer (#9058)
A `receiveMessage` returning more than one message extracted no trace context at all — the `MaxNumberOfMessages !== 1` guard bailed before reading any `_datadog` carrier — so the consumer span was an orphan whenever a batch was pulled. The receive now reads the carrier of every message: the first becomes the parent and each additional one fans in as a span link, matching the batch-receive shape dd-trace-java and dd-trace-py use for SQS. Carrier parsing (MessageAttributes, SNS unwrap, EventBridge envelope) is now one helper shared by APM extraction and DSM, so each message body is parsed once and the two paths can no longer drift on the parse-failure fall-through. Fixes: #2474
1 parent 091946d commit b054df3

4 files changed

Lines changed: 297 additions & 177 deletions

File tree

packages/datadog-plugin-aws-sdk/src/services/sqs.js

Lines changed: 83 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -72,44 +72,53 @@ class Sqs extends BaseAwsSdkPlugin {
7272
})
7373
}
7474

75+
/**
76+
* Start the consumer (`aws.response`) span for a receive. The first message carrying trace
77+
* context becomes the parent; every additional one fans in as a span link.
78+
*
79+
* @param {{ request: object, response: object, needsFinish?: boolean, currentStore?: object }} ctx
80+
* @returns {object | undefined} The store to activate for the consumer span, else the parent store.
81+
*/
7582
#startResponseSpan (ctx) {
7683
const { request, response } = ctx
77-
const contextExtraction = this.responseExtract(request.params, request.operation, response)
84+
const carriers = this.responseExtract(request.params, request.operation, response)
7885

7986
let store = this._parentMap.get(request)
8087
let span
81-
let parsedMessageAttributes
82-
let parsedFirstBody
83-
let firstBodyChecked = false
84-
if (contextExtraction !== undefined) {
85-
parsedFirstBody = contextExtraction.parsedBody
86-
firstBodyChecked = contextExtraction.bodyChecked === true
87-
if (contextExtraction.datadogContext !== undefined) {
88-
parsedMessageAttributes = contextExtraction.parsedAttributes
89-
// request:start records requestTags only after the isEnabled gate, so an absent entry
90-
// means this consumer is disabled — gate on it instead of paying isEnabled again here.
91-
const requestTags = this.requestTags.get(request)
92-
if (requestTags !== undefined) {
93-
ctx.needsFinish = true
94-
const options = {
95-
childOf: contextExtraction.datadogContext,
96-
meta: {
97-
...requestTags,
98-
'span.kind': 'server',
99-
},
100-
integrationName: 'aws-sdk',
88+
89+
if (carriers !== undefined) {
90+
// request:start records requestTags only after the isEnabled gate, so an absent entry
91+
// means this consumer is disabled — gate on it instead of paying isEnabled again here.
92+
const requestTags = this.requestTags.get(request)
93+
if (requestTags !== undefined) {
94+
// A receive can return messages from many producers; fanning the extra ones in as span
95+
// links is the shape dd-trace-java and dd-trace-py use for batch SQS receives.
96+
for (const carrier of carriers) {
97+
if (carrier === undefined) continue
98+
const datadogContext = this.tracer.extract('text_map', carrier)
99+
// A DSM-only carrier (a non-first sendMessageBatch entry when batchPropagationEnabled
100+
// is off) extracts to null; span.addLink dereferences the context and would throw on it.
101+
if (datadogContext === null) continue
102+
if (span === undefined) {
103+
ctx.needsFinish = true
104+
span = this.startSpan('aws.response', {
105+
childOf: datadogContext,
106+
meta: {
107+
...requestTags,
108+
'span.kind': 'server',
109+
},
110+
integrationName: 'aws-sdk',
111+
}, ctx)
112+
store = ctx.currentStore
113+
} else {
114+
span.addLink({ context: datadogContext })
101115
}
102-
span = this.startSpan('aws.response', options, ctx)
103-
store = ctx.currentStore
104116
}
105117
}
106118
}
107119

108120
// Extract DSM context after, as we might not have a parent-child but may have a DSM context.
109-
this.responseExtractDSMContext(
110-
request.operation, request.params, response, span ?? null,
111-
{ parsedAttributes: parsedMessageAttributes, parsedFirstBody, firstBodyChecked }
112-
)
121+
this.responseExtractDSMContext(request.operation, request.params, response, span ?? null, carriers)
113122

114123
return store
115124
}
@@ -183,54 +192,49 @@ class Sqs extends BaseAwsSdkPlugin {
183192
return tags
184193
}
185194

195+
/**
196+
* Parse the trace-context carrier of every received message, in message order.
197+
* Entries are `undefined` for messages that carry no `_datadog` context.
198+
*
199+
* @param {{ MaxNumberOfMessages?: number }} params
200+
* @param {string} operation
201+
* @param {{ Messages?: object[] }} response
202+
* @returns {Array<Record<string, string> | undefined> | undefined}
203+
*/
186204
responseExtract (params, operation, response) {
187205
if (operation !== 'receiveMessage') return
188-
if (params.MaxNumberOfMessages && params.MaxNumberOfMessages !== 1) return
189-
if (!response || !response.Messages || !response.Messages[0]) return
206+
if (!response?.Messages?.length) return
190207

191-
let message = response.Messages[0]
192-
let parsedBody
208+
return response.Messages.map(message => this.parseMessageCarrier(message))
209+
}
193210

211+
/**
212+
* Resolve the trace-context carrier for a single received message. The
213+
* `MessageAttributes._datadog` text map (direct SQS or SNS to SQS) wins;
214+
* otherwise the EventBridge envelope, optionally wrapped in an SNS
215+
* `Notification` (see getEventBridgeContext). Checking MessageAttributes first
216+
* avoids parsing a large SNS `Message` just to rule out an EventBridge envelope.
217+
*
218+
* @param {object} message A single `response.Messages` entry.
219+
* @returns {Record<string, string> | undefined}
220+
*/
221+
parseMessageCarrier (message) {
222+
let parsedBody
194223
if (message.Body) {
195224
try {
196225
parsedBody = JSON.parse(message.Body)
197226
} catch {
198-
// SQS to SQS
227+
// Opaque, non-JSON body (SQS to SQS).
199228
}
200229
// SNS to SQS
201230
if (parsedBody?.Type === 'Notification') {
202231
message = parsedBody
203232
}
204233
}
205234

206-
// Check MessageAttributes first (common direct-SQS/SNS path): avoids parsing
207-
// the body (e.g. a large SNS `Message`) just to rule out an EventBridge
208-
// envelope. Precedence matches responseExtractDSMContext.
209235
const datadogAttribute = message.MessageAttributes?._datadog
210-
if (datadogAttribute) {
211-
const parsedAttributes = this.parseDatadogAttributes(datadogAttribute)
212-
if (parsedAttributes) {
213-
return {
214-
datadogContext: this.tracer.extract('text_map', parsedAttributes),
215-
parsedAttributes,
216-
parsedBody,
217-
bodyChecked: true,
218-
}
219-
}
220-
}
221-
222-
// Then the EventBridge envelope (optionally via SNS); see getEventBridgeContext.
223-
const eventBridgeContext = getEventBridgeContext(parsedBody)
224-
if (eventBridgeContext) {
225-
return {
226-
datadogContext: this.tracer.extract('text_map', eventBridgeContext),
227-
parsedAttributes: eventBridgeContext,
228-
parsedBody,
229-
bodyChecked: true,
230-
}
231-
}
232-
233-
return { parsedBody, bodyChecked: true }
236+
const carrier = datadogAttribute ? this.parseDatadogAttributes(datadogAttribute) : undefined
237+
return carrier ?? getEventBridgeContext(parsedBody)
234238
}
235239

236240
parseDatadogAttributes (attributes) {
@@ -247,51 +251,39 @@ class Sqs extends BaseAwsSdkPlugin {
247251
}
248252
}
249253

250-
responseExtractDSMContext (operation, params, response, span, kwargs = {}) {
251-
let { parsedAttributes } = kwargs
252-
const { parsedFirstBody, firstBodyChecked } = kwargs
254+
/**
255+
* @param {string} operation
256+
* @param {{ QueueUrl: string }} params
257+
* @param {{ Messages?: object[] }} response
258+
* @param {import('../../../dd-trace/src/opentracing/span') | null} span
259+
* @param {Array<Record<string, string> | undefined>} [carriers] Per-message carriers already
260+
* parsed by `responseExtract`; reused so each message body is parsed once. When omitted, the
261+
* carriers are parsed here.
262+
*/
263+
responseExtractDSMContext (operation, params, response, span, carriers) {
253264
if (!this.config.dsmEnabled) return
254265
if (operation !== 'receiveMessage') return
255-
if (!response || !response.Messages || !response.Messages[0]) return
266+
if (!response?.Messages?.length) return
256267

268+
const messages = response.Messages
257269
// Only attribute payloadSize to the span when there is a single message.
258-
span = response.Messages.length > 1 ? null : span
270+
span = messages.length > 1 ? null : span
259271

260272
// QueueUrl is the same for the whole receive batch.
261273
const queue = params.QueueUrl.slice(params.QueueUrl.lastIndexOf('/') + 1)
262274

263-
for (let i = 0; i < response.Messages.length; i++) {
264-
let message = response.Messages[i]
265-
if (!parsedAttributes) {
266-
let body
267-
// responseExtract already parsed message[0]; reuse that result instead of re-parsing.
268-
if (i === 0 && firstBodyChecked) {
269-
body = parsedFirstBody
270-
} else if (message.Body) {
271-
try {
272-
body = JSON.parse(message.Body)
273-
} catch {
274-
// SQS to SQS
275-
}
276-
}
277-
// SNS to SQS
278-
if (body?.Type === 'Notification') {
279-
message = body
280-
}
281-
// MessageAttributes for direct SQS/SNS; else the EventBridge envelope.
282-
parsedAttributes = message.MessageAttributes?._datadog
283-
? this.parseDatadogAttributes(message.MessageAttributes._datadog)
284-
: getEventBridgeContext(body)
275+
for (let i = 0; i < messages.length; i++) {
276+
const message = messages[i]
277+
const carrier = carriers === undefined ? this.parseMessageCarrier(message) : carriers[i]
278+
if (carrier) {
279+
// Inert for EventBridge until its producer emits a pathway (separate
280+
// change) — no `dd-pathway-ctx-base64` to decode yet; SQS/SNS decode now.
281+
this.tracer.decodeDataStreamsContext(carrier)
285282
}
286283
const payloadSize = getHeadersSize({
287284
Body: message.Body,
288285
MessageAttributes: message.MessageAttributes,
289286
})
290-
if (parsedAttributes) {
291-
// Inert for EventBridge until its producer emits a pathway (separate
292-
// change) — no `dd-pathway-ctx-base64` to decode yet; SQS/SNS decode now.
293-
this.tracer.decodeDataStreamsContext(parsedAttributes)
294-
}
295287
this.tracer
296288
.setCheckpoint(['direction:in', `topic:${queue}`, 'type:sqs'], span, payloadSize)
297289
}

0 commit comments

Comments
 (0)