duron 0.3.0-beta.11 → 0.3.0-beta.13

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 (61) hide show
  1. package/dist/action-job.d.ts +2 -2
  2. package/dist/action-job.d.ts.map +1 -1
  3. package/dist/action-job.js +30 -24
  4. package/dist/action-manager.d.ts +2 -2
  5. package/dist/action-manager.d.ts.map +1 -1
  6. package/dist/action-manager.js +3 -3
  7. package/dist/action.d.ts +7 -7
  8. package/dist/action.d.ts.map +1 -1
  9. package/dist/adapters/adapter.d.ts +24 -26
  10. package/dist/adapters/adapter.d.ts.map +1 -1
  11. package/dist/adapters/adapter.js +25 -27
  12. package/dist/adapters/postgres/base.d.ts +21 -9
  13. package/dist/adapters/postgres/base.d.ts.map +1 -1
  14. package/dist/adapters/postgres/base.js +157 -62
  15. package/dist/adapters/postgres/schema.d.ts +118 -35
  16. package/dist/adapters/postgres/schema.d.ts.map +1 -1
  17. package/dist/adapters/postgres/schema.default.d.ts +119 -36
  18. package/dist/adapters/postgres/schema.default.d.ts.map +1 -1
  19. package/dist/adapters/postgres/schema.default.js +2 -2
  20. package/dist/adapters/postgres/schema.js +45 -22
  21. package/dist/adapters/schemas.d.ts +98 -80
  22. package/dist/adapters/schemas.d.ts.map +1 -1
  23. package/dist/adapters/schemas.js +59 -26
  24. package/dist/client.d.ts +106 -24
  25. package/dist/client.d.ts.map +1 -1
  26. package/dist/client.js +90 -30
  27. package/dist/index.d.ts +1 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/server.d.ts +51 -41
  30. package/dist/server.d.ts.map +1 -1
  31. package/dist/server.js +27 -27
  32. package/dist/step-manager.d.ts +30 -6
  33. package/dist/step-manager.d.ts.map +1 -1
  34. package/dist/step-manager.js +124 -85
  35. package/dist/telemetry/index.d.ts +1 -4
  36. package/dist/telemetry/index.d.ts.map +1 -1
  37. package/dist/telemetry/index.js +2 -5
  38. package/dist/telemetry/local-span-exporter.d.ts +56 -0
  39. package/dist/telemetry/local-span-exporter.d.ts.map +1 -0
  40. package/dist/telemetry/local-span-exporter.js +118 -0
  41. package/migrations/postgres/{20260119153838_flimsy_thor_girl → 20260120154151_mean_magdalene}/migration.sql +27 -19
  42. package/migrations/postgres/{20260119153838_flimsy_thor_girl → 20260120154151_mean_magdalene}/snapshot.json +172 -65
  43. package/package.json +7 -2
  44. package/src/action-job.ts +32 -28
  45. package/src/action-manager.ts +5 -5
  46. package/src/action.ts +7 -7
  47. package/src/adapters/adapter.ts +54 -54
  48. package/src/adapters/postgres/base.ts +201 -70
  49. package/src/adapters/postgres/schema.default.ts +2 -2
  50. package/src/adapters/postgres/schema.ts +47 -23
  51. package/src/adapters/schemas.ts +72 -35
  52. package/src/client.ts +195 -42
  53. package/src/index.ts +1 -0
  54. package/src/server.ts +37 -37
  55. package/src/step-manager.ts +170 -86
  56. package/src/telemetry/index.ts +2 -20
  57. package/src/telemetry/local-span-exporter.ts +148 -0
  58. package/src/telemetry/adapter.ts +0 -642
  59. package/src/telemetry/local.ts +0 -429
  60. package/src/telemetry/noop.ts +0 -141
  61. package/src/telemetry/opentelemetry.ts +0 -453
@@ -1,42 +1,84 @@
1
+ import { context, SpanKind, SpanStatusCode, trace, } from '@opentelemetry/api';
1
2
  import fastq from 'fastq';
2
3
  import { StepOptionsSchema, } from './action.js';
3
4
  import { STEP_STATUS_CANCELLED, STEP_STATUS_COMPLETED, STEP_STATUS_FAILED } from './constants.js';
4
5
  import { ActionCancelError, isCancelError, isNonRetriableError, isTimeoutError, NonRetriableError, StepAlreadyExecutedError, StepTimeoutError, serializeError, UnhandledChildStepsError, } from './errors.js';
