@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.
- package/README.md +338 -12
- package/dist/execution-plugin.mjs +227 -50
- package/dist/execution-plugin.mjs.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/invocation-plugin.mjs +209 -105
- package/dist/invocation-plugin.mjs.map +1 -1
- package/dist/otel-execution.mjs +1 -1
- package/dist/otel-execution.mjs.map +1 -1
- package/dist/otel-invocation.mjs +2 -2
- package/dist/otel-invocation.mjs.map +1 -1
- package/dist/{global-id-generator.mjs → plugin-registration.mjs} +147 -4
- package/dist/plugin-registration.mjs.map +1 -0
- package/dist-cjs/execution-plugin.js +240 -63
- package/dist-cjs/execution-plugin.js.map +1 -1
- package/dist-cjs/index.js +10 -10
- package/dist-cjs/invocation-plugin.js +221 -117
- package/dist-cjs/invocation-plugin.js.map +1 -1
- package/dist-cjs/otel-execution.js +1 -1
- package/dist-cjs/otel-execution.js.map +1 -1
- package/dist-cjs/otel-invocation.js +2 -2
- package/dist-cjs/otel-invocation.js.map +1 -1
- package/dist-cjs/{global-id-generator.js → plugin-registration.js} +152 -2
- package/dist-cjs/plugin-registration.js.map +1 -0
- package/dist-types/execution-plugin.d.ts +25 -4
- package/dist-types/execution-plugin.d.ts.map +1 -1
- package/dist-types/execution-trace-context.d.ts +4 -2
- package/dist-types/execution-trace-context.d.ts.map +1 -1
- package/dist-types/external-completions.d.ts +11 -0
- package/dist-types/external-completions.d.ts.map +1 -0
- package/dist-types/global-sampler.d.ts +2 -0
- package/dist-types/global-sampler.d.ts.map +1 -1
- package/dist-types/invocation-clock.d.ts +9 -0
- package/dist-types/invocation-clock.d.ts.map +1 -0
- package/dist-types/invocation-plugin.d.ts +18 -2
- package/dist-types/invocation-plugin.d.ts.map +1 -1
- package/dist-types/operation-span-status.d.ts +5 -0
- package/dist-types/operation-span-status.d.ts.map +1 -0
- package/dist-types/otel-plugin-config.d.ts +8 -0
- package/dist-types/otel-plugin-config.d.ts.map +1 -1
- package/dist-types/otel-plugin-provider.d.ts +7 -0
- package/dist-types/otel-plugin-provider.d.ts.map +1 -1
- package/dist-types/plugin-registration.d.ts +2 -0
- package/dist-types/plugin-registration.d.ts.map +1 -0
- package/dist-types/propagation-metadata.d.ts +12 -0
- package/dist-types/propagation-metadata.d.ts.map +1 -0
- package/package.json +13 -9
- package/dist/global-id-generator.mjs.map +0 -1
- 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
|
-
|
|
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.
|
|
52
|
-
this.
|
|
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 } =
|
|
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 =
|
|
84
|
+
this.durableSampler = pluginRegistration.tryInstallDurableSampler(this.tracer);
|
|
60
85
|
this.globalIdGeneratorInstalled = !this.usesGlobalProvider;
|
|
61
86
|
if (this.usesGlobalProvider) {
|
|
62
|
-
const installedIdGenerator =
|
|
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 =
|
|
87
|
-
const execTraceContext =
|
|
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 =
|
|
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
|
-
|
|
111
|
-
this.
|
|
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:
|
|
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
|
-
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
241
|
-
const installedIdGenerator =
|
|
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
|
|
278
|
-
// execution trace AND carries the same sampled bit as the execution
|
|
279
|
-
// ancestor.
|
|
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 (
|
|
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:
|
|
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
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
|
|
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:
|
|
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.
|
|
391
|
-
|
|
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
|
-
//
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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:
|
|
631
|
+
startTime: this.liveTimestamp(),
|
|
511
632
|
}, parentContext);
|
|
512
|
-
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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
|
-
|
|
706
|
+
this.endOperationSpan(attemptSpan, this.liveTimestamp());
|
|
597
707
|
this.spanMap.delete(key);
|
|
598
708
|
}
|
|
599
709
|
}
|
|
600
|
-
async onOperationChange(
|
|
601
|
-
|
|
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
|
}
|