Skip to content

Commit ad72846

Browse files
feat(otel): connect durable orchestration spans across worker processes
Persist one orchestration span identity per instance in a shared store so activities on any worker parent correctly, seed metadata from the HTTP span at startNew, and export a single orchestration span when the instance completes instead of one span per replay turn. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 806c888 commit ad72846

10 files changed

Lines changed: 863 additions & 40 deletions

packages/datadog-instrumentations/src/azure-durable-functions.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,18 @@ addHook({ name: 'durable-functions', versions: ['>=3'], patchDefault: false }, (
2525
return df
2626
})
2727

28+
addHook({
29+
name: 'durable-functions',
30+
versions: ['>=3'],
31+
file: 'lib/src/durableClient/DurableClient.js',
32+
}, (durableClientModule) => {
33+
if (require('./helpers/otel-azure-enabled').isOtelAzureInstrumentationEnabled()) {
34+
require('./helpers/otel-orchestration-http-link').patchDurableClient(durableClientModule.DurableClient)
35+
}
36+
37+
return durableClientModule
38+
})
39+
2840
function entityWrapper (method) {
2941
return function (entityName, arg) {
3042
// because this method is overloaded, the second argument can either be an object

packages/datadog-instrumentations/src/helpers/azure-trace-context.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,81 @@ function extractContext (traceContext) {
2323
return api.propagation.extract(ROOT_CONTEXT, carrier, api.defaultTextMapGetter)
2424
}
2525

26+
function getInstanceId (invocationContext) {
27+
const attributes = invocationContext?.traceContext?.attributes
28+
if (!attributes) return
29+
30+
return attributes['durabletask.task.instance_id'] || attributes.DurableFunctionsInstanceId
31+
}
32+
33+
function parentContextFromOrchestrationMeta (meta) {
34+
const { traceContextFromMeta } = require('./otel-orchestration-meta')
35+
return extractContext(traceContextFromMeta(meta))
36+
}
37+
38+
function resolveActivityParentContext (invocationContext) {
39+
const instanceId = getInstanceId(invocationContext)
40+
const traceContext = invocationContext?.traceContext
41+
42+
const { getOrchestrationSpan } = require('./otel-orchestration-registry')
43+
const orchestrationSpan = getOrchestrationSpan(instanceId)
44+
if (orchestrationSpan) {
45+
return api.trace.setSpan(extractContext(traceContext), orchestrationSpan)
46+
}
47+
48+
const { readOrchestrationSpanMetaSync } = require('./otel-orchestration-store')
49+
const meta = readOrchestrationSpanMetaSync(instanceId, traceContext)
50+
if (meta) {
51+
return parentContextFromOrchestrationMeta(meta)
52+
}
53+
54+
return extractContext(traceContext)
55+
}
56+
57+
async function resolveActivityParentContextAsync (invocationContext) {
58+
const instanceId = getInstanceId(invocationContext)
59+
const traceContext = invocationContext?.traceContext
60+
61+
const { getOrchestrationSpan } = require('./otel-orchestration-registry')
62+
const orchestrationSpan = getOrchestrationSpan(instanceId)
63+
if (orchestrationSpan) {
64+
return api.trace.setSpan(extractContext(traceContext), orchestrationSpan)
65+
}
66+
67+
const {
68+
readOrchestrationSpanMetaAsync,
69+
readOrchestrationSpanMetaSync,
70+
} = require('./otel-orchestration-store')
71+
72+
let meta = readOrchestrationSpanMetaSync(instanceId, traceContext)
73+
if (!meta) {
74+
meta = await readOrchestrationSpanMetaAsync(instanceId, traceContext)
75+
}
76+
if (meta) {
77+
return parentContextFromOrchestrationMeta(meta)
78+
}
79+
80+
return extractContext(traceContext)
81+
}
82+
83+
function buildSpanParentContext (args, trigger) {
84+
const invocationContext = getInvocationContext(args, trigger)
85+
if (trigger === 'durable-activity') {
86+
return resolveActivityParentContext(invocationContext)
87+
}
88+
89+
return extractContext(invocationContext?.traceContext)
90+
}
91+
92+
function buildSpanParentContextAsync (args, trigger) {
93+
const invocationContext = getInvocationContext(args, trigger)
94+
if (trigger === 'durable-activity') {
95+
return resolveActivityParentContextAsync(invocationContext)
96+
}
97+
98+
return Promise.resolve(extractContext(invocationContext?.traceContext))
99+
}
100+
26101
function runWithTraceContext (traceContext, fn) {
27102
return api.context.with(extractContext(traceContext), fn)
28103
}
@@ -52,9 +127,14 @@ function runWithInvocationContext (args, trigger, fn) {
52127
}
53128

54129
module.exports = {
130+
buildSpanParentContext,
131+
buildSpanParentContextAsync,
55132
carrierFromTraceContext,
56133
extractContext,
134+
getInstanceId,
57135
getInvocationContext,
136+
parentContextFromOrchestrationMeta,
137+
resolveActivityParentContext,
58138
runWithInvocationContext,
59139
runWithTraceContext,
60140
}

packages/datadog-instrumentations/src/helpers/otel-azure-span.js

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,26 @@ function spanAttributes (functionName, trigger, operationName) {
2525
}
2626
}
2727

28+
function startChildSpan (tracerName, trigger, functionName, operationName, args, parentContext) {
29+
return getTracer(tracerName).startSpan(
30+
`${trigger} ${functionName}`,
31+
{ attributes: spanAttributes(functionName, trigger, operationName) },
32+
parentContext,
33+
)
34+
}
35+
2836
function wrapSyncWithTraceContext (tracerName, trigger, handler, functionName, operationName) {
2937
return function (...args) {
30-
const { runWithInvocationContext } = require('./azure-trace-context')
38+
const { runWithInvocationContext, buildSpanParentContext } = require('./azure-trace-context')
3139
return runWithInvocationContext(args, trigger, () => {
32-
const span = getTracer(tracerName).startSpan(`${trigger} ${functionName}`, {
33-
attributes: spanAttributes(functionName, trigger, operationName),
34-
})
40+
const span = startChildSpan(
41+
tracerName,
42+
trigger,
43+
functionName,
44+
operationName,
45+
args,
46+
buildSpanParentContext(args, trigger),
47+
)
3548
try {
3649
const result = handler.apply(this, args)
3750
endSpan(span)
@@ -46,11 +59,17 @@ function wrapSyncWithTraceContext (tracerName, trigger, handler, functionName, o
4659

4760
function wrapAsyncWithTraceContext (tracerName, trigger, handler, functionName) {
4861
return function (...args) {
49-
const { runWithInvocationContext } = require('./azure-trace-context')
50-
return runWithInvocationContext(args, trigger, () =>
51-
getTracer(tracerName).startActiveSpan(
62+
const {
63+
runWithInvocationContext,
64+
buildSpanParentContextAsync,
65+
} = require('./azure-trace-context')
66+
67+
return runWithInvocationContext(args, trigger, async () => {
68+
const parentContext = await buildSpanParentContextAsync(args, trigger)
69+
return getTracer(tracerName).startActiveSpan(
5270
`${trigger} ${functionName}`,
5371
{ attributes: spanAttributes(functionName, trigger) },
72+
parentContext,
5473
async (span) => {
5574
try {
5675
const result = await handler.apply(this, args)
@@ -61,7 +80,8 @@ function wrapAsyncWithTraceContext (tracerName, trigger, handler, functionName)
6180
throw error
6281
}
6382
},
64-
))
83+
)
84+
})
6585
}
6686
}
6787

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
'use strict'
2+
3+
const api = require('@opentelemetry/api')
4+
const createId = require('../../../dd-trace/src/id')
5+
const DatadogSpanContext = require('../../../dd-trace/src/opentracing/span_context')
6+
const OtelSpan = require('../../../dd-trace/src/opentelemetry/span')
7+
const OtelSpanContext = require('../../../dd-trace/src/opentelemetry/span_context')
8+
const { extractContext } = require('./azure-trace-context')
9+
const { getTracer, spanAttributes, endSpan } = require('./otel-azure-span')
10+
const { normalizeSpanId, normalizeTraceId } = require('./otel-orchestration-meta')
11+
const { resolveHttpParentForOrchestration } = require('./otel-orchestration-http-link')
12+
13+
function getParentFromTraceContext (traceContext) {
14+
const traceParent = traceContext?.traceParent
15+
if (!traceParent) return
16+
17+
const parts = traceParent.split('-')
18+
if (parts.length < 4) return
19+
20+
return {
21+
traceId: normalizeTraceId(parts[1]),
22+
parentId: normalizeSpanId(parts[2]),
23+
}
24+
}
25+
26+
function createOrchestrationMeta (instanceId, invocationContext, functionName) {
27+
const traceContext = invocationContext?.traceContext
28+
const httpParent = resolveHttpParentForOrchestration(instanceId, traceContext)
29+
const fromHeader = getParentFromTraceContext(traceContext)
30+
31+
let traceId = httpParent?.traceId ?? fromHeader?.traceId
32+
let parentId = httpParent?.spanId ?? fromHeader?.parentId
33+
34+
if (!traceId) {
35+
const parentContext = extractContext(traceContext)
36+
const parentSpan = api.trace.getSpan(parentContext)
37+
const parentDdContext = parentSpan?.spanContext()?._ddContext
38+
39+
if (parentDdContext) {
40+
traceId = normalizeTraceId(parentDdContext._traceId)
41+
parentId ??= normalizeSpanId(parentDdContext._spanId)
42+
}
43+
}
44+
45+
if (!traceId) {
46+
traceId = normalizeTraceId(createId())
47+
}
48+
49+
return {
50+
instanceId,
51+
functionName,
52+
traceId,
53+
spanId: normalizeSpanId(createId()),
54+
parentId,
55+
startTime: Date.now(),
56+
status: 'open',
57+
}
58+
}
59+
60+
// Build orchestration metadata from the HTTP span that called `startNew`. The
61+
// orchestration runs later, often in another worker process, so its identity has
62+
// to be decided here while the HTTP span is still known.
63+
function createOrchestrationMetaFromHttpParent (instanceId, httpParent, functionName) {
64+
if (!httpParent?.traceId || !httpParent.spanId) return
65+
66+
return {
67+
instanceId,
68+
functionName,
69+
traceId: normalizeTraceId(httpParent.traceId),
70+
spanId: normalizeSpanId(createId()),
71+
parentId: normalizeSpanId(httpParent.spanId),
72+
httpParentSpanId: normalizeSpanId(httpParent.spanId),
73+
startTime: Date.now(),
74+
// Replaced with the real start time on the first orchestration turn.
75+
pendingStart: true,
76+
status: 'open',
77+
}
78+
}
79+
80+
function exportOrchestrationSpanFromMeta (tracerName, meta, { error, endTime } = {}) {
81+
if (!meta?.traceId || !meta?.spanId) return false
82+
83+
const tracer = getTracer(tracerName)
84+
const ddContext = new DatadogSpanContext({
85+
traceId: createId(meta.traceId, 16),
86+
spanId: createId(meta.spanId, 16),
87+
parentId: meta.parentId ? createId(meta.parentId, 16) : null,
88+
})
89+
90+
const span = new OtelSpan(
91+
tracer,
92+
api.ROOT_CONTEXT,
93+
`orchestration ${meta.functionName || 'orchestration'}`,
94+
new OtelSpanContext(ddContext),
95+
api.SpanKind.INTERNAL,
96+
[],
97+
meta.startTime,
98+
spanAttributes(meta.functionName || 'orchestration', 'durable-orchestration'),
99+
)
100+
101+
if (error) {
102+
endSpan(span, error)
103+
} else {
104+
span.end(endTime ?? Date.now())
105+
}
106+
107+
return true
108+
}
109+
110+
module.exports = {
111+
createOrchestrationMeta,
112+
createOrchestrationMetaFromHttpParent,
113+
exportOrchestrationSpanFromMeta,
114+
getParentFromTraceContext,
115+
}

0 commit comments

Comments
 (0)