5
- // ============================================================================
6
- // Noop Tracer (for fallback observe context)
7
- // ============================================================================
8
- const noopTracerSpan = {
9
- setAttribute() {
10
- // No-op
11
- },
12
- setAttributes() {
13
- // No-op
14
- },
15
- addEvent() {
16
- // No-op
17
- },
18
- recordException() {
19
- // No-op
20
- },
21
- setStatusOk() {
22
- // No-op
23
- },
24
- setStatusError() {
25
- // No-op
26
- },
27
- end() {
28
- // No-op
29
- },
30
- isRecording() {
31
- return false;
32
- },
33
- };
34
- const createNoopTracer = (name) => ({
35
- name,
36
- startSpan() {
37
- return noopTracerSpan;
38
- },
39
- });
6
+ /**
7
+ * Inject parent span into a context if we have one.
8
+ */
9
+ function injectParentSpan(ctx, parentSpan) {
10
+ return parentSpan ? trace.setSpan(ctx, parentSpan) : ctx;
11
+ }
12
+ /**
13
+ * Create a context-aware tracer wrapper that automatically injects the parent span.
14
+ * This ensures spans created by external libraries (like AI SDK) are properly linked
15
+ * to the current job/step trace hierarchy.
16
+ */
17
+ function createContextAwareTracer(tracer, parentSpan) {
18
+ return {
19
+ startSpan(name, options, ctx) {
20
+ // Always inject our parent span into the context, regardless of what context is passed.
21
+ // This is necessary because without global registration, context.active() returns
22
+ // ROOT_CONTEXT, so external libraries (like AI SDK) that pass context.active()
23
+ // would otherwise create orphan spans.
24
+ const baseContext = ctx ?? context.active();
25
+ const effectiveContext = injectParentSpan(baseContext, parentSpan);
26
+ return tracer.startSpan(name, options, effectiveContext);
27
+ },
28
+ // startActiveSpan has multiple overloads, we need to handle them all
29
+ startActiveSpan(name, optionsOrFn, ctxOrFn, fn) {
30
+ // Parse the overloaded arguments
31
+ let options;
32
+ let ctx;
33
+ let callback;
34
+ if (typeof optionsOrFn === 'function') {
35
+ // startActiveSpan(name, fn)
36
+ callback = optionsOrFn;
37
+ }
38
+ else if (typeof ctxOrFn === 'function') {
39
+ // startActiveSpan(name, options, fn)
40
+ options = optionsOrFn;
41
+ callback = ctxOrFn;
42
+ }
43
+ else {
44
+ // startActiveSpan(name, options, context, fn)
45
+ options = optionsOrFn;
46
+ ctx = ctxOrFn;
47
+ callback = fn;
48
+ }
49
+ const baseContext = ctx ?? context.active();
50
+ const effectiveContext = injectParentSpan(baseContext, parentSpan);
51
+ return tracer.startActiveSpan(name, options ?? {}, effectiveContext, callback);
52
+ },
53
+ };
54
+ }
55
+ /**
56
+ * Create a TelemetryContext that wraps an OTel span.
57
+ */
58
+ function createTelemetryContext(span, tracer) {
59
+ return {
60
+ getActiveSpan() {
61
+ return span ?? undefined;
62
+ },
63
+ getTracer(_name) {
64
+ // Return a context-aware tracer that automatically links spans to the current trace
65
+ return createContextAwareTracer(tracer, span);
66
+ },
67
+ startSpan(name, options) {
68
+ // Create a child span linked to the current span (job or step)
69
+ const parentContext = span ? trace.setSpan(context.active(), span) : context.active();
70
+ return tracer.startSpan(name, { attributes: options?.attributes }, parentContext);
71
+ },
72
+ recordMetric(name, value, attributes) {
73
+ if (span) {
74
+ span.addEvent(`metric:${name}`, {
75
+ 'metric.value': value,
76
+ ...attributes,
77
+ });
78
+ }
79
+ },
80
+ };
81
+ }
40
82
  import pRetry from './utils/p-retry.js';
41
83
  import waitForAbort from './utils/wait-for-abort.js';
