Skip to content

Commit 87ecea4

Browse files
authored
[SVLS-7168] Create inferred Span and Span links for GCP PubSub (#6415)
* feat: add producer-side batch message handling with span linking - Collect span links from messages 2-N (first becomes parent) - Extract parent context from first message trace context - Create pubsub.request span with span links metadata - Inject batch metadata into all messages (_dd.pubsub_request.*, _dd.batch.*) - Add 128-bit trace ID support (_dd.p.tid) - Add operation tag for batched vs single requests * feat: add ack context map and producer improvements for batching - Add ack context map to preserve trace context across batched acknowledges - Update producer to use batchSpan._startTime for accurate publish time - Add explicit parent span support in client plugin - Wrap Message.ack() to store context before batched gRPC acknowledge - Update Subscription.emit to properly handle storage context - Sync auto-load improvements from Branch 1 * fix: resolve linting errors in google-cloud-pubsub.js * fix: remove trailing whitespace in client.js * fix comments * feat: add span linking from delivery span to pubsub.request - Add _reconstructPubSubContext to extract pubsub.request span ID from headers - Add span link to original trace context if different from pubsub.request - Supports same-trace parenting for better trace continuity * feat: check for pubsub.delivery span in AsyncLocalStorage before extracting from headers - HTTP plugin now checks if a delivery span is active in storage - If found, uses delivery span as parent for http.request - Ensures proper span hierarchy for push subscriptions * feat: add span linking and batch metadata to pull-based consumer - Extract pubsub.request span ID from message attributes - Add span link correlation tags - Calculate delivery duration from publish start time - Add batch size and index tags for batched messages * feat: add comprehensive span linking for consumer and push subscriptions - Add _reconstructPubSubRequestContext in consumer for proper span reparenting - Reparent first message in batch to pubsub.request span - Add comprehensive batch metadata and correlation tags - Improve resource naming and service separation - Add delivery duration calculation - Update push subscription logging to warn level with better messages - Add missing headers warning for troubleshooting * Remove comments * run npm lint * fix test * fix test * fix test * fix index.js test * fix index.js test * fix context error * fix lint * fix lint * fix lint * fix index.js test * fix plugin * add ctx * test the index * update consumer subscription bindstart * update addhook subscription * add debug logs * Fix consumer span context loss with WeakMap and add comprehensive debug logging * Fix double finish * Fix consumer span type: explicitly set _type='worker' after startSpan * Fix: use span.setTag('span.type', 'worker') instead of span._type * Debug: Add type in startSpan options and via setTag, improve logging * Fix: Use runStores for _dispense and publish for remove with improved logging * add debug logs * Fix: Guarantee test harness loads instrumentation before requiring @google-cloud/pubsub * update subscription * update subscription * test fix for index.spec.js timeout * fix test logic * fix linter * fix linter * remove comments * feat: add producer-side batch message handling with span linking - Collect span links from messages 2-N (first becomes parent) - Extract parent context from first message trace context - Create pubsub.request span with span links metadata - Inject batch metadata into all messages (_dd.pubsub_request.*, _dd.batch.*) - Add 128-bit trace ID support (_dd.p.tid) - Add operation tag for batched vs single requests * feat: add ack context map and producer improvements for batching - Add ack context map to preserve trace context across batched acknowledges - Update producer to use batchSpan._startTime for accurate publish time - Add explicit parent span support in client plugin - Wrap Message.ack() to store context before batched gRPC acknowledge - Update Subscription.emit to properly handle storage context - Sync auto-load improvements from Branch 1 * fix: resolve linting errors in google-cloud-pubsub.js * fix: remove trailing whitespace in client.js * fix comments * update from review * addional cleanup * [SVLS-7168] Create tests for GCP PubSub Push Subscriptions Plugin (#6414) * feat: add producer-side batch message handling with span linking - Collect span links from messages 2-N (first becomes parent) - Extract parent context from first message trace context - Create pubsub.request span with span links metadata - Inject batch metadata into all messages (_dd.pubsub_request.*, _dd.batch.*) - Add 128-bit trace ID support (_dd.p.tid) - Add operation tag for batched vs single requests * feat: add span linking from delivery span to pubsub.request - Add _reconstructPubSubContext to extract pubsub.request span ID from headers - Add span link to original trace context if different from pubsub.request - Supports same-trace parenting for better trace continuity * feat: check for pubsub.delivery span in AsyncLocalStorage before extracting from headers - HTTP plugin now checks if a delivery span is active in storage - If found, uses delivery span as parent for http.request - Ensures proper span hierarchy for push subscriptions * feat: add span linking and batch metadata to pull-based consumer - Extract pubsub.request span ID from message attributes - Add span link correlation tags - Calculate delivery duration from publish start time - Add batch size and index tags for batched messages * remove comments * new test file * Fix push subscription test module resolution for CI * Implement full HTTP+Pub/Sub integration tests * run linter * test other frameworks * Fix push subscription tests
1 parent 75dd311 commit 87ecea4

7 files changed

Lines changed: 716 additions & 116 deletions

File tree

packages/datadog-instrumentations/src/google-cloud-pubsub.js

Lines changed: 65 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,25 @@ const receiveStartCh = channel('apm:google-cloud-pubsub:receive:start')
3030
const receiveFinishCh = channel('apm:google-cloud-pubsub:receive:finish')
3131
const receiveErrorCh = channel('apm:google-cloud-pubsub:receive:error')
3232

33+
// Bounded map to prevent memory leaks from acks that never complete
3334
const ackContextMap = new Map()
35+
const ACK_CONTEXT_MAX_SIZE = 10_000
36+
const ACK_CONTEXT_TTL_MS = 600_000 // 10 minutes - matches Cloud Run streaming pull default deadline
37+
38+
// Cleanup old entries periodically
39+
const ackContextCleanupInterval = setInterval(() => {
40+
const now = Date.now()
41+
for (const [ackId, entry] of ackContextMap.entries()) {
42+
if (now - entry.timestamp > ACK_CONTEXT_TTL_MS) {
43+
ackContextMap.delete(ackId)
44+
}
45+
}
46+
}, 60_000) // Run cleanup every 60 seconds
47+
48+
// Allow process to exit cleanly
49+
if (ackContextCleanupInterval.unref) {
50+
ackContextCleanupInterval.unref()
51+
}
3452

3553
const publisherMethods = [
3654
'createTopic',
@@ -88,18 +106,16 @@ function wrapMethod (method) {
88106
if (isAckOperation && request && request.ackIds && request.ackIds.length > 0) {
89107
// Try to find a stored context for any of these ack IDs
90108
for (const ackId of request.ackIds) {
91-
const storedContext = ackContextMap.get(ackId)
92-
if (storedContext) {
93-
restoredStore = storedContext
109+
const entry = ackContextMap.get(ackId)
110+
if (entry) {
111+
restoredStore = entry.context
94112
break
95113
}
96114
}
97115

98116
if (api === 'acknowledge') {
99117
request.ackIds.forEach(ackId => {
100-
if (ackContextMap.has(ackId)) {
101-
ackContextMap.delete(ackId)
102-
}
118+
ackContextMap.delete(ackId)
103119
})
104120
}
105121
}
@@ -214,7 +230,19 @@ addHook({ name: '@google-cloud/pubsub', versions: ['>=1.2'], file: 'build/src/su
214230
const storeWithSpanContext = { ...currentStore, span: activeSpan }
215231

216232
if (this.ackId) {
217-
ackContextMap.set(this.ackId, storeWithSpanContext)
233+
// Enforce max size to prevent unbounded growth
234+
if (ackContextMap.size >= ACK_CONTEXT_MAX_SIZE) {
235+
// Remove oldest entry (first entry in Map iteration order)
236+
const firstKey = ackContextMap.keys().next().value
237+
if (firstKey !== undefined) {
238+
ackContextMap.delete(firstKey)
239+
}
240+
}
241+
242+
ackContextMap.set(this.ackId, {
243+
context: storeWithSpanContext,
244+
timestamp: Date.now()
245+
})
218246
}
219247
}
220248

@@ -227,24 +255,36 @@ addHook({ name: '@google-cloud/pubsub', versions: ['>=1.2'], file: 'build/src/su
227255

228256
addHook({ name: '@google-cloud/pubsub', versions: ['>=1.2'], file: 'build/src/lease-manager.js' }, (obj) => {
229257
const LeaseManager = obj.LeaseManager
230-
const ctx = {}
258+
if (!LeaseManager) {
259+
return obj
260+
}
261+
262+
const messageContexts = new WeakMap()
231263

232264
shimmer.wrap(LeaseManager.prototype, '_dispense', dispense => function (message) {
233-
if (receiveStartCh.hasSubscribers) {
234-
ctx.message = message
235-
return receiveStartCh.runStores(ctx, dispense, this, ...arguments)
236-
}
237-
return dispense.apply(this, arguments)
265+
const ctx = { message }
266+
messageContexts.set(message, ctx)
267+
268+
return receiveStartCh.runStores(ctx, dispense, this, ...arguments)
238269
})
239270

240271
shimmer.wrap(LeaseManager.prototype, 'remove', remove => function (message) {
272+
const ctx = messageContexts.get(message) || { message }
273+
messageContexts.delete(message)
274+
241275
return receiveFinishCh.runStores(ctx, remove, this, ...arguments)
242276
})
243277

244278
shimmer.wrap(LeaseManager.prototype, 'clear', clear => function () {
245-
for (const message of this._messages) {
246-
ctx.message = message
247-
receiveFinishCh.publish(ctx)
279+
// Finish spans for all messages still in the lease before clearing
280+
if (this._messages) {
281+
for (const message of this._messages.values()) {
282+
const ctx = messageContexts.get(message)
283+
if (ctx) {
284+
receiveFinishCh.publish(ctx)
285+
messageContexts.delete(message)
286+
}
287+
}
248288
}
249289
return clear.apply(this, arguments)
250290
})
@@ -275,19 +315,19 @@ function injectTraceContext (attributes, pubsub, topicName) {
275315
addHook({ name: '@google-cloud/pubsub', versions: ['>=1.2'] }, (obj) => {
276316
if (!obj.Topic?.prototype) return obj
277317

278-
// Wrap Topic.publishMessage (modern API)
279-
if (obj.Topic.prototype.publishMessage) {
280-
shimmer.wrap(obj.Topic.prototype, 'publishMessage', publishMessage => function (data) {
281-
if (data && typeof data === 'object') {
282-
if (!data.attributes) data.attributes = {}
283-
injectTraceContext(data.attributes, this.pubsub, this.name)
318+
if (typeof obj.Topic.prototype.publishMessage === 'function') {
319+
shimmer.wrap(obj.Topic.prototype, 'publishMessage', publishMessage => {
320+
return function (data, attributesOrCallback, callback) {
321+
if (data && typeof data === 'object') {
322+
if (!data.attributes) data.attributes = {}
323+
injectTraceContext(data.attributes, this.pubsub, this.name)
324+
}
325+
return publishMessage.apply(this, arguments)
284326
}
285-
return publishMessage.apply(this, arguments)
286327
})
287328
}
288329

289-
// Wrap Topic.publish (legacy API)
290-
if (obj.Topic.prototype.publish) {
330+
if (typeof obj.Topic.prototype.publish === 'function') {
291331
shimmer.wrap(obj.Topic.prototype, 'publish', publish => function (buffer, attributesOrCallback, callback) {
292332
if (typeof attributesOrCallback === 'function' || !attributesOrCallback) {
293333
arguments[1] = {}

packages/datadog-plugin-google-cloud-pubsub/src/consumer.js

Lines changed: 142 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,30 +2,159 @@
22

33
const { getMessageSize } = require('../../dd-trace/src/datastreams')
44
const ConsumerPlugin = require('../../dd-trace/src/plugins/consumer')
5+
const SpanContext = require('../../dd-trace/src/opentracing/span_context')
6+
const id = require('../../dd-trace/src/id')
57

68
class GoogleCloudPubsubConsumerPlugin extends ConsumerPlugin {
79
static id = 'google-cloud-pubsub'
810
static operation = 'receive'
911

12+
_reconstructPubSubRequestContext (attrs) {
13+
const traceIdLower = attrs['_dd.pubsub_request.trace_id']
14+
const spanId = attrs['_dd.pubsub_request.span_id']
15+
const traceIdUpper = attrs['_dd.p.tid']
16+
17+
if (!traceIdLower || !spanId) return null
18+
19+
try {
20+
const traceId128 = traceIdUpper ? traceIdUpper + traceIdLower : traceIdLower.padStart(32, '0')
21+
const traceId = id(traceId128, 16)
22+
const parentId = id(spanId, 16)
23+
24+
const tags = {}
25+
if (traceIdUpper) tags['_dd.p.tid'] = traceIdUpper
26+
27+
return new SpanContext({
28+
traceId,
29+
spanId: parentId,
30+
tags
31+
})
32+
} catch {
33+
return null
34+
}
35+
}
36+
1037
bindStart (ctx) {
1138
const { message } = ctx
1239
const subscription = message._subscriber._subscription
13-
const topic = subscription.metadata && subscription.metadata.topic
14-
const childOf = this.tracer.extract('text_map', message.attributes) || null
40+
const topic = (subscription.metadata && subscription.metadata.topic) ||
41+
(message.attributes && message.attributes['pubsub.topic']) ||
42+
(message.attributes && message.attributes['gcloud.project_id']
43+
? `projects/${message.attributes['gcloud.project_id']}/topics/unknown`
44+
: null)
45+
46+
const batchRequestTraceId = message.attributes?.['_dd.pubsub_request.trace_id']
47+
const batchRequestSpanId = message.attributes?.['_dd.pubsub_request.span_id']
48+
const batchSize = message.attributes?.['_dd.batch.size']
49+
const batchIndex = message.attributes?.['_dd.batch.index']
50+
51+
let childOf = this.tracer.extract('text_map', message.attributes) || null
52+
53+
const isFirstMessage = batchIndex === '0' || batchIndex === 0
54+
if (isFirstMessage && batchRequestSpanId) {
55+
const pubsubRequestContext = this._reconstructPubSubRequestContext(message.attributes)
56+
if (pubsubRequestContext) {
57+
childOf = pubsubRequestContext
58+
}
59+
}
60+
61+
const topicName = topic ? topic.split('/').pop() : subscription.name.split('/').pop()
62+
const baseService = this.tracer._service || 'unknown'
63+
const serviceName = this.config.service || `${baseService}-pubsub`
64+
const meta = {
65+
'gcloud.project_id': subscription.pubsub.projectId,
66+
'pubsub.topic': topic,
67+
'span.kind': 'consumer',
68+
'pubsub.delivery_method': 'pull',
69+
'pubsub.span_type': 'message_processing',
70+
'messaging.operation': 'receive',
71+
'_dd.base_service': this.tracer._service,
72+
'_dd.serviceoverride.type': 'custom'
73+
}
74+
75+
if (batchRequestTraceId) {
76+
meta['pubsub.batch.request_trace_id'] = batchRequestTraceId
77+
}
78+
if (batchRequestSpanId) {
79+
meta['pubsub.batch.request_span_id'] = batchRequestSpanId
80+
meta['_dd.pubsub_request.trace_id'] = batchRequestTraceId
81+
meta['_dd.pubsub_request.span_id'] = batchRequestSpanId
82+
if (batchRequestTraceId && batchRequestSpanId) {
83+
// Use JSON format like producer for proper span link parsing
84+
meta['_dd.span_links'] = JSON.stringify([{
85+
trace_id: batchRequestTraceId,
86+
span_id: batchRequestSpanId,
87+
flags: 0
88+
}])
89+
}
90+
}
91+
92+
const metrics = {
93+
'pubsub.ack': 0
94+
}
95+
96+
if (batchSize) {
97+
metrics['pubsub.batch.message_count'] = Number.parseInt(batchSize, 10)
98+
metrics['pubsub.batch.size'] = Number.parseInt(batchSize, 10)
99+
}
100+
if (batchIndex !== undefined) {
101+
metrics['pubsub.batch.message_index'] = Number.parseInt(batchIndex, 10)
102+
metrics['pubsub.batch.index'] = Number.parseInt(batchIndex, 10)
103+
}
104+
105+
if (batchSize && batchIndex !== undefined) {
106+
const index = Number.parseInt(batchIndex, 10)
107+
const size = Number.parseInt(batchSize, 10)
108+
meta['pubsub.batch.description'] = `Message ${index + 1} of ${size}`
109+
}
15110

16111
const span = this.startSpan({
17112
childOf,
18-
resource: topic,
113+
resource: `Message from ${topicName}`,
19114
type: 'worker',
20-
meta: {
21-
'gcloud.project_id': subscription.pubsub.projectId,
22-
'pubsub.topic': topic
23-
},
24-
metrics: {
25-
'pubsub.ack': 0
26-
}
115+
service: serviceName,
116+
meta,
117+
metrics
27118
}, ctx)
28119

120+
if (message.id) {
121+
span.setTag('pubsub.message_id', message.id)
122+
}
123+
if (message.publishTime) {
124+
span.setTag('pubsub.publish_time', message.publishTime.toISOString())
125+
}
126+
127+
if (message.attributes) {
128+
const publishStartTime = message.attributes['x-dd-publish-start-time']
129+
if (publishStartTime) {
130+
const deliveryDuration = Date.now() - Number.parseInt(publishStartTime, 10)
131+
span.setTag('pubsub.delivery_duration_ms', deliveryDuration)
132+
}
133+
134+
const pubsubRequestTraceId = message.attributes['_dd.pubsub_request.trace_id']
135+
const pubsubRequestSpanId = message.attributes['_dd.pubsub_request.span_id']
136+
const batchSize = message.attributes['_dd.batch.size']
137+
const batchIndex = message.attributes['_dd.batch.index']
138+
139+
if (pubsubRequestTraceId && pubsubRequestSpanId) {
140+
span.setTag('_dd.pubsub_request.trace_id', pubsubRequestTraceId)
141+
span.setTag('_dd.pubsub_request.span_id', pubsubRequestSpanId)
142+
// Use JSON format like producer for proper span link parsing
143+
span.setTag('_dd.span_links', JSON.stringify([{
144+
trace_id: pubsubRequestTraceId,
145+
span_id: pubsubRequestSpanId,
146+
flags: 0
147+
}]))
148+
}
149+
150+
if (batchSize) {
151+
span.setTag('pubsub.batch.size', Number.parseInt(batchSize, 10))
152+
}
153+
if (batchIndex) {
154+
span.setTag('pubsub.batch.index', Number.parseInt(batchIndex, 10))
155+
}
156+
}
157+
29158
if (this.config.dsmEnabled && message?.attributes) {
30159
const payloadSize = getMessageSize(message)
31160
this.tracer.decodeDataStreamsContext(message.attributes)
@@ -38,14 +167,15 @@ class GoogleCloudPubsubConsumerPlugin extends ConsumerPlugin {
38167

39168
bindFinish (ctx) {
40169
const { message } = ctx
41-
const span = ctx.currentStore.span
170+
const span = ctx.currentStore?.span
171+
172+
if (!span) return ctx.parentStore
42173

43174
if (message?._handled) {
44175
span.setTag('pubsub.ack', 1)
45176
}
46177

47178
super.finish()
48-
49179
return ctx.parentStore
50180
}
51181
}

0 commit comments

Comments
 (0)