@aws/durable-execution-sdk-js-otel 1.1.0 → 1.2.1

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 (48) hide show
  1. package/README.md +338 -12
  2. package/dist/execution-plugin.mjs +227 -50
  3. package/dist/execution-plugin.mjs.map +1 -1
  4. package/dist/index.mjs +1 -1
  5. package/dist/invocation-plugin.mjs +209 -105
  6. package/dist/invocation-plugin.mjs.map +1 -1
  7. package/dist/otel-execution.mjs +1 -1
  8. package/dist/otel-execution.mjs.map +1 -1
  9. package/dist/otel-invocation.mjs +2 -2
  10. package/dist/otel-invocation.mjs.map +1 -1
  11. package/dist/{global-id-generator.mjs → plugin-registration.mjs} +147 -4
  12. package/dist/plugin-registration.mjs.map +1 -0
  13. package/dist-cjs/execution-plugin.js +240 -63
  14. package/dist-cjs/execution-plugin.js.map +1 -1
  15. package/dist-cjs/index.js +10 -10
  16. package/dist-cjs/invocation-plugin.js +221 -117
  17. package/dist-cjs/invocation-plugin.js.map +1 -1
  18. package/dist-cjs/otel-execution.js +1 -1
  19. package/dist-cjs/otel-execution.js.map +1 -1
  20. package/dist-cjs/otel-invocation.js +2 -2
  21. package/dist-cjs/otel-invocation.js.map +1 -1
  22. package/dist-cjs/{global-id-generator.js → plugin-registration.js} +152 -2
  23. package/dist-cjs/plugin-registration.js.map +1 -0
  24. package/dist-types/execution-plugin.d.ts +25 -4
  25. package/dist-types/execution-plugin.d.ts.map +1 -1
  26. package/dist-types/execution-trace-context.d.ts +4 -2
  27. package/dist-types/execution-trace-context.d.ts.map +1 -1
  28. package/dist-types/external-completions.d.ts +11 -0
  29. package/dist-types/external-completions.d.ts.map +1 -0
  30. package/dist-types/global-sampler.d.ts +2 -0
  31. package/dist-types/global-sampler.d.ts.map +1 -1
  32. package/dist-types/invocation-clock.d.ts +9 -0
  33. package/dist-types/invocation-clock.d.ts.map +1 -0
  34. package/dist-types/invocation-plugin.d.ts +18 -2
  35. package/dist-types/invocation-plugin.d.ts.map +1 -1
  36. package/dist-types/operation-span-status.d.ts +5 -0
  37. package/dist-types/operation-span-status.d.ts.map +1 -0
  38. package/dist-types/otel-plugin-config.d.ts +8 -0
  39. package/dist-types/otel-plugin-config.d.ts.map +1 -1
  40. package/dist-types/otel-plugin-provider.d.ts +7 -0
  41. package/dist-types/otel-plugin-provider.d.ts.map +1 -1
  42. package/dist-types/plugin-registration.d.ts +2 -0
  43. package/dist-types/plugin-registration.d.ts.map +1 -0
  44. package/dist-types/propagation-metadata.d.ts +12 -0
  45. package/dist-types/propagation-metadata.d.ts.map +1 -0
  46. package/package.json +13 -9
  47. package/dist/global-id-generator.mjs.map +0 -1
  48. package/dist-cjs/global-id-generator.js.map +0 -1
@@ -1,9 +1,21 @@
1
1
  'use strict';
2
2
 
3
+ var pluginRegistration = require('./plugin-registration.js');
3
4
  var api = require('@opentelemetry/api');
4
5
  var core = require('@opentelemetry/core');
5
6
  var sdkTraceNode = require('@opentelemetry/sdk-trace-node');