42
84
  /**
@@ -128,7 +170,7 @@ export class StepManager {
128
170
  #jobId;
129
171
  #actionName;
130
172
  #stepStore;
131
- #telemetry;
173
+ #tracer;
132
174
  #queue;
133
175
  #logger;
134
176
  // each step name should be executed only once per parent (name + parentStepId)
@@ -136,7 +178,7 @@ export class StepManager {
136
178
  // Store step spans for nested step tracking
137
179
  #stepSpans = new Map();
138
180
  // Store the job span for creating step spans
139
- #jobSpan = null;
181
+ #jobSpan;
140
182
  // Factory function to create run functions with the correct parent step ID and abort signal
141
183
  #runFnFactory = null;
142
184
  // ============================================================================
@@ -151,7 +193,7 @@ export class StepManager {
151
193
  this.#jobId = options.jobId;
152
194
  this.#actionName = options.actionName;
153
195
  this.#logger = options.logger;
154
- this.#telemetry = options.telemetry;
196
+ this.#tracer = options.tracer;
155
197
  this.#stepStore = new StepStore(options.adapter);
156
198
  this.#queue = fastq.promise(async (task) => {
157
199
  // Create composite key: name + parentStepId (allows same name under different parents)
@@ -191,39 +233,22 @@ export class StepManager {
191
233
  * @param variables - Variables available to the action
192
234
  * @param abortSignal - Abort signal for cancelling the action
193
235
  * @param logger - Pino child logger for this job
194
- * @param observeContext - Observability context for telemetry
195
236
  * @returns ActionHandlerContext instance
196
237
  */
197
- createActionContext(job, action, variables, abortSignal, logger, observeContext) {
198
- return new ActionContext(this, job, action, variables, abortSignal, logger, observeContext);
238
+ createActionContext(job, action, variables, abortSignal, logger) {
239
+ const telemetryContext = createTelemetryContext(this.#jobSpan, this.#tracer);
240
+ return new ActionContext(this, job, action, variables, abortSignal, logger, telemetryContext);
199
241
  }
200
242
  /**
201
- * Create an observe context for a step.
243
+ * Create a telemetry context for a step.
202
244
  */
203
- createStepObserveContext(stepId) {
245
+ createStepTelemetryContext(stepId) {
204
246
  const stepSpan = this.#stepSpans.get(stepId);
205
247
  if (stepSpan) {
206
- return this.#telemetry.createObserveContext(this.#jobId, stepId, stepSpan);
248
+ return createTelemetryContext(stepSpan, this.#tracer);
207
249
  }
208
250
  // Fallback to job span if step span not found
209
- if (this.#jobSpan) {
210
- return this.#telemetry.createObserveContext(this.#jobId, stepId, this.#jobSpan);
211
- }
212
- // No-op observe context
213
- return {
214
- recordMetric: () => {
215
- // No-op
216
- },
217
- addSpanAttribute: () => {
218
- // No-op
219
- },
220
- addSpanEvent: () => {
221
- // No-op
222
- },
223
- getTracer: (name) => {
224
- return createNoopTracer(name);
225
- },
226
- };
251
+ return createTelemetryContext(this.#jobSpan, this.#tracer);
227
252
  }
228
253
  /**
229
254
  * Queue a step task for execution.
@@ -283,15 +308,18 @@ export class StepManager {
283
308
  throw new NonRetriableError(`Failed to create step "${name}" for job "${this.#jobId}" action "${this.#actionName}"`, { cause: 'step not created' });
284
309
  }
285
310
  step = newStep;
286
- // Start step telemetry span
311
+ // Start step span - uses no-op tracer if no SDK is configured
287
312
  const parentSpan = parentStepId ? this.#stepSpans.get(parentStepId) : this.#jobSpan;
288
- const stepSpan = await this.#telemetry.startStepSpan({
289
- jobId: this.#jobId,
290
- stepId: step.id,
291
- stepName: name,
292
- parentSpan: parentSpan ?? undefined,
293
- parentStepId,
294
- });
313
+ const parentContext = parentSpan ? trace.setSpan(context.active(), parentSpan) : context.active();
314
+ const stepSpan = this.#tracer.startSpan(`step:${name}`, {
315
+ kind: SpanKind.INTERNAL,
316
+ attributes: {
317
+ 'duron.job.id': this.#jobId,
318
+ 'duron.step.id': step.id,
319
+ 'duron.step.name': name,
320
+ 'duron.step.parent_id': parentStepId ?? undefined,
321
+ },
322
+ }, parentContext);
295
323
  this.#stepSpans.set(step.id, stepSpan);
296
324
  if (abortSignal.aborted) {
297
325
  throw new ActionCancelError(this.#actionName, this.#jobId, { cause: 'step cancelled after create step' });
@@ -329,14 +357,14 @@ export class StepManager {
329
357
  // Create abort controller for child steps (used when parent returns with pending children)
330
358
  const childAbortController = new AbortController();
331
359
  const childSignal = AbortSignal.any([stepSignal, childAbortController.signal]);
332
- // Create observe context for this step
333
- const stepObserveContext = this.createStepObserveContext(step.id);
360
+ // Create telemetry context for this step
361
+ const stepTelemetryContext = this.createStepTelemetryContext(step.id);
334
362
  // Create StepHandlerContext with nested step support
335
363
  const stepContext = {
336
364
  signal: stepSignal,
337
365
  stepId: step.id,
338
366
  parentStepId,
339
- observe: stepObserveContext,
367
+ telemetry: stepTelemetryContext,
340
368
  step: (childName, childCb, childOptions = {}) => {
341
369
  // Inherit parent step options EXCEPT parallel (each step's parallel status is independent)
342
370
  const { parallel: _parentParallel, ...inheritableOptions } = options;
@@ -377,7 +405,10 @@ export class StepManager {
377
405
  try {
378
406
  // Race between abort signal and callback execution
379
407
  const abortPromise = waitForAbort(stepSignal);
380
- const callbackPromise = cb(stepContext);
408
+ // Execute callback within the span context so that child spans inherit the trace
409
+ const currentStepSpan = step?.id ? this.#stepSpans.get(step.id) : undefined;
410
+ const spanContext = currentStepSpan ? trace.setSpan(context.active(), currentStepSpan) : context.active();
411
+ const callbackPromise = context.with(spanContext, () => cb(stepContext));
381
412
  let result = null;
382
413
  let aborted = false;
383
414
  let callbackError = null;
@@ -445,10 +476,11 @@ export class StepManager {
445
476
  if (!completed) {
446
477
  throw new Error(`Failed to complete step "${name}" for job "${this.#jobId}" action "${this.#actionName}"`);
447
478
  }
448
- // End step telemetry span successfully
479
+ // End step span successfully
449
480
  const stepSpan = this.#stepSpans.get(step.id);
450
481
  if (stepSpan) {
451
- await this.#telemetry.endStepSpan(stepSpan, { status: 'ok' });
482
+ stepSpan.setStatus({ code: SpanStatusCode.OK });
483
+ stepSpan.end();
452
484
  this.#stepSpans.delete(step.id);
453
485
  }
454
486
  // Log step completion
@@ -514,15 +546,22 @@ export class StepManager {
514
546
  },
515
547
  }).catch(async (error) => {
516
548
  if (step) {
517
- // End step telemetry span with error/cancelled status
549
+ // End step span with error/cancelled status
518
550
  const stepSpan = this.#stepSpans.get(step.id);
519
551
  if (stepSpan) {
520
552
  if (isCancelError(error)) {
521
- await this.#telemetry.endStepSpan(stepSpan, { status: 'cancelled' });
553
+ stepSpan.setStatus({ code: SpanStatusCode.ERROR, message: 'Step cancelled' });
522
554
  }
523
555
  else {
524
- await this.#telemetry.endStepSpan(stepSpan, { status: 'error', error });
556
+ stepSpan.setStatus({
557
+ code: SpanStatusCode.ERROR,
558
+ message: error instanceof Error ? error.message : String(error),
559
+ });
560
+ if (error instanceof Error) {
561
+ stepSpan.recordException(error);
562
+ }
525
563
  }
564
+ stepSpan.end();
526
565
  this.#stepSpans.delete(step.id);
527
566
  }
528
567
  if (isCancelError(error)) {
@@ -564,11 +603,11 @@ class ActionContext {
564
603
  #jobId;
565
604
  #groupKey = '@default';
566
605
  #action;
567
- #observeContext;
606
+ #telemetryContext;
568
607
  // ============================================================================
569
608
  // Constructor
570
609
  // ============================================================================
571
- constructor(stepManager, job, action, variables, abortSignal, logger, observeContext) {
610
+ constructor(stepManager, job, action, variables, abortSignal, logger, telemetryContext) {
572
611
  this.#stepManager = stepManager;
573
612
  this.#variables = variables;
574
613
  this.#abortSignal = abortSignal;
@@ -576,7 +615,7 @@ class ActionContext {
576
615
  this.#action = action;
577
616
  this.#jobId = job.id;
578
617
  this.#groupKey = job.groupKey ?? '@default';
579
- this.#observeContext = observeContext;
618
+ this.#telemetryContext = telemetryContext;
580
619
  if (action.input) {
581
620
  this.#input = action.input.parse(job.input, {
582
621
  error: () => 'Error parsing action input',
@@ -629,10 +668,10 @@ class ActionContext {
629
668
  return this.#logger;
630
669
  }
631
670
  /**
632
- * Get the observability context for recording metrics and span data.
671
+ * Get the telemetry context for recording metrics and span data.
633
672
  */
634
- get observe() {
635
- return this.#observeContext;
673
+ get telemetry() {
674
+ return this.#telemetryContext;
636
675
  }
637
676
  /**
638
677
  * Execute a step within the action.
@@ -1,5 +1,2 @@
1
- export { type AddSpanAttributeOptions, type AddSpanEventOptions, type EndSpanOptions, type ObserveContext, type RecordMetricOptions, type Span, type StartDatabaseSpanOptions, type StartJobSpanOptions, type StartSpanOptions, type StartStepSpanOptions, TelemetryAdapter, type Tracer, type TracerSpan, } from './adapter.js';
2
- export { LocalTelemetryAdapter, type LocalTelemetryAdapterOptions, localTelemetryAdapter } from './local.js';
3
- export { NoopTelemetryAdapter, noopTelemetryAdapter } from './noop.js';
4
- export { OpenTelemetryAdapter, type OpenTelemetryAdapterOptions, openTelemetryAdapter } from './opentelemetry.js';
1
+ export { LocalSpanExporter, type LocalSpanExporterOptions } from './local-span-exporter.js';
5
2
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/telemetry/index.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,KAAK,uBAAuB,EAC5B,KAAK,mBAAmB,EACxB,KAAK,cAAc,EACnB,KAAK,cAAc,EACnB,KAAK,mBAAmB,EACxB,KAAK,IAAI,EACT,KAAK,wBAAwB,EAC7B,KAAK,mBAAmB,EACxB,KAAK,gBAAgB,EACrB,KAAK,oBAAoB,EACzB,gBAAgB,EAChB,KAAK,MAAM,EACX,KAAK,UAAU,GAChB,MAAM,cAAc,CAAA;AACrB,OAAO,EAAE,qBAAqB,EAAE,KAAK,4BAA4B,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAA;AAC5G,OAAO,EAAE,oBAAoB,EAAE,oBAAoB,EAAE,MAAM,WAAW,CAAA;AACtE,OAAO,EAAE,oBAAoB,EAAE,KAAK,2BAA2B,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/telemetry/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,iBAAiB,EAAE,KAAK,wBAAwB,EAAE,MAAM,0BAA0B,CAAA"}
@@ -1,5 +1,2 @@
1
- // Re-export telemetry adapters and types
2
- export { TelemetryAdapter, } from './adapter.js';
3
- export { LocalTelemetryAdapter, localTelemetryAdapter } from './local.js';
4
- export { NoopTelemetryAdapter, noopTelemetryAdapter } from './noop.js';
5
- export { OpenTelemetryAdapter, openTelemetryAdapter } from './opentelemetry.js';
1
+ // OpenTelemetry-based span exporter for local database persistence
2
+ export { LocalSpanExporter } from './local-span-exporter.js';
@@ -0,0 +1,56 @@
1
+ import { type ExportResult } from '@opentelemetry/core';
2
+ import type { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base';
3
+ import type { Adapter } from '../adapters/adapter.js';
4
+ /**
5
+ * Configuration options for the LocalSpanExporter.
6
+ */
7
+ export interface LocalSpanExporterOptions {
8
+ /**
9
+ * The database adapter to use for storing spans.
10
+ * This is the same adapter used by the Duron Client.
11
+ */
12
+ adapter: Adapter;
13
+ }
14
+ /**
15
+ * A custom OpenTelemetry SpanExporter that stores spans locally in the database.
16
+ *
17
+ * This exporter converts OpenTelemetry ReadableSpan objects into database records
18
+ * and inserts them via the Duron Adapter interface.
19
+ *
20
+ * It extracts `duron.job.id` and `duron.step.id` from span attributes to link
21
+ * spans to Duron jobs and steps.
22
+ *
23
+ * @example
24
+ * ```typescript
25
+ * import { LocalSpanExporter } from 'duron/telemetry'
26
+ * import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'
27
+ *
28
+ * const exporter = new LocalSpanExporter({ adapter })
29
+ * const processor = new BatchSpanProcessor(exporter)
30
+ * ```
31
+ */
32
+ export declare class LocalSpanExporter implements SpanExporter {
33
+ #private;
34
+ constructor(options: LocalSpanExporterOptions);
35
+ /**
36
+ * Export spans to the local database.
37
+ *
38
+ * Converts ReadableSpan objects to database records and inserts them.
39
+ * Follows OpenTelemetry exporter rules:
40
+ * - Does not throw exceptions
41
+ * - Does not modify received spans
42
+ * - Does not implement queuing/batching (handled by SpanProcessor)
43
+ */
44
+ export(spans: ReadableSpan[], resultCallback: (result: ExportResult) => void): void;
45
+ /**
46
+ * Shutdown the exporter.
47
+ * After shutdown, no more spans will be exported.
48
+ */
49
+ shutdown(): Promise<void>;
50
+ /**
51
+ * Force flush any pending exports.
52
+ * Since we export synchronously to the database, this is a no-op.
53
+ */
54
+ forceFlush(): Promise<void>;
55
+ }
56
+ //# sourceMappingURL=local-span-exporter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"local-span-exporter.d.ts","sourceRoot":"","sources":["../../src/telemetry/local-span-exporter.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,YAAY,EAAoB,MAAM,qBAAqB,CAAA;AACzE,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,+BAA+B,CAAA;AAE/E,OAAO,KAAK,EAAE,OAAO,EAAqB,MAAM,wBAAwB,CAAA;AAExE;;GAEG;AACH,MAAM,WAAW,wBAAwB;IACvC;;;OAGG;IACH,OAAO,EAAE,OAAO,CAAA;CACjB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,qBAAa,iBAAkB,YAAW,YAAY;;gBAIxC,OAAO,EAAE,wBAAwB;IAI7C;;;;;;;;OAQG;IACH,MAAM,CAAC,KAAK,EAAE,YAAY,EAAE,EAAE,cAAc,EAAE,CAAC,MAAM,EAAE,YAAY,KAAK,IAAI,GAAG,IAAI;IAenF;;;OAGG;IACG,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;IAI/B;;;OAGG;IACG,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;CAoElC"}
@@ -0,0 +1,118 @@
1
+ import { ExportResultCode } from '@opentelemetry/core';
2
+ /**
3
+ * A custom OpenTelemetry SpanExporter that stores spans locally in the database.
4
+ *
5
+ * This exporter converts OpenTelemetry ReadableSpan objects into database records
6
+ * and inserts them via the Duron Adapter interface.
7
+ *
8
+ * It extracts `duron.job.id` and `duron.step.id` from span attributes to link
9
+ * spans to Duron jobs and steps.
10
+ *
11
+ * @example
12
+ * ```typescript
13
+ * import { LocalSpanExporter } from 'duron/telemetry'
14
+ * import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'
15
+ *
16
+ * const exporter = new LocalSpanExporter({ adapter })
17
+ * const processor = new BatchSpanProcessor(exporter)
18
+ * ```
19
+ */
20
+ export class LocalSpanExporter {
21
+ #adapter;
22
+ #shutdown = false;
23
+ constructor(options) {
24
+ this.#adapter = options.adapter;
25
+ }
26
+ /**
27
+ * Export spans to the local database.
28
+ *
29
+ * Converts ReadableSpan objects to database records and inserts them.
30
+ * Follows OpenTelemetry exporter rules:
31
+ * - Does not throw exceptions
32
+ * - Does not modify received spans
33
+ * - Does not implement queuing/batching (handled by SpanProcessor)
34
+ */
35
+ export(spans, resultCallback) {
36
+ if (this.#shutdown) {
37
+ resultCallback({ code: ExportResultCode.FAILED, error: new Error('Exporter has been shutdown') });
38
+ return;
39
+ }
40
+ this.#exportSpans(spans)
41
+ .then(() => {
42
+ resultCallback({ code: ExportResultCode.SUCCESS });
43
+ })
44
+ .catch((error) => {
45
+ resultCallback({ code: ExportResultCode.FAILED, error });
46
+ });
47
+ }
48
+ /**
49
+ * Shutdown the exporter.
50
+ * After shutdown, no more spans will be exported.
51
+ */
52
+ async shutdown() {
53
+ this.#shutdown = true;
54
+ }
55
+ /**
56
+ * Force flush any pending exports.
57
+ * Since we export synchronously to the database, this is a no-op.
58
+ */
59
+ async forceFlush() {
60
+ // No-op: we don't buffer spans, they're exported immediately
61
+ }
62
+ /**
63
+ * Internal method to export spans to the database.
64
+ */
65
+ async #exportSpans(spans) {
66
+ if (spans.length === 0) {
67
+ return;
68
+ }
69
+ const records = spans.map((span) => this.#readableSpanToRecord(span));
70
+ await this.#adapter.insertSpans(records);
71
+ }
72
+ /**
73
+ * Convert a ReadableSpan to an InsertSpanOptions record.
74
+ */
75
+ #readableSpanToRecord(span) {
76
+ const spanContext = span.spanContext();
77
+ // Extract Duron-specific attributes
78
+ const jobId = span.attributes['duron.job.id'];
79
+ const stepId = span.attributes['duron.step.id'];
80
+ // Convert attributes to plain object, excluding only job.id and step.id
81
+ // (those are stored in separate columns)
82
+ const attributes = {};
83
+ for (const [key, value] of Object.entries(span.attributes)) {
84
+ if (key !== 'duron.job.id' && key !== 'duron.step.id') {
85
+ attributes[key] = value;
86
+ }
87
+ }
88
+ // Convert events
89
+ const events = span.events.map((event) => ({
90
+ name: event.name,
91
+ timeUnixNano: this.#hrTimeToNanos(event.time).toString(),
92
+ attributes: event.attributes,
93
+ }));
94
+ // Get parent span ID from parentSpanContext if available
95
+ const parentSpanId = span.parentSpanContext?.spanId || null;
96
+ return {
97
+ traceId: spanContext.traceId,
98
+ spanId: spanContext.spanId,
99
+ parentSpanId,
100
+ jobId: jobId || null,
101
+ stepId: stepId || null,
102
+ name: span.name,
103
+ kind: span.kind,
104
+ startTimeUnixNano: this.#hrTimeToNanos(span.startTime),
105
+ endTimeUnixNano: span.endTime ? this.#hrTimeToNanos(span.endTime) : null,
106
+ statusCode: span.status.code,
107
+ statusMessage: span.status.message || null,
108
+ attributes,
109
+ events,
110
+ };
111
+ }
112
+ /**
113
+ * Convert HrTime [seconds, nanoseconds] to bigint nanoseconds.
114
+ */
115
+ #hrTimeToNanos(hrTime) {
116
+ return BigInt(hrTime[0]) * BigInt(1_000_000_000) + BigInt(hrTime[1]);
117
+ }
118
+ }
@@ -43,17 +43,23 @@ CREATE TABLE "duron"."jobs" (
43
43
  CONSTRAINT "jobs_status_check" CHECK ("status" IN ('created','active','completed','failed','cancelled'))
44
44
  );
45
45
  --> statement-breakpoint
46
- CREATE TABLE "duron"."metrics" (
47
- "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
48
- "job_id" uuid NOT NULL,
46
+ CREATE TABLE "duron"."spans" (
47
+ "id" bigserial PRIMARY KEY,
48
+ "trace_id" text NOT NULL,
49
+ "span_id" text NOT NULL,
50
+ "parent_span_id" text,
51
+ "job_id" uuid,
49
52
  "step_id" uuid,
50
53
  "name" text NOT NULL,
51
- "value" double precision NOT NULL,
54
+ "kind" integer DEFAULT 0 NOT NULL,
55
+ "start_time_unix_nano" bigint NOT NULL,
56
+ "end_time_unix_nano" bigint,
57
+ "status_code" integer DEFAULT 0 NOT NULL,
58
+ "status_message" text,
52
59
  "attributes" jsonb DEFAULT '{}' NOT NULL,
53
- "type" text NOT NULL,
54
- "timestamp" timestamp with time zone DEFAULT now() NOT NULL,
55
- "created_at" timestamp with time zone DEFAULT now() NOT NULL,
56
- CONSTRAINT "metrics_type_check" CHECK ("type" IN ('metric', 'span_event', 'span_attribute'))
60
+ "events" jsonb DEFAULT '[]' NOT NULL,
61
+ CONSTRAINT "spans_kind_check" CHECK ("kind" IN (0, 1, 2, 3, 4)),
62
+ CONSTRAINT "spans_status_code_check" CHECK ("status_code" IN (0, 1, 2))
57
63
  );
58
64
  --> statement-breakpoint
59
65
  CREATE INDEX "idx_job_steps_job_id" ON "duron"."job_steps" ("job_id");--> statement-breakpoint
@@ -77,15 +83,17 @@ CREATE INDEX "idx_jobs_action_status" ON "duron"."jobs" ("action_name","status")
77
83
  CREATE INDEX "idx_jobs_action_group" ON "duron"."jobs" ("action_name","group_key");--> statement-breakpoint
78
84
  CREATE INDEX "idx_jobs_input_fts" ON "duron"."jobs" USING gin (to_tsvector('english', "input"::text));--> statement-breakpoint
79
85
  CREATE INDEX "idx_jobs_output_fts" ON "duron"."jobs" USING gin (to_tsvector('english', "output"::text));--> statement-breakpoint
80
- CREATE INDEX "idx_metrics_job_id" ON "duron"."metrics" ("job_id");--> statement-breakpoint
81
- CREATE INDEX "idx_metrics_step_id" ON "duron"."metrics" ("step_id");--> statement-breakpoint
82
- CREATE INDEX "idx_metrics_name" ON "duron"."metrics" ("name");--> statement-breakpoint
83
- CREATE INDEX "idx_metrics_type" ON "duron"."metrics" ("type");--> statement-breakpoint
84
- CREATE INDEX "idx_metrics_timestamp" ON "duron"."metrics" ("timestamp");--> statement-breakpoint
85
- CREATE INDEX "idx_metrics_job_step" ON "duron"."metrics" ("job_id","step_id");--> statement-breakpoint
86
- CREATE INDEX "idx_metrics_job_name" ON "duron"."metrics" ("job_id","name");--> statement-breakpoint
87
- CREATE INDEX "idx_metrics_job_type" ON "duron"."metrics" ("job_id","type");--> statement-breakpoint
88
- CREATE INDEX "idx_metrics_attributes" ON "duron"."metrics" USING gin ("attributes");--> statement-breakpoint
86
+ CREATE INDEX "idx_spans_trace_id" ON "duron"."spans" ("trace_id");--> statement-breakpoint
87
+ CREATE INDEX "idx_spans_span_id" ON "duron"."spans" ("span_id");--> statement-breakpoint
88
+ CREATE INDEX "idx_spans_job_id" ON "duron"."spans" ("job_id");--> statement-breakpoint
89
+ CREATE INDEX "idx_spans_step_id" ON "duron"."spans" ("step_id");--> statement-breakpoint
90
+ CREATE INDEX "idx_spans_name" ON "duron"."spans" ("name");--> statement-breakpoint
91
+ CREATE INDEX "idx_spans_kind" ON "duron"."spans" ("kind");--> statement-breakpoint
92
+ CREATE INDEX "idx_spans_status_code" ON "duron"."spans" ("status_code");--> statement-breakpoint
93
+ CREATE INDEX "idx_spans_job_step" ON "duron"."spans" ("job_id","step_id");--> statement-breakpoint
94
+ CREATE INDEX "idx_spans_trace_parent" ON "duron"."spans" ("trace_id","parent_span_id");--> statement-breakpoint
95
+ CREATE INDEX "idx_spans_attributes" ON "duron"."spans" USING gin ("attributes");--> statement-breakpoint
96
+ CREATE INDEX "idx_spans_events" ON "duron"."spans" USING gin ("events");--> statement-breakpoint
89
97
  ALTER TABLE "duron"."job_steps" ADD CONSTRAINT "job_steps_job_id_jobs_id_fkey" FOREIGN KEY ("job_id") REFERENCES "duron"."jobs"("id") ON DELETE CASCADE;--> statement-breakpoint
90
- ALTER TABLE "duron"."metrics" ADD CONSTRAINT "metrics_job_id_jobs_id_fkey" FOREIGN KEY ("job_id") REFERENCES "duron"."jobs"("id") ON DELETE CASCADE;--> statement-breakpoint
91
- ALTER TABLE "duron"."metrics" ADD CONSTRAINT "metrics_step_id_job_steps_id_fkey" FOREIGN KEY ("step_id") REFERENCES "duron"."job_steps"("id") ON DELETE CASCADE;
98
+ ALTER TABLE "duron"."spans" ADD CONSTRAINT "spans_job_id_jobs_id_fkey" FOREIGN KEY ("job_id") REFERENCES "duron"."jobs"("id") ON DELETE CASCADE;--> statement-breakpoint
99
+ ALTER TABLE "duron"."spans" ADD CONSTRAINT "spans_step_id_job_steps_id_fkey" FOREIGN KEY ("step_id") REFERENCES "duron"."job_steps"("id") ON DELETE CASCADE;