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