6
- var globalIdGenerator = require('./global-id-generator.js');
7
+
8
+ /** Match ordinary OTel spans: a wall-clock origin plus monotonic elapsed time. */
9
+ function captureInvocationClock() {
10
+ return {
11
+ epochMillis: Date.now(),
12
+ monotonicMillis: core.otperformance.now(),
13
+ };
14
+ }
15
+ function readInvocationClock(clock) {
16
+ // HrTime explicitly denotes absolute time; numeric TimeInput can be relative.
17
+ return core.millisToHrTime(clock.epochMillis + (core.otperformance.now() - clock.monotonicMillis));
18
+ }
7
19
 
8
20
  const DEFAULT_INSTRUMENTATION_NAME = "aws-durable-execution-sdk-js";
9
21
  /**
@@ -14,18 +26,27 @@ const DEFAULT_INSTRUMENTATION_NAME = "aws-durable-execution-sdk-js";
14
26
  * durable execution.
15
27
  */
16
28
  class InvocationOtelPlugin {
29
+ static [pluginRegistration.PLUGIN_REGISTRATION] = {
30
+ exclusiveGroup: "durable-opentelemetry-view",
31
+ };
17
32
  tracerProvider;
18
33
  tracer;
19
34
  idGenerator;
20
35
  contextExtractor;
21
36
  instrumentationName;
37
+ flushTimeoutMillis;
22
38
  usesGlobalProvider;
23
39
  globalIdGeneratorInstalled;
24
40
  durableSampler;
25
41
  tracingEnabled = false;
42
+ // Operation and attempt spans ended after a flush starts need a final batch.
43
+ operationSpansNeedFlush = false;
44
+ externalCompletions = new pluginRegistration.ExternalCompletions();
45
+ endedOperationSpans = new WeakSet();
26
46
  workflowSpanName;
27
47
  enrichLogger;
28
48
  // Per-invocation state
49
+ invocationClock;
29
50
  spanMap = new Map();
30
51
  spanStack = [];
31
52
  invocationSpan;
@@ -41,6 +62,9 @@ class InvocationOtelPlugin {
41
62
  // Workflow span can be backdated to it; the now() fallback is taken here (not
42
63
  // at onInvocationEnd, where it would land after the span's own end time).
43
64
  executionStartTimestamp;
65
+ // Only a backend timestamp can define a retry-stable, zero-duration anchor.
66
+ syntheticRootAnchorTimestamp;
67
+ syntheticRootMaterialized = false;
44
68
  executionArn = "";
45
69
  executionTraceId = "";
46
70
  executionTraceFlags = 0;
@@ -48,18 +72,19 @@ class InvocationOtelPlugin {
48
72
  constructor(config) {
49
73
  const instrumentationName = config?.instrumentationName ?? DEFAULT_INSTRUMENTATION_NAME;
50
74
  this.instrumentationName = instrumentationName;
51
- this.idGenerator = new globalIdGenerator.DeterministicIdGenerator();
52
- this.contextExtractor = config?.contextExtractor ?? globalIdGenerator.xRayContextExtractor;
75
+ this.flushTimeoutMillis = pluginRegistration.resolveFlushTimeoutMillis(config?.flushTimeoutMillis);
76
+ this.idGenerator = new pluginRegistration.DeterministicIdGenerator();
77
+ this.contextExtractor = config?.contextExtractor ?? pluginRegistration.xRayContextExtractor;
53
78
  this.workflowSpanName = config?.workflowSpanName ?? "Workflow";
54
79
  this.enrichLogger = config?.enrichLogger ?? true;
55
- const { tracerProvider, usesGlobalProvider } = globalIdGenerator.createTracerProvider(config, this.idGenerator);
80
+ const { tracerProvider, usesGlobalProvider } = pluginRegistration.createTracerProvider(config, this.idGenerator);
56
81
  this.tracerProvider = tracerProvider;
57
82
  this.usesGlobalProvider = usesGlobalProvider;
58
83
  this.tracer = this.tracerProvider.getTracer(instrumentationName);
59
- this.durableSampler = globalIdGenerator.tryInstallDurableSampler(this.tracer);
84
+ this.durableSampler = pluginRegistration.tryInstallDurableSampler(this.tracer);
60
85
  this.globalIdGeneratorInstalled = !this.usesGlobalProvider;
61
86
  if (this.usesGlobalProvider) {
62
- const installedIdGenerator = globalIdGenerator.tryInstallGlobalIdGenerator(this.tracer);
87
+ const installedIdGenerator = pluginRegistration.tryInstallGlobalIdGenerator(this.tracer);
63
88
  if (installedIdGenerator) {
64
89
  this.idGenerator = installedIdGenerator;
65
90
  this.globalIdGeneratorInstalled = true;
@@ -72,6 +97,10 @@ class InvocationOtelPlugin {
72
97
  if (!this.tracingEnabled) {
73
98
  return;
74
99
  }
100
+ // Match the epoch/elapsed-time model of an ordinary OTel span, but share
101
+ // this anchor across all live spans in the invocation. Never use the
102
+ // process timeOrigin: it can differ from the current wall-clock epoch.
103
+ this.invocationClock = captureInvocationClock();
75
104
  // 1. Store the execution ARN
76
105
  this.executionArn = info.executionArn;
77
106
  // 2. Invoke the context extractor
@@ -83,8 +112,8 @@ class InvocationOtelPlugin {
83
112
  // parent, else a synthetic execution root. A live ambient span is not used
84
113
  // as the ancestor (its trace is not stable across reinvocations); the
85
114
  // 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, {
115
+ const canonical = pluginRegistration.canonicalTraceId(extractedContext, info.executionArn, info.executionStartTimestamp);
116
+ const execTraceContext = pluginRegistration.resolveExecutionTraceContext(extractedContext, canonical, info.executionArn, () => pluginRegistration.rootSamplingDecision(this.tracer, canonical, this.workflowSpanName, {
88
117
  "durable.execution.arn": info.executionArn,
89
118
  }));
90
119
  this.executionTraceId = canonical;
@@ -104,12 +133,16 @@ class InvocationOtelPlugin {
104
133
  // single real span is created and ended once, at the terminal invocation
105
134
  // (see onInvocationEnd). It carries the execution sampled bit so a
106
135
  // parent-based sampler stays consistent with the eventual root.
107
- const workflowSpanId = globalIdGenerator.deriveWorkflowSpanId(info.executionArn);
136
+ const workflowSpanId = pluginRegistration.deriveWorkflowSpanId(info.executionArn);
108
137
  this.executionAncestor = execTraceContext.executionAncestor;
138
+ // Snapshot the Date: another hook must not be able to alter a later copy
139
+ // of this invocation's anchor by mutating the input object.
140
+ this.syntheticRootAnchorTimestamp = info.executionStartTimestamp
141
+ ? new Date(info.executionStartTimestamp.getTime())
142
+ : undefined;
109
143
  this.executionStartTimestamp =
110
- info.executionStartTimestamp ??
111
- this.executionStartTimestamp ??
112
- new Date();
144
+ this.syntheticRootAnchorTimestamp ??
145
+ new Date(this.invocationClock.epochMillis);
113
146
  this.workflowSpan = api.trace.wrapSpanContext({
114
147
  traceId: this.executionTraceId,
115
148
  spanId: workflowSpanId,
@@ -132,8 +165,24 @@ class InvocationOtelPlugin {
132
165
  "durable.execution.arn": info.executionArn,
133
166
  "durable.invocation.first": info.isFirstInvocation,
134
167
  },
135
- startTime: core.hrTime(),
168
+ startTime: this.liveTimestamp(),
136
169
  }, invocationParentContext);
170
+ this.externalCompletions.observe(info.updatedOperations, info.operations);
171
+ // Each sampled invocation can recover an earlier undelivered anchor. An
172
+ // opaque tracer retains terminal-only creation: without the sampler wrapper,
173
+ // an early anchor would independently query its hidden sampler again.
174
+ if (this.durableSampler && this.syntheticRootAnchorTimestamp) {
175
+ this.createSyntheticRoot()?.end(this.syntheticRootAnchorTimestamp);
176
+ }
177
+ }
178
+ providePropagationMetadata(input) {
179
+ if (!this.tracingEnabled ||
180
+ !this.invocationSpan ||
181
+ !this.workflowSpan ||
182
+ !pluginRegistration.isDurableSamplerInstalled(this.tracer, this.durableSampler)) {
183
+ return undefined;
184
+ }
185
+ return pluginRegistration.createPropagationMetadata(input, this.executionArn, this.executionTraceId, this.executionTraceFlags);
137
186
  }
138
187
  wrapInvocation(_info, fn) {
139
188
  if (!this.tracingEnabled || !this.invocationSpan) {
@@ -146,21 +195,15 @@ class InvocationOtelPlugin {
146
195
  this.resetInvocationState();
147
196
  return;
148
197
  }
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
- }
198
+ // Prefer normal lifecycle hooks, which preserve live parent scopes. Before
199
+ // returning, export any fresh completion the workflow did not reach. A
200
+ // later resume may label it replay even though it has never been exported.
201
+ for (const operation of this.externalCompletions.pending.values()) {
202
+ this.finishOperation({ ...operation, isReplay: false });
163
203
  }
204
+ const endTime = this.liveTimestamp();
205
+ // 1. Close the spans that exist at the initial invocation unwind.
206
+ this.endOpenSpans(endTime);
164
207
  // 2. End the invocation span if it exists
165
208
  if (this.invocationSpan) {
166
209
  this.invocationSpan.setAttribute("durable.invocation.status", info.status);
@@ -193,7 +236,10 @@ class InvocationOtelPlugin {
193
236
  if (this.executionSamplingDecision === sdkTraceNode.SamplingDecision.RECORD_AND_SAMPLED &&
194
237
  this.executionAncestor &&
195
238
  (info.status === "SUCCEEDED" || info.status === "FAILED")) {
196
- const workflowSpanId = globalIdGenerator.deriveWorkflowSpanId(this.executionArn);
239
+ // Keep terminal-only fallback for providers without an early anchor.
240
+ // An anchor already materialized in this invocation is not emitted twice.
241
+ const syntheticRootSpan = this.createSyntheticRoot();
242
+ const workflowSpanId = pluginRegistration.deriveWorkflowSpanId(this.executionArn);
197
243
  const executionAncestorContext = api.trace.setSpanContext(api.ROOT_CONTEXT, this.executionAncestor);
198
244
  const workflowSpan = this.idGenerator.withIds({ spanId: workflowSpanId }, () => this.startSpan(this.workflowSpanName, {
199
245
  kind: api.SpanKind.INTERNAL,
@@ -213,20 +259,69 @@ class InvocationOtelPlugin {
213
259
  workflowSpan.setStatus({ code: api.SpanStatusCode.OK });
214
260
  }
215
261
  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) {
262
+ syntheticRootSpan?.end(this.syntheticRootAnchorTimestamp ?? endTime);
263
+ }
264
+ // PENDING/RETRYING never materializes Workflow. A sampled invocation may
265
+ // already have ended its stable synthetic anchor before customer code ran.
266
+ // End owns the drain/flush loop; change hooks only enqueue notifications.
267
+ // Only segments actually ended above need a linked continuation. A span
268
+ // created during an awaited flush can still complete normally.
269
+ const flushDeadline = performance.now() + this.flushTimeoutMillis;
270
+ const flush = async () => {
221
271
  try {
222
- await this.tracerProvider.forceFlush();
272
+ await pluginRegistration.flushTracerProvider(this.tracerProvider, this.usesGlobalProvider, Math.max(0, flushDeadline - performance.now()));
223
273
  }
224
274
  catch {
225
- // Gracefully handle flush errors
275
+ // Gracefully handle flush errors.
226
276
  }
227
- }
228
- // 5. Clear all per-invocation state
277
+ };
278
+ do {
279
+ for (const operation of this.externalCompletions.pending.values()) {
280
+ this.finishOperation({ ...operation, isReplay: false });
281
+ }
282
+ this.operationSpansNeedFlush = false;
283
+ await flush();
284
+ } while (this.externalCompletions.pending.size > 0 &&
285
+ performance.now() < flushDeadline);
286
+ // Retain one bounded snapshot of terminal notifications already received,
287
+ // including while a provider wait timed out.
288
+ for (const operation of [...this.externalCompletions.pending.values()]) {
289
+ this.finishOperation({ ...operation, isReplay: false });
290
+ }
291
+ // A still-running operation may have started while flush yielded. End its
292
+ // owned recording span before dropping the maps.
293
+ this.endOpenSpans(this.liveTimestamp());
294
+ // Close hooks before the final batch: late lifecycle callbacks cannot create
295
+ // more spans while this last wait uses only the original budget remaining.
296
+ const flushFinalOperations = this.operationSpansNeedFlush;
229
297
  this.resetInvocationState();
298
+ if (flushFinalOperations && performance.now() < flushDeadline) {
299
+ await flush();
300
+ }
301
+ }
302
+ /** Creates at most one SDK-owned root per invocation; the caller ends it. */
303
+ createSyntheticRoot() {
304
+ if (this.syntheticRootMaterialized ||
305
+ this.executionSamplingDecision !== sdkTraceNode.SamplingDecision.RECORD_AND_SAMPLED ||
306
+ !this.executionAncestor ||
307
+ this.executionAncestor.isRemote) {
308
+ return undefined;
309
+ }
310
+ const span = this.idGenerator.withIds({
311
+ traceId: this.executionTraceId,
312
+ spanId: this.executionAncestor.spanId,
313
+ }, () => this.startSpan("DurableExecutionRoot", {
314
+ kind: api.SpanKind.INTERNAL,
315
+ attributes: {
316
+ "durable.execution.synthetic_root": true,
317
+ "durable.execution.arn": this.executionArn,
318
+ },
319
+ startTime: this.syntheticRootAnchorTimestamp ??
320
+ this.executionStartTimestamp ??
321
+ new Date(),
322
+ }, api.ROOT_CONTEXT));
323
+ this.syntheticRootMaterialized = true;
324
+ return span;
230
325
  }
231
326
  ensureGlobalIdGeneratorInstalled() {
232
327
  if (!this.usesGlobalProvider || this.globalIdGeneratorInstalled) {
@@ -237,8 +332,8 @@ class InvocationOtelPlugin {
237
332
  // again at invocation start, after preload initialization has completed.
238
333
  this.tracerProvider = api.trace.getTracerProvider();
239
334
  this.tracer = this.tracerProvider.getTracer(this.instrumentationName);
240
- this.durableSampler = globalIdGenerator.tryInstallDurableSampler(this.tracer);
241
- const installedIdGenerator = globalIdGenerator.tryInstallGlobalIdGenerator(this.tracer);
335
+ this.durableSampler = pluginRegistration.tryInstallDurableSampler(this.tracer);
336
+ const installedIdGenerator = pluginRegistration.tryInstallGlobalIdGenerator(this.tracer);
242
337
  if (installedIdGenerator) {
243
338
  this.idGenerator = installedIdGenerator;
244
339
  this.globalIdGeneratorInstalled = true;
@@ -247,19 +342,47 @@ class InvocationOtelPlugin {
247
342
  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
343
  return false;
249
344
  }
345
+ liveTimestamp() {
346
+ return readInvocationClock(this.invocationClock);
347
+ }
250
348
  resetInvocationState() {
349
+ this.externalCompletions.clear();
350
+ this.operationSpansNeedFlush = false;
351
+ this.endedOperationSpans = new WeakSet();
352
+ this.invocationClock = undefined;
251
353
  this.spanMap.clear();
252
354
  this.spanStack = [];
253
355
  this.invocationSpan = undefined;
254
356
  this.workflowSpan = undefined;
255
357
  this.executionAncestor = undefined;
256
358
  this.executionStartTimestamp = undefined;
359
+ this.syntheticRootAnchorTimestamp = undefined;
360
+ this.syntheticRootMaterialized = false;
257
361
  this.executionArn = "";
258
362
  this.executionTraceId = "";
259
363
  this.executionTraceFlags = 0;
260
364
  this.executionSamplingDecision = sdkTraceNode.SamplingDecision.NOT_RECORD;
261
365
  this.tracingEnabled = false;
262
366
  }
367
+ endOperationSpan(span, endTime) {
368
+ span.end(endTime);
369
+ this.operationSpansNeedFlush = true;
370
+ }
371
+ endOpenSpans(endTime) {
372
+ while (this.spanStack.length > 0) {
373
+ const span = this.spanStack.pop();
374
+ this.endOperationSpan(span, endTime);
375
+ this.endedOperationSpans.add(span);
376
+ }
377
+ // Attempts are held only in the map. Recording state avoids ending spans
378
+ // twice here; it is not used to infer whether a sampled-out span has ended.
379
+ for (const span of this.spanMap.values()) {
380
+ if (span.isRecording()) {
381
+ this.endOperationSpan(span, endTime);
382
+ this.endedOperationSpans.add(span);
383
+ }
384
+ }
385
+ }
263
386
  startSpan(name, options, parentContext) {
264
387
  const fn = () => this.tracer.startSpan(name, options, parentContext);
265
388
  return this.durableSampler
@@ -274,14 +397,16 @@ class InvocationOtelPlugin {
274
397
  invocationParentContext(executionAncestor, canonical) {
275
398
  const activeContext = api.context.active();
276
399
  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
400
+ // Adopt the ambient span only in propagated-parent mode, when it is on
401
+ // the execution trace AND carries the same sampled bit as the execution
402
+ // ancestor. Synthetic fallback keeps Workflow and Invocation on the same
403
+ // SDK-owned root. Matching the trace ID alone is not enough: if the ambient span's
280
404
  // sampled bit differs, the Invocation span would inherit the ambient
281
405
  // decision and could export after an explicit Sampled=0, or drop after the
282
406
  // root sampler chose sampled. On a mismatch, fall back to the execution
283
407
  // ancestor, which carries the authoritative decision.
284
- if (ambient &&
408
+ if (executionAncestor.isRemote &&
409
+ ambient &&
285
410
  api.isSpanContextValid(ambient) &&
286
411
  ambient.traceId === canonical &&
287
412
  (ambient.traceFlags & api.TraceFlags.SAMPLED) ===
@@ -307,7 +432,7 @@ class InvocationOtelPlugin {
307
432
  }
308
433
  const initialContext = {
309
434
  traceId: this.executionTraceId,
310
- spanId: globalIdGenerator.deriveSpanIdFromOperationId(operationId, this.executionArn),
435
+ spanId: pluginRegistration.deriveSpanIdFromOperationId(operationId, this.executionArn),
311
436
  traceFlags: this.executionTraceFlags,
312
437
  isRemote: false,
313
438
  };
@@ -326,13 +451,12 @@ class InvocationOtelPlugin {
326
451
  if (info.isReplay && this.spanMap.has(info.id)) {
327
452
  return;
328
453
  }
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);
454
+ // All live spans use the invocation's wall-anchored monotonic clock, so
455
+ // wall-clock adjustments cannot collapse or inflate their durations.
456
+ // Invocation cleanup shares one end time for open spans. Durable timestamps
457
+ // can predate this invocation and are used only for the backdated Workflow
458
+ // and synthetic execution root starts.
459
+ const deterministicSpanId = pluginRegistration.deriveSpanIdFromOperationId(info.id, this.executionArn);
336
460
  const spanName = info.name ?? info.type;
337
461
  // Resolve parent span: use parentId from map, or fall back to invocation span
338
462
  let parentSpan;
@@ -371,7 +495,7 @@ class InvocationOtelPlugin {
371
495
  }, () => this.startSpan(spanName, {
372
496
  attributes,
373
497
  links: this.workflowLinks(),
374
- startTime: core.hrTime(),
498
+ startTime: this.liveTimestamp(),
375
499
  }, parentContext));
376
500
  }
377
501
  else if (info.type === "CONTEXT" || info.type === "STEP") {
@@ -380,17 +504,10 @@ class InvocationOtelPlugin {
380
504
  // to the reproducible Workflow span, and links back to the initial logical
381
505
  // operation span (deterministic on the execution trace) so the segments
382
506
  // 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
507
  span = this.startSpan(spanName, {
389
508
  attributes,
390
- links: info.subType === "WaitForCondition"
391
- ? this.workflowLinks()
392
- : this.replayLinks(info.id),
393
- startTime: core.hrTime(),
509
+ links: this.replayLinks(info.id),
510
+ startTime: this.liveTimestamp(),
394
511
  }, parentContext);
395
512
  }
396
513
  // Push to map and stack
@@ -410,20 +527,33 @@ class InvocationOtelPlugin {
410
527
  return api.context.with(api.trace.setSpan(api.context.active(), span), fn);
411
528
  }
412
529
  async onOperationEnd(info) {
530
+ this.finishOperation(info);
531
+ }
532
+ // Child completion and parent finalization share one synchronous turn. A
533
+ // provider flush must not reset invocation state between these two actions.
534
+ finishOperation(info) {
413
535
  if (!this.tracingEnabled) {
414
536
  return;
415
537
  }
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")) {
538
+ // Checkpoint updates may complete an unawaited operation from an earlier
539
+ // invocation. Drain it before removing its live parent from spanMap.
540
+ if (info.type === "CONTEXT" && !info.isReplay) {
541
+ for (const child of this.externalCompletions.pending.values()) {
542
+ if (child.parentId === info.id) {
543
+ this.finishOperation({ ...child, isReplay: false });
544
+ }
545
+ }
546
+ }
547
+ // Updates and traversal can report the same completion in either order.
548
+ // The set is reset each invocation so failed-invocation redelivery remains
549
+ // eligible, while normal replay never exports a completion a second time.
550
+ if (this.externalCompletions.shouldSkip(info)) {
422
551
  return;
423
552
  }
424
- if (this.spanMap.has(info.id)) {
425
- // Operation was started in this invocation
426
- const span = this.spanMap.get(info.id);
553
+ const currentSpan = this.spanMap.get(info.id);
554
+ if (currentSpan && !this.endedOperationSpans.has(currentSpan)) {
555
+ // Operation was started in this invocation and its segment is still open
556
+ const span = currentSpan;
427
557
  // Finalize the operation status from the core-supplied terminal status.
428
558
  // Container CONTEXT ops receive a terminal status from the core too:
429
559
  // run-in-child-context-handler passes SUCCEEDED/FAILED on both the
@@ -438,25 +568,12 @@ class InvocationOtelPlugin {
438
568
  info.attempt != null) {
439
569
  span.setAttribute("durable.attempt.number", info.attempt);
440
570
  }
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.
571
+ pluginRegistration.setOperationSpanStatus(span, info);
448
572
  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 });
573
+ span.recordException(info.error, this.liveTimestamp());
457
574
  }
458
575
  // End the span
459
- span.end(core.hrTime());
576
+ this.endOperationSpan(span, this.liveTimestamp());
460
577
  // Remove from map
461
578
  this.spanMap.delete(info.id);
462
579
  // Remove from stack (find by reference)
@@ -465,7 +582,11 @@ class InvocationOtelPlugin {
465
582
  this.spanStack.splice(stackIndex, 1);
466
583
  }
467
584
  }
468
- else if (!info.isReplay) {
585
+ else if (!info.isReplay ||
586
+ this.externalCompletions.pending.has(info.id)) {
587
+ // A fresh checkpoint completion can be replay-marked: updated IDs were
588
+ // captured before this invocation received the update. Export it while
589
+ // its child parent is still active, without changing the SDK's info.
469
590
  // Operation was started in a prior invocation — create Continuation_Span
470
591
  const spanName = info.name ?? info.type;
471
592
  // Resolve parent span: use parentId from map (e.g. the CONTEXT span for
@@ -507,27 +628,16 @@ class InvocationOtelPlugin {
507
628
  // deterministic on the execution trace, so the segments of one logical
508
629
  // operation stay correlated across invocations.
509
630
  links: this.replayLinks(info.id),
510
- startTime: core.hrTime(),
631
+ startTime: this.liveTimestamp(),
511
632
  }, 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.
633
+ pluginRegistration.setOperationSpanStatus(continuationSpan, info);
518
634
  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 });
635
+ continuationSpan.recordException(info.error, this.liveTimestamp());
527
636
  }
528
637
  // Immediately end
529
- continuationSpan.end(core.hrTime());
638
+ this.endOperationSpan(continuationSpan, this.liveTimestamp());
530
639
  }
640
+ this.externalCompletions.markExported(info);
531
641
  }
532
642
  async onOperationAttemptStart(info) {
533
643
  if (!this.tracingEnabled) {
@@ -557,7 +667,7 @@ class InvocationOtelPlugin {
557
667
  }
558
668
  const attemptSpan = this.startSpan(spanName, {
559
669
  attributes,
560
- startTime: core.hrTime(),
670
+ startTime: this.liveTimestamp(),
561
671
  }, parentContext);
562
672
  this.spanMap.set(this.attemptSpanKey(info.id, info.attempt), attemptSpan);
563
673
  }
@@ -578,7 +688,7 @@ class InvocationOtelPlugin {
578
688
  const key = this.attemptSpanKey(info.id, info.attempt);
579
689
  const attemptSpan = this.spanMap.get(key);
580
690
  if (attemptSpan) {
581
- attemptSpan.addLinks(this.attemptLinks(info));
691
+ attemptSpan.addLinks(this.workflowLinks());
582
692
  attemptSpan.setAttribute("durable.attempt.outcome", info.outcome);
583
693
  if (info.outcome === "FAILED") {
584
694
  attemptSpan.setStatus({
@@ -586,19 +696,21 @@ class InvocationOtelPlugin {
586
696
  message: info.error?.message ?? "Attempt failed",
587
697
  });
588
698
  if (info.error) {
589
- attemptSpan.recordException(info.error);
699
+ attemptSpan.recordException(info.error, this.liveTimestamp());
590
700
  }
591
701
  }
592
702
  else {
593
703
  // Non-failed attempt: stamp explicit OK (matches Python OTel #604).
594
704
  attemptSpan.setStatus({ code: api.SpanStatusCode.OK });
595
705
  }
596
- attemptSpan.end(core.hrTime());
706
+ this.endOperationSpan(attemptSpan, this.liveTimestamp());
597
707
  this.spanMap.delete(key);
598
708
  }
599
709
  }
600
- async onOperationChange(_info) {
601
- // No-op for this plugin
710
+ async onOperationChange(info) {
711
+ if (this.tracingEnabled) {
712
+ this.externalCompletions.observe(info.updatedOperations, info.operations);
713
+ }
602
714
  }
603
715
  enrichLogContext() {
604
716
  if (!this.tracingEnabled || !this.enrichLogger) {
@@ -646,14 +758,6 @@ class InvocationOtelPlugin {
646
758
  links.push(...this.workflowLinks());
647
759
  return links;
648
760
  }
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
761
  attemptSpanKey(operationId, attempt) {
658
762
  return `attempt:${operationId}:${attempt}`;
659
763
  }