@aws/durable-execution-sdk-js-otel 0.1.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +523 -175
- package/dist/execution-plugin.mjs +539 -0
- package/dist/execution-plugin.mjs.map +1 -0
- package/dist/global-id-generator.mjs +648 -0
- package/dist/global-id-generator.mjs.map +1 -0
- package/dist/index.mjs +8 -537
- package/dist/index.mjs.map +1 -1
- package/dist/invocation-plugin.mjs +661 -0
- package/dist/invocation-plugin.mjs.map +1 -0
- package/dist/otel-execution.mjs +15 -0
- package/dist/otel-execution.mjs.map +1 -0
- package/dist/otel-invocation.mjs +16 -0
- package/dist/otel-invocation.mjs.map +1 -0
- package/dist-cjs/execution-plugin.js +541 -0
- package/dist-cjs/execution-plugin.js.map +1 -0
- package/dist-cjs/global-id-generator.js +664 -0
- package/dist-cjs/global-id-generator.js.map +1 -0
- package/dist-cjs/index.js +19 -540
- package/dist-cjs/index.js.map +1 -1
- package/dist-cjs/invocation-plugin.js +663 -0
- package/dist-cjs/invocation-plugin.js.map +1 -0
- package/dist-cjs/otel-execution.js +17 -0
- package/dist-cjs/otel-execution.js.map +1 -0
- package/dist-cjs/otel-invocation.js +18 -0
- package/dist-cjs/otel-invocation.js.map +1 -0
- package/dist-types/context-extractors.d.ts +70 -2
- package/dist-types/context-extractors.d.ts.map +1 -1
- package/dist-types/deterministic-id-generator.d.ts +56 -31
- package/dist-types/deterministic-id-generator.d.ts.map +1 -1
- package/dist-types/execution-plugin-provider.d.ts +7 -0
- package/dist-types/execution-plugin-provider.d.ts.map +1 -0
- package/dist-types/execution-plugin.d.ts +78 -0
- package/dist-types/execution-plugin.d.ts.map +1 -0
- package/dist-types/execution-trace-context.d.ts +131 -0
- package/dist-types/execution-trace-context.d.ts.map +1 -0
- package/dist-types/global-id-generator.d.ts +12 -0
- package/dist-types/global-id-generator.d.ts.map +1 -0
- package/dist-types/global-sampler.d.ts +25 -0
- package/dist-types/global-sampler.d.ts.map +1 -0
- package/dist-types/index.d.ts +6 -3
- package/dist-types/index.d.ts.map +1 -1
- package/dist-types/invocation-plugin-provider.d.ts +7 -0
- package/dist-types/invocation-plugin-provider.d.ts.map +1 -0
- package/dist-types/invocation-plugin.d.ts +89 -0
- package/dist-types/invocation-plugin.d.ts.map +1 -0
- package/dist-types/otel-plugin-config.d.ts +66 -0
- package/dist-types/otel-plugin-config.d.ts.map +1 -0
- package/dist-types/otel-plugin-provider.d.ts +21 -0
- package/dist-types/otel-plugin-provider.d.ts.map +1 -0
- package/package.json +26 -15
- package/dist-types/plugin.d.ts +0 -46
- package/dist-types/plugin.d.ts.map +0 -1
- package/dist-types/tsconfig.build.tsbuildinfo +0 -1
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
import { trace, SpanKind, context, SpanStatusCode, ROOT_CONTEXT, isSpanContextValid, TraceFlags } from '@opentelemetry/api';
|
|
2
|
+
import { hrTime } from '@opentelemetry/core';
|
|
3
|
+
import { SamplingDecision } from '@opentelemetry/sdk-trace-node';
|
|
4
|
+
import { D as DeterministicIdGenerator, x as xRayContextExtractor, g as createTracerProvider, t as tryInstallDurableSampler, h as tryInstallGlobalIdGenerator, i as canonicalTraceId, r as resolveExecutionTraceContext, f as deriveWorkflowSpanId, b as deriveSpanIdFromOperationId, j as rootSamplingDecision } from './global-id-generator.mjs';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_INSTRUMENTATION_NAME = "aws-durable-execution-sdk-js";
|
|
7
|
+
/**
|
|
8
|
+
* OpenTelemetry instrumentation plugin for durable executions.
|
|
9
|
+
*
|
|
10
|
+
* Implements the DurableInstrumentationPlugin interface to emit distributed
|
|
11
|
+
* traces that correlate across multiple Lambda invocations of a single
|
|
12
|
+
* durable execution.
|
|
13
|
+
*/
|
|
14
|
+
class InvocationOtelPlugin {
|
|
15
|
+
tracerProvider;
|
|
16
|
+
tracer;
|
|
17
|
+
idGenerator;
|
|
18
|
+
contextExtractor;
|
|
19
|
+
instrumentationName;
|
|
20
|
+
usesGlobalProvider;
|
|
21
|
+
globalIdGeneratorInstalled;
|
|
22
|
+
durableSampler;
|
|
23
|
+
tracingEnabled = false;
|
|
24
|
+
workflowSpanName;
|
|
25
|
+
enrichLogger;
|
|
26
|
+
// Per-invocation state
|
|
27
|
+
spanMap = new Map();
|
|
28
|
+
spanStack = [];
|
|
29
|
+
invocationSpan;
|
|
30
|
+
// workflowSpan is a NON-RECORDING context carrying the deterministic Workflow
|
|
31
|
+
// identity for links; the one real recording span is created and ended only at
|
|
32
|
+
// the terminal invocation (see onInvocationEnd), so a Workflow span that spans
|
|
33
|
+
// many invocations is never left un-ended (issue #831).
|
|
34
|
+
workflowSpan;
|
|
35
|
+
// The execution ancestor the terminal Workflow span parents onto. Resolved at
|
|
36
|
+
// invocation start and reused when the real span is created at terminal.
|
|
37
|
+
executionAncestor;
|
|
38
|
+
// The backend execution start, captured at invocation start so the terminal
|
|
39
|
+
// Workflow span can be backdated to it; the now() fallback is taken here (not
|
|
40
|
+
// at onInvocationEnd, where it would land after the span's own end time).
|
|
41
|
+
executionStartTimestamp;
|
|
42
|
+
executionArn = "";
|
|
43
|
+
executionTraceId = "";
|
|
44
|
+
executionTraceFlags = 0;
|
|
45
|
+
executionSamplingDecision = SamplingDecision.NOT_RECORD;
|
|
46
|
+
constructor(config) {
|
|
47
|
+
const instrumentationName = config?.instrumentationName ?? DEFAULT_INSTRUMENTATION_NAME;
|
|
48
|
+
this.instrumentationName = instrumentationName;
|
|
49
|
+
this.idGenerator = new DeterministicIdGenerator();
|
|
50
|
+
this.contextExtractor = config?.contextExtractor ?? xRayContextExtractor;
|
|
51
|
+
this.workflowSpanName = config?.workflowSpanName ?? "Workflow";
|
|
52
|
+
this.enrichLogger = config?.enrichLogger ?? true;
|
|
53
|
+
const { tracerProvider, usesGlobalProvider } = createTracerProvider(config, this.idGenerator);
|
|
54
|
+
this.tracerProvider = tracerProvider;
|
|
55
|
+
this.usesGlobalProvider = usesGlobalProvider;
|
|
56
|
+
this.tracer = this.tracerProvider.getTracer(instrumentationName);
|
|
57
|
+
this.durableSampler = tryInstallDurableSampler(this.tracer);
|
|
58
|
+
this.globalIdGeneratorInstalled = !this.usesGlobalProvider;
|
|
59
|
+
if (this.usesGlobalProvider) {
|
|
60
|
+
const installedIdGenerator = tryInstallGlobalIdGenerator(this.tracer);
|
|
61
|
+
if (installedIdGenerator) {
|
|
62
|
+
this.idGenerator = installedIdGenerator;
|
|
63
|
+
this.globalIdGeneratorInstalled = true;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async onInvocationStart(info) {
|
|
68
|
+
this.resetInvocationState();
|
|
69
|
+
this.tracingEnabled = this.ensureGlobalIdGeneratorInstalled();
|
|
70
|
+
if (!this.tracingEnabled) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
// 1. Store the execution ARN
|
|
74
|
+
this.executionArn = info.executionArn;
|
|
75
|
+
// 2. Invoke the context extractor
|
|
76
|
+
const extractedContext = this.contextExtractor(info);
|
|
77
|
+
// 3. Resolve the one execution ancestor both the Workflow and Invocation
|
|
78
|
+
// spans parent onto so they share a single execution trace. The canonical
|
|
79
|
+
// trace ID is the propagated remote trace when valid, else one derived from
|
|
80
|
+
// the ARN and start time. The execution ancestor is a complete remote
|
|
81
|
+
// parent, else a synthetic execution root. A live ambient span is not used
|
|
82
|
+
// as the ancestor (its trace is not stable across reinvocations); the
|
|
83
|
+
// Invocation span may still parent onto a same-trace ambient span below.
|
|
84
|
+
const canonical = canonicalTraceId(extractedContext, info.executionArn, info.executionStartTimestamp);
|
|
85
|
+
const execTraceContext = resolveExecutionTraceContext(extractedContext, canonical, info.executionArn, () => rootSamplingDecision(this.tracer, canonical, this.workflowSpanName, {
|
|
86
|
+
"durable.execution.arn": info.executionArn,
|
|
87
|
+
}));
|
|
88
|
+
this.executionTraceId = canonical;
|
|
89
|
+
this.executionTraceFlags = execTraceContext.traceFlags;
|
|
90
|
+
this.executionSamplingDecision = execTraceContext.samplingDecision;
|
|
91
|
+
// 4. Resolve the Workflow span identity as a NON-RECORDING context.
|
|
92
|
+
//
|
|
93
|
+
// The Workflow span joins the execution trace (parented onto the execution
|
|
94
|
+
// ancestor) with a deterministic span ID derived from the ARN, so every
|
|
95
|
+
// invocation of the same durable execution shares one root. But a single
|
|
96
|
+
// execution spans many invocations, so only the terminal one can complete
|
|
97
|
+
// it: carrying a real recording span across invocations would leak it
|
|
98
|
+
// un-ended (issue #831), and ending one per invocation would export
|
|
99
|
+
// duplicate (traceId, spanId). Its identity is therefore carried by a
|
|
100
|
+
// non-recording context here — operation and attempt spans link to it (see
|
|
101
|
+
// workflowLinks) while staying parented to the per-invocation span — and the
|
|
102
|
+
// single real span is created and ended once, at the terminal invocation
|
|
103
|
+
// (see onInvocationEnd). It carries the execution sampled bit so a
|
|
104
|
+
// parent-based sampler stays consistent with the eventual root.
|
|
105
|
+
const workflowSpanId = deriveWorkflowSpanId(info.executionArn);
|
|
106
|
+
this.executionAncestor = execTraceContext.executionAncestor;
|
|
107
|
+
this.executionStartTimestamp =
|
|
108
|
+
info.executionStartTimestamp ??
|
|
109
|
+
this.executionStartTimestamp ??
|
|
110
|
+
new Date();
|
|
111
|
+
this.workflowSpan = trace.wrapSpanContext({
|
|
112
|
+
traceId: this.executionTraceId,
|
|
113
|
+
spanId: workflowSpanId,
|
|
114
|
+
traceFlags: this.executionTraceFlags,
|
|
115
|
+
isRemote: false,
|
|
116
|
+
});
|
|
117
|
+
// 5. Create the invocation span parented onto the same-trace ambient span
|
|
118
|
+
// when available (under ADOT/X-Ray the active context at
|
|
119
|
+
// onInvocationStart is the Lambda execution-environment span), otherwise
|
|
120
|
+
// onto the execution ancestor so it stays within the execution trace.
|
|
121
|
+
// The invocation span is intentionally NOT a child of the Workflow span:
|
|
122
|
+
// this plugin stays invocation-rooted. Execution-scoped correlation to
|
|
123
|
+
// the Workflow span is expressed via links on the operation/attempt
|
|
124
|
+
// spans (see onOperationStart / onOperationAttemptStart), matching the
|
|
125
|
+
// Java and Python reference plugins.
|
|
126
|
+
const invocationParentContext = this.invocationParentContext(execTraceContext.executionAncestor, canonical);
|
|
127
|
+
this.invocationSpan = this.startSpan("Invocation", {
|
|
128
|
+
kind: SpanKind.INTERNAL,
|
|
129
|
+
attributes: {
|
|
130
|
+
"durable.execution.arn": info.executionArn,
|
|
131
|
+
"durable.invocation.first": info.isFirstInvocation,
|
|
132
|
+
},
|
|
133
|
+
startTime: hrTime(),
|
|
134
|
+
}, invocationParentContext);
|
|
135
|
+
}
|
|
136
|
+
wrapInvocation(_info, fn) {
|
|
137
|
+
if (!this.tracingEnabled || !this.invocationSpan) {
|
|
138
|
+
return fn();
|
|
139
|
+
}
|
|
140
|
+
return context.with(trace.setSpan(context.active(), this.invocationSpan), fn);
|
|
141
|
+
}
|
|
142
|
+
async onInvocationEnd(info) {
|
|
143
|
+
if (!this.tracingEnabled) {
|
|
144
|
+
this.resetInvocationState();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const endTime = hrTime();
|
|
148
|
+
// 1. End all spans in the stack in reverse order
|
|
149
|
+
while (this.spanStack.length > 0) {
|
|
150
|
+
const span = this.spanStack.pop();
|
|
151
|
+
span.end(endTime);
|
|
152
|
+
}
|
|
153
|
+
// 1b. End any still-recording span left in spanMap (attempt spans, which are
|
|
154
|
+
// not on the stack; operation spans were unwound above). Safeguard against a
|
|
155
|
+
// leak on a non-terminal invocation (issue #831). isRecording() skips spans
|
|
156
|
+
// already ended by the stack unwind.
|
|
157
|
+
for (const span of this.spanMap.values()) {
|
|
158
|
+
if (span.isRecording()) {
|
|
159
|
+
span.end(endTime);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// 2. End the invocation span if it exists
|
|
163
|
+
if (this.invocationSpan) {
|
|
164
|
+
this.invocationSpan.setAttribute("durable.invocation.status", info.status);
|
|
165
|
+
// Map PluginInvocationStatus to span status:
|
|
166
|
+
// FAILED -> ERROR
|
|
167
|
+
// SUCCEEDED / PENDING -> OK (PENDING is a normal suspension, not an error)
|
|
168
|
+
// RETRYING -> UNSET. The plugin interface cannot distinguish a STOPPED or
|
|
169
|
+
// TIMED_OUT invocation from a RETRYING one at onInvocationEnd, so RETRYING
|
|
170
|
+
// is intentionally left UNSET rather than reported as ERROR.
|
|
171
|
+
if (info.status === "FAILED") {
|
|
172
|
+
this.invocationSpan.setStatus({
|
|
173
|
+
code: SpanStatusCode.ERROR,
|
|
174
|
+
message: info.executionError?.message ?? "Execution failed",
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
else if (info.status === "SUCCEEDED" || info.status === "PENDING") {
|
|
178
|
+
this.invocationSpan.setStatus({ code: SpanStatusCode.OK });
|
|
179
|
+
}
|
|
180
|
+
// RETRYING: leave status UNSET (default)
|
|
181
|
+
this.invocationSpan.end(endTime);
|
|
182
|
+
}
|
|
183
|
+
// 3. Terminal invocation ONLY: create and end the one real Workflow span,
|
|
184
|
+
// so a single span ever claims the deterministic (traceId, spanId) and no
|
|
185
|
+
// span is left un-ended on a non-terminal invocation (issue #831). It is
|
|
186
|
+
// parented onto the execution ancestor (the join-trace model) and
|
|
187
|
+
// backdated to the execution start captured at invocation start. Skipped
|
|
188
|
+
// when the execution decision was not sampled, so a dropped execution
|
|
189
|
+
// does not export a lone root. PluginInvocationStatus collapses
|
|
190
|
+
// TIMED_OUT/STOPPED into FAILED -> ERROR.
|
|
191
|
+
if (this.executionSamplingDecision === SamplingDecision.RECORD_AND_SAMPLED &&
|
|
192
|
+
this.executionAncestor &&
|
|
193
|
+
(info.status === "SUCCEEDED" || info.status === "FAILED")) {
|
|
194
|
+
const workflowSpanId = deriveWorkflowSpanId(this.executionArn);
|
|
195
|
+
const executionAncestorContext = trace.setSpanContext(ROOT_CONTEXT, this.executionAncestor);
|
|
196
|
+
const workflowSpan = this.idGenerator.withIds({ spanId: workflowSpanId }, () => this.startSpan(this.workflowSpanName, {
|
|
197
|
+
kind: SpanKind.INTERNAL,
|
|
198
|
+
attributes: {
|
|
199
|
+
"durable.execution.arn": this.executionArn,
|
|
200
|
+
},
|
|
201
|
+
startTime: this.executionStartTimestamp ?? new Date(),
|
|
202
|
+
}, executionAncestorContext));
|
|
203
|
+
workflowSpan.setAttribute("durable.execution.status", info.status);
|
|
204
|
+
if (info.status === "FAILED") {
|
|
205
|
+
workflowSpan.setStatus({
|
|
206
|
+
code: SpanStatusCode.ERROR,
|
|
207
|
+
message: info.executionError?.message ?? "Execution failed",
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
workflowSpan.setStatus({ code: SpanStatusCode.OK });
|
|
212
|
+
}
|
|
213
|
+
workflowSpan.end(endTime);
|
|
214
|
+
}
|
|
215
|
+
// Non-terminal (PENDING/RETRYING): no real Workflow span is created, so
|
|
216
|
+
// nothing to end — the identity was only ever a non-recording context.
|
|
217
|
+
// 4. Force flush the tracer provider
|
|
218
|
+
if ("forceFlush" in this.tracerProvider) {
|
|
219
|
+
try {
|
|
220
|
+
await this.tracerProvider.forceFlush();
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
// Gracefully handle flush errors
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// 5. Clear all per-invocation state
|
|
227
|
+
this.resetInvocationState();
|
|
228
|
+
}
|
|
229
|
+
ensureGlobalIdGeneratorInstalled() {
|
|
230
|
+
if (!this.usesGlobalProvider || this.globalIdGeneratorInstalled) {
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
// A plugin constructed before zero-code instrumentation is registered sees
|
|
234
|
+
// a ProxyTracer without the SDK's ID generator. Resolve the global provider
|
|
235
|
+
// again at invocation start, after preload initialization has completed.
|
|
236
|
+
this.tracerProvider = trace.getTracerProvider();
|
|
237
|
+
this.tracer = this.tracerProvider.getTracer(this.instrumentationName);
|
|
238
|
+
this.durableSampler = tryInstallDurableSampler(this.tracer);
|
|
239
|
+
const installedIdGenerator = tryInstallGlobalIdGenerator(this.tracer);
|
|
240
|
+
if (installedIdGenerator) {
|
|
241
|
+
this.idGenerator = installedIdGenerator;
|
|
242
|
+
this.globalIdGeneratorInstalled = true;
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
console.warn("[InvocationOtelPlugin] Expected a compatible OpenTelemetry SDK tracer at invocation start; telemetry is disabled for this invocation. Ensure the OpenTelemetry SDK is configured before invocation start.");
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
resetInvocationState() {
|
|
249
|
+
this.spanMap.clear();
|
|
250
|
+
this.spanStack = [];
|
|
251
|
+
this.invocationSpan = undefined;
|
|
252
|
+
this.workflowSpan = undefined;
|
|
253
|
+
this.executionAncestor = undefined;
|
|
254
|
+
this.executionStartTimestamp = undefined;
|
|
255
|
+
this.executionArn = "";
|
|
256
|
+
this.executionTraceId = "";
|
|
257
|
+
this.executionTraceFlags = 0;
|
|
258
|
+
this.executionSamplingDecision = SamplingDecision.NOT_RECORD;
|
|
259
|
+
this.tracingEnabled = false;
|
|
260
|
+
}
|
|
261
|
+
startSpan(name, options, parentContext) {
|
|
262
|
+
const fn = () => this.tracer.startSpan(name, options, parentContext);
|
|
263
|
+
return this.durableSampler
|
|
264
|
+
? this.durableSampler.withDecision(this.executionSamplingDecision, fn)
|
|
265
|
+
: fn();
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* The parent context for the Invocation span: the active ambient span when it
|
|
269
|
+
* is on the execution trace, otherwise the execution ancestor so the
|
|
270
|
+
* Invocation span stays within the same trace.
|
|
271
|
+
*/
|
|
272
|
+
invocationParentContext(executionAncestor, canonical) {
|
|
273
|
+
const activeContext = context.active();
|
|
274
|
+
const ambient = trace.getSpanContext(activeContext);
|
|
275
|
+
// Adopt the ambient span as the Invocation parent only when it is on the
|
|
276
|
+
// execution trace AND carries the same sampled bit as the execution
|
|
277
|
+
// ancestor. Matching the trace ID alone is not enough: if the ambient span's
|
|
278
|
+
// sampled bit differs, the Invocation span would inherit the ambient
|
|
279
|
+
// decision and could export after an explicit Sampled=0, or drop after the
|
|
280
|
+
// root sampler chose sampled. On a mismatch, fall back to the execution
|
|
281
|
+
// ancestor, which carries the authoritative decision.
|
|
282
|
+
if (ambient &&
|
|
283
|
+
isSpanContextValid(ambient) &&
|
|
284
|
+
ambient.traceId === canonical &&
|
|
285
|
+
(ambient.traceFlags & TraceFlags.SAMPLED) ===
|
|
286
|
+
(executionAncestor.traceFlags & TraceFlags.SAMPLED)) {
|
|
287
|
+
return activeContext;
|
|
288
|
+
}
|
|
289
|
+
return trace.setSpanContext(ROOT_CONTEXT, executionAncestor);
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* A link to the initial logical operation span, whose span ID is
|
|
293
|
+
* deterministic on (executionArn, operationId) and lives on the execution
|
|
294
|
+
* trace. A continuation or replay segment carries this link so the segments
|
|
295
|
+
* of one logical operation stay correlated across invocations.
|
|
296
|
+
*
|
|
297
|
+
* Because the initial operation span's ID is reproducible, this restores the
|
|
298
|
+
* cross-invocation link that was previously dropped as "fabricated" — it now
|
|
299
|
+
* targets a genuinely exported span (open operation spans are ended on
|
|
300
|
+
* non-terminal invocations; see onInvocationEnd).
|
|
301
|
+
*/
|
|
302
|
+
initialOperationLink(operationId) {
|
|
303
|
+
if (!this.executionTraceId) {
|
|
304
|
+
return undefined;
|
|
305
|
+
}
|
|
306
|
+
const initialContext = {
|
|
307
|
+
traceId: this.executionTraceId,
|
|
308
|
+
spanId: deriveSpanIdFromOperationId(operationId, this.executionArn),
|
|
309
|
+
traceFlags: this.executionTraceFlags,
|
|
310
|
+
isRemote: false,
|
|
311
|
+
};
|
|
312
|
+
return { context: initialContext };
|
|
313
|
+
}
|
|
314
|
+
async onOperationStart(info) {
|
|
315
|
+
if (!this.tracingEnabled) {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
// Same-invocation replay of an operation we already started: the span
|
|
319
|
+
// already exists (e.g. a child context that is replayed after an internal
|
|
320
|
+
// step retry, or a retried step). Reuse it rather than emitting a duplicate
|
|
321
|
+
// span — the existing span is finalized at onOperationEnd. A genuine
|
|
322
|
+
// cross-invocation continuation has no span in the map and still creates
|
|
323
|
+
// its link span below.
|
|
324
|
+
if (info.isReplay && this.spanMap.has(info.id)) {
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
// Operation, continuation, and attempt spans use explicit monotonic
|
|
328
|
+
// timestamps rather than durable start/end timestamps. Durable timestamps
|
|
329
|
+
// can predate this invocation, while letting each span use the tracer's
|
|
330
|
+
// default timestamp can give it an independent wall-clock anchor. A shared
|
|
331
|
+
// monotonic clock keeps children inside their parent timing envelopes.
|
|
332
|
+
// Only the parentless Workflow span keeps a backdated start.
|
|
333
|
+
const deterministicSpanId = deriveSpanIdFromOperationId(info.id, this.executionArn);
|
|
334
|
+
const spanName = info.name ?? info.type;
|
|
335
|
+
// Resolve parent span: use parentId from map, or fall back to invocation span
|
|
336
|
+
let parentSpan;
|
|
337
|
+
if (info.parentId && this.spanMap.has(info.parentId)) {
|
|
338
|
+
parentSpan = this.spanMap.get(info.parentId);
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
parentSpan = this.invocationSpan;
|
|
342
|
+
}
|
|
343
|
+
const parentContext = parentSpan
|
|
344
|
+
? trace.setSpan(context.active(), parentSpan)
|
|
345
|
+
: context.active();
|
|
346
|
+
const attributes = {
|
|
347
|
+
"durable.execution.arn": this.executionArn,
|
|
348
|
+
"durable.operation.id": info.id,
|
|
349
|
+
"durable.operation.type": info.type,
|
|
350
|
+
// Operations are STARTED when their span is created. Any operation that
|
|
351
|
+
// reaches a terminal status this invocation (STEP / WAIT /
|
|
352
|
+
// CHAINED_INVOKE / CALLBACK / CONTEXT) overwrites this with that terminal
|
|
353
|
+
// status at onOperationEnd. Only operations that suspend and are not
|
|
354
|
+
// resumed in this invocation keep STARTED.
|
|
355
|
+
"durable.operation.status": "STARTED",
|
|
356
|
+
};
|
|
357
|
+
if (info.name) {
|
|
358
|
+
attributes["durable.operation.name"] = info.name;
|
|
359
|
+
}
|
|
360
|
+
if (info.subType) {
|
|
361
|
+
attributes["durable.operation.subtype"] = info.subType;
|
|
362
|
+
}
|
|
363
|
+
let span;
|
|
364
|
+
if (!info.isReplay) {
|
|
365
|
+
// Non-replay: use deterministic span ID
|
|
366
|
+
span = this.idGenerator.withIds({
|
|
367
|
+
traceId: this.executionTraceId,
|
|
368
|
+
spanId: deterministicSpanId,
|
|
369
|
+
}, () => this.startSpan(spanName, {
|
|
370
|
+
attributes,
|
|
371
|
+
links: this.workflowLinks(),
|
|
372
|
+
startTime: hrTime(),
|
|
373
|
+
}, parentContext));
|
|
374
|
+
}
|
|
375
|
+
else if (info.type === "CONTEXT" || info.type === "STEP") {
|
|
376
|
+
// This replay segment is a distinct span of an operation whose initial
|
|
377
|
+
// span ran in an earlier invocation. It uses a new (random) span ID, links
|
|
378
|
+
// to the reproducible Workflow span, and links back to the initial logical
|
|
379
|
+
// operation span (deterministic on the execution trace) so the segments
|
|
380
|
+
// stay correlated across invocations.
|
|
381
|
+
//
|
|
382
|
+
// WaitForCondition is modeled differently by the OTel conformance
|
|
383
|
+
// contract: the resumed operation span keeps only its Workflow link, while
|
|
384
|
+
// the non-terminal first polling attempt links back to the first operation
|
|
385
|
+
// span once it is known to have completed successfully.
|
|
386
|
+
span = this.startSpan(spanName, {
|
|
387
|
+
attributes,
|
|
388
|
+
links: info.subType === "WaitForCondition"
|
|
389
|
+
? this.workflowLinks()
|
|
390
|
+
: this.replayLinks(info.id),
|
|
391
|
+
startTime: hrTime(),
|
|
392
|
+
}, parentContext);
|
|
393
|
+
}
|
|
394
|
+
// Push to map and stack
|
|
395
|
+
if (span != null) {
|
|
396
|
+
this.spanMap.set(info.id, span);
|
|
397
|
+
this.spanStack.push(span);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
wrapChildContextFn(info, fn) {
|
|
401
|
+
if (!this.tracingEnabled) {
|
|
402
|
+
return fn();
|
|
403
|
+
}
|
|
404
|
+
const span = this.spanMap.get(info.id);
|
|
405
|
+
if (!span) {
|
|
406
|
+
return fn();
|
|
407
|
+
}
|
|
408
|
+
return context.with(trace.setSpan(context.active(), span), fn);
|
|
409
|
+
}
|
|
410
|
+
async onOperationEnd(info) {
|
|
411
|
+
if (!this.tracingEnabled) {
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
// Skip span creation for WAIT, INVOKE, CHAINED_INVOKE, and CALLBACK operations on replay
|
|
415
|
+
if (info.isReplay &&
|
|
416
|
+
(info.type === "WAIT" ||
|
|
417
|
+
info.type === "INVOKE" ||
|
|
418
|
+
info.type === "CHAINED_INVOKE" ||
|
|
419
|
+
info.type === "CALLBACK")) {
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (this.spanMap.has(info.id)) {
|
|
423
|
+
// Operation was started in this invocation
|
|
424
|
+
const span = this.spanMap.get(info.id);
|
|
425
|
+
// Finalize the operation status from the core-supplied terminal status.
|
|
426
|
+
// Container CONTEXT ops receive a terminal status from the core too:
|
|
427
|
+
// run-in-child-context-handler passes SUCCEEDED/FAILED on both the
|
|
428
|
+
// virtual and non-virtual paths (parallel/map containers route through
|
|
429
|
+
// the same handler). Operations that suspend never reach here this
|
|
430
|
+
// invocation and keep the STARTED stamped at span start.
|
|
431
|
+
if (info.status) {
|
|
432
|
+
span.setAttribute("durable.operation.status", info.status);
|
|
433
|
+
}
|
|
434
|
+
// Set durable.attempt.number for STEP and WAIT_FOR_CONDITION operations
|
|
435
|
+
if ((info.type === "STEP" || info.subType === "WAIT_FOR_CONDITION") &&
|
|
436
|
+
info.attempt != null) {
|
|
437
|
+
span.setAttribute("durable.attempt.number", info.attempt);
|
|
438
|
+
}
|
|
439
|
+
// Record error if present; otherwise stamp explicit OK ONLY on a
|
|
440
|
+
// SUCCEEDED terminal status (matches Python OTel plugin #604). Only
|
|
441
|
+
// reached for operations that terminate this invocation — suspended ops
|
|
442
|
+
// early-return above and keep their STARTED status UNSET. Terminal
|
|
443
|
+
// FAILURE statuses (TIMED_OUT/STOPPED/FAILED/CANCELLED) can arrive with
|
|
444
|
+
// NO error object (callback-timeout, chained-invoke fast paths); those
|
|
445
|
+
// must NOT be labelled OK, so they are left UNSET.
|
|
446
|
+
if (info.error) {
|
|
447
|
+
span.setStatus({
|
|
448
|
+
code: SpanStatusCode.ERROR,
|
|
449
|
+
message: info.error.message,
|
|
450
|
+
});
|
|
451
|
+
span.recordException(info.error);
|
|
452
|
+
}
|
|
453
|
+
else if (info.status === "SUCCEEDED") {
|
|
454
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
455
|
+
}
|
|
456
|
+
// End the span
|
|
457
|
+
span.end(hrTime());
|
|
458
|
+
// Remove from map
|
|
459
|
+
this.spanMap.delete(info.id);
|
|
460
|
+
// Remove from stack (find by reference)
|
|
461
|
+
const stackIndex = this.spanStack.indexOf(span);
|
|
462
|
+
if (stackIndex !== -1) {
|
|
463
|
+
this.spanStack.splice(stackIndex, 1);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
else if (!info.isReplay) {
|
|
467
|
+
// Operation was started in a prior invocation — create Continuation_Span
|
|
468
|
+
const spanName = info.name ?? info.type;
|
|
469
|
+
// Resolve parent span: use parentId from map (e.g. the CONTEXT span for
|
|
470
|
+
// child operations inside waitForCallback), or fall back to invocation span.
|
|
471
|
+
let continuationParentSpan;
|
|
472
|
+
if (info.parentId && this.spanMap.has(info.parentId)) {
|
|
473
|
+
continuationParentSpan = this.spanMap.get(info.parentId);
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
continuationParentSpan = this.invocationSpan;
|
|
477
|
+
}
|
|
478
|
+
const parentContext = continuationParentSpan
|
|
479
|
+
? trace.setSpan(context.active(), continuationParentSpan)
|
|
480
|
+
: context.active();
|
|
481
|
+
const attributes = {
|
|
482
|
+
"durable.execution.arn": this.executionArn,
|
|
483
|
+
"durable.operation.id": info.id,
|
|
484
|
+
"durable.operation.type": info.type,
|
|
485
|
+
};
|
|
486
|
+
if (info.name) {
|
|
487
|
+
attributes["durable.operation.name"] = info.name;
|
|
488
|
+
}
|
|
489
|
+
if (info.subType) {
|
|
490
|
+
attributes["durable.operation.subtype"] = info.subType;
|
|
491
|
+
}
|
|
492
|
+
if (info.status) {
|
|
493
|
+
attributes["durable.operation.status"] = info.status;
|
|
494
|
+
}
|
|
495
|
+
// Set durable.attempt.number for STEP and WAIT_FOR_CONDITION operations
|
|
496
|
+
if ((info.type === "STEP" || info.subType === "WAIT_FOR_CONDITION") &&
|
|
497
|
+
info.attempt != null) {
|
|
498
|
+
attributes["durable.attempt.number"] = info.attempt;
|
|
499
|
+
}
|
|
500
|
+
const continuationSpan = this.startSpan(spanName, {
|
|
501
|
+
attributes,
|
|
502
|
+
// This continuation segment completes an operation whose initial span
|
|
503
|
+
// ran in an earlier invocation. It links to the reproducible Workflow
|
|
504
|
+
// span and back to the initial logical operation span, whose ID is
|
|
505
|
+
// deterministic on the execution trace, so the segments of one logical
|
|
506
|
+
// operation stay correlated across invocations.
|
|
507
|
+
links: this.replayLinks(info.id),
|
|
508
|
+
startTime: hrTime(),
|
|
509
|
+
}, parentContext);
|
|
510
|
+
// Record error if present; otherwise stamp explicit OK ONLY on a
|
|
511
|
+
// SUCCEEDED terminal status (matches Python OTel plugin #604). This is
|
|
512
|
+
// the terminal completion path for an operation started in a prior
|
|
513
|
+
// invocation. Terminal FAILURE statuses can arrive with NO error object
|
|
514
|
+
// (callback-timeout, chained-invoke 'already failed' cross-invocation
|
|
515
|
+
// fast paths); those must NOT be labelled OK, so they are left UNSET.
|
|
516
|
+
if (info.error) {
|
|
517
|
+
continuationSpan.setStatus({
|
|
518
|
+
code: SpanStatusCode.ERROR,
|
|
519
|
+
message: info.error.message,
|
|
520
|
+
});
|
|
521
|
+
continuationSpan.recordException(info.error);
|
|
522
|
+
}
|
|
523
|
+
else if (info.status === "SUCCEEDED") {
|
|
524
|
+
continuationSpan.setStatus({ code: SpanStatusCode.OK });
|
|
525
|
+
}
|
|
526
|
+
// Immediately end
|
|
527
|
+
continuationSpan.end(hrTime());
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
async onOperationAttemptStart(info) {
|
|
531
|
+
if (!this.tracingEnabled) {
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
const baseName = info.name ?? info.type;
|
|
535
|
+
const spanName = `${baseName} attempt ${info.attempt}`;
|
|
536
|
+
// Find the parent Operation_Span for this operation
|
|
537
|
+
const parentSpan = this.spanMap.get(info.id);
|
|
538
|
+
const parentContext = parentSpan
|
|
539
|
+
? trace.setSpan(context.active(), parentSpan)
|
|
540
|
+
: context.active();
|
|
541
|
+
const attributes = {
|
|
542
|
+
"durable.execution.arn": this.executionArn,
|
|
543
|
+
"durable.operation.id": info.id,
|
|
544
|
+
"durable.operation.type": info.type,
|
|
545
|
+
"durable.attempt.number": info.attempt,
|
|
546
|
+
// Attempt spans do NOT carry durable.operation.status; the attempt's
|
|
547
|
+
// success/failure is carried solely by durable.attempt.outcome (set at
|
|
548
|
+
// onOperationAttemptEnd).
|
|
549
|
+
};
|
|
550
|
+
if (info.name) {
|
|
551
|
+
attributes["durable.operation.name"] = info.name;
|
|
552
|
+
}
|
|
553
|
+
if (info.subType) {
|
|
554
|
+
attributes["durable.operation.subtype"] = info.subType;
|
|
555
|
+
}
|
|
556
|
+
const attemptSpan = this.startSpan(spanName, {
|
|
557
|
+
attributes,
|
|
558
|
+
startTime: hrTime(),
|
|
559
|
+
}, parentContext);
|
|
560
|
+
this.spanMap.set(this.attemptSpanKey(info.id, info.attempt), attemptSpan);
|
|
561
|
+
}
|
|
562
|
+
wrapOperationAttemptFn(info, fn) {
|
|
563
|
+
if (!this.tracingEnabled) {
|
|
564
|
+
return fn();
|
|
565
|
+
}
|
|
566
|
+
const attemptSpan = this.spanMap.get(this.attemptSpanKey(info.id, info.attempt));
|
|
567
|
+
if (!attemptSpan) {
|
|
568
|
+
return fn();
|
|
569
|
+
}
|
|
570
|
+
return context.with(trace.setSpan(context.active(), attemptSpan), fn);
|
|
571
|
+
}
|
|
572
|
+
async onOperationAttemptEnd(info) {
|
|
573
|
+
if (!this.tracingEnabled) {
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
const key = this.attemptSpanKey(info.id, info.attempt);
|
|
577
|
+
const attemptSpan = this.spanMap.get(key);
|
|
578
|
+
if (attemptSpan) {
|
|
579
|
+
attemptSpan.addLinks(this.attemptLinks(info));
|
|
580
|
+
attemptSpan.setAttribute("durable.attempt.outcome", info.outcome);
|
|
581
|
+
if (info.outcome === "FAILED") {
|
|
582
|
+
attemptSpan.setStatus({
|
|
583
|
+
code: SpanStatusCode.ERROR,
|
|
584
|
+
message: info.error?.message ?? "Attempt failed",
|
|
585
|
+
});
|
|
586
|
+
if (info.error) {
|
|
587
|
+
attemptSpan.recordException(info.error);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
else {
|
|
591
|
+
// Non-failed attempt: stamp explicit OK (matches Python OTel #604).
|
|
592
|
+
attemptSpan.setStatus({ code: SpanStatusCode.OK });
|
|
593
|
+
}
|
|
594
|
+
attemptSpan.end(hrTime());
|
|
595
|
+
this.spanMap.delete(key);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
async onOperationChange(_info) {
|
|
599
|
+
// No-op for this plugin
|
|
600
|
+
}
|
|
601
|
+
enrichLogContext() {
|
|
602
|
+
if (!this.tracingEnabled || !this.enrichLogger) {
|
|
603
|
+
return undefined;
|
|
604
|
+
}
|
|
605
|
+
const span = trace.getSpan(context.active());
|
|
606
|
+
if (!span) {
|
|
607
|
+
return undefined;
|
|
608
|
+
}
|
|
609
|
+
const spanContext = span.spanContext();
|
|
610
|
+
return {
|
|
611
|
+
traceId: spanContext.traceId,
|
|
612
|
+
spanId: spanContext.spanId,
|
|
613
|
+
otelTraceSampled: (spanContext.traceFlags & 1) !== 0,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Link(s) to the execution-scoped Workflow span. Operation and attempt spans
|
|
618
|
+
* carry this link so they correlate to the one Workflow span for the durable
|
|
619
|
+
* execution while remaining parented to the invocation/operation hierarchy.
|
|
620
|
+
* The invocation span itself does NOT carry this link. Returns an empty array
|
|
621
|
+
* if the Workflow span was not created.
|
|
622
|
+
*/
|
|
623
|
+
workflowLinks() {
|
|
624
|
+
const workflowContext = this.workflowSpan?.spanContext();
|
|
625
|
+
return workflowContext ? [{ context: workflowContext }] : [];
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Links for a continuation or replay operation span: a link back to the
|
|
629
|
+
* initial logical operation span (deterministic on the execution trace)
|
|
630
|
+
* FIRST, then the Workflow link, so the links are ordered
|
|
631
|
+
* `[operation, Workflow]`. Restores the cross-invocation operation
|
|
632
|
+
* correlation that was previously dropped as "fabricated" — the initial span
|
|
633
|
+
* ID is reproducible and the span is genuinely exported. The order matters:
|
|
634
|
+
* the conformance contract resolves `links[0]` to the operation span (which
|
|
635
|
+
* carries `durable.operation.id`) and `links[1]` to the Workflow span (which
|
|
636
|
+
* carries `durable.execution.arn`).
|
|
637
|
+
*/
|
|
638
|
+
replayLinks(operationId) {
|
|
639
|
+
const links = [];
|
|
640
|
+
const initialLink = this.initialOperationLink(operationId);
|
|
641
|
+
if (initialLink) {
|
|
642
|
+
links.push(initialLink);
|
|
643
|
+
}
|
|
644
|
+
links.push(...this.workflowLinks());
|
|
645
|
+
return links;
|
|
646
|
+
}
|
|
647
|
+
attemptLinks(info) {
|
|
648
|
+
if (info.subType === "WaitForCondition" &&
|
|
649
|
+
info.attempt === 1 &&
|
|
650
|
+
info.outcome === "SUCCEEDED") {
|
|
651
|
+
return this.replayLinks(info.id);
|
|
652
|
+
}
|
|
653
|
+
return this.workflowLinks();
|
|
654
|
+
}
|
|
655
|
+
attemptSpanKey(operationId, attempt) {
|
|
656
|
+
return `attempt:${operationId}:${attempt}`;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
export { InvocationOtelPlugin as I };
|
|
661
|
+
//# sourceMappingURL=invocation-plugin.mjs.map
|