@aws/durable-execution-sdk-js-otel 0.1.0 → 1.0.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.
Files changed (35) hide show
  1. package/README.md +494 -176
  2. package/dist/execution-plugin.mjs +539 -0
  3. package/dist/execution-plugin.mjs.map +1 -0
  4. package/dist/global-id-generator.mjs +622 -0
  5. package/dist/global-id-generator.mjs.map +1 -0
  6. package/dist/index.mjs +8 -516
  7. package/dist/index.mjs.map +1 -1
  8. package/dist/invocation-plugin.mjs +661 -0
  9. package/dist/invocation-plugin.mjs.map +1 -0
  10. package/dist/otel-execution.mjs +15 -0
  11. package/dist/otel-execution.mjs.map +1 -0
  12. package/dist/otel-invocation.mjs +16 -0
  13. package/dist/otel-invocation.mjs.map +1 -0
  14. package/dist-cjs/execution-plugin.js +541 -0
  15. package/dist-cjs/execution-plugin.js.map +1 -0
  16. package/dist-cjs/global-id-generator.js +637 -0
  17. package/dist-cjs/global-id-generator.js.map +1 -0
  18. package/dist-cjs/index.js +18 -519
  19. package/dist-cjs/index.js.map +1 -1
  20. package/dist-cjs/invocation-plugin.js +663 -0
  21. package/dist-cjs/invocation-plugin.js.map +1 -0
  22. package/dist-cjs/otel-execution.js +17 -0
  23. package/dist-cjs/otel-execution.js.map +1 -0
  24. package/dist-cjs/otel-invocation.js +18 -0
  25. package/dist-cjs/otel-invocation.js.map +1 -0
  26. package/package.json +26 -15
  27. package/dist-types/context-extractors.d.ts +0 -43
  28. package/dist-types/context-extractors.d.ts.map +0 -1
  29. package/dist-types/deterministic-id-generator.d.ts +0 -64
  30. package/dist-types/deterministic-id-generator.d.ts.map +0 -1
  31. package/dist-types/index.d.ts +0 -6
  32. package/dist-types/index.d.ts.map +0 -1
  33. package/dist-types/plugin.d.ts +0 -46
  34. package/dist-types/plugin.d.ts.map +0 -1
  35. 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, f as createTracerProvider, t as tryInstallDurableSampler, g as tryInstallGlobalIdGenerator, h as canonicalTraceId, r as resolveExecutionTraceContext, e as deriveWorkflowSpanId, a as deriveSpanIdFromOperationId, i 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