duron 0.3.0-beta.0 → 0.3.0-beta.10

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 (55) hide show
  1. package/dist/action-job.d.ts.map +1 -1
  2. package/dist/action-job.js +9 -7
  3. package/dist/action.d.ts +21 -0
  4. package/dist/action.d.ts.map +1 -1
  5. package/dist/action.js +10 -2
  6. package/dist/adapters/postgres/base.d.ts.map +1 -1
  7. package/dist/adapters/postgres/base.js +5 -2
  8. package/dist/adapters/postgres/schema.d.ts.map +1 -1
  9. package/dist/adapters/postgres/schema.js +3 -1
  10. package/dist/client.d.ts +25 -1
  11. package/dist/client.d.ts.map +1 -1
  12. package/dist/client.js +102 -2
  13. package/dist/errors.d.ts +47 -9
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js +78 -19
  16. package/dist/index.d.ts +1 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +1 -1
  19. package/dist/step-manager.d.ts +1 -0
  20. package/dist/step-manager.d.ts.map +1 -1
  21. package/dist/step-manager.js +151 -7
  22. package/dist/telemetry/adapter.d.ts +22 -0
  23. package/dist/telemetry/adapter.d.ts.map +1 -1
  24. package/dist/telemetry/adapter.js +6 -0
  25. package/dist/telemetry/index.d.ts +1 -1
  26. package/dist/telemetry/index.d.ts.map +1 -1
  27. package/dist/telemetry/local.d.ts +2 -1
  28. package/dist/telemetry/local.d.ts.map +1 -1
  29. package/dist/telemetry/local.js +63 -0
  30. package/dist/telemetry/noop.d.ts +2 -1
  31. package/dist/telemetry/noop.d.ts.map +1 -1
  32. package/dist/telemetry/noop.js +27 -0
  33. package/dist/telemetry/opentelemetry.d.ts +2 -1
  34. package/dist/telemetry/opentelemetry.d.ts.map +1 -1
  35. package/dist/telemetry/opentelemetry.js +110 -0
  36. package/migrations/postgres/{20251203223656_conscious_johnny_blaze → 20260119153838_flimsy_thor_girl}/migration.sql +29 -2
  37. package/migrations/postgres/{20260118202533_wealthy_mysterio → 20260119153838_flimsy_thor_girl}/snapshot.json +5 -5
  38. package/package.json +1 -1
  39. package/src/action-job.ts +14 -7
  40. package/src/action.ts +156 -3
  41. package/src/adapters/postgres/base.ts +5 -2
  42. package/src/adapters/postgres/schema.ts +5 -2
  43. package/src/client.ts +187 -8
  44. package/src/errors.ts +141 -30
  45. package/src/index.ts +7 -1
  46. package/src/step-manager.ts +230 -8
  47. package/src/telemetry/adapter.ts +174 -0
  48. package/src/telemetry/index.ts +3 -0
  49. package/src/telemetry/local.ts +93 -0
  50. package/src/telemetry/noop.ts +46 -0
  51. package/src/telemetry/opentelemetry.ts +145 -2
  52. package/migrations/postgres/20251203223656_conscious_johnny_blaze/snapshot.json +0 -941
  53. package/migrations/postgres/20260117231749_clumsy_penance/migration.sql +0 -3
  54. package/migrations/postgres/20260117231749_clumsy_penance/snapshot.json +0 -988
  55. package/migrations/postgres/20260118202533_wealthy_mysterio/migration.sql +0 -24
@@ -5,6 +5,8 @@ import type { z } from 'zod'
5
5
  import {
6
6
  type Action,
7
7
  type ActionHandlerContext,
8
+ type StepDefinition,
9
+ type StepDefinitionHandlerContext,
8
10
  type StepHandlerContext,
9
11
  type StepOptions,
10
12
  StepOptionsSchema,
@@ -15,13 +17,53 @@ import {
15
17
  ActionCancelError,
16
18
  isCancelError,
17
19
  isNonRetriableError,
20
+ isTimeoutError,
18
21
  NonRetriableError,
19
22
  StepAlreadyExecutedError,
20
23
  StepTimeoutError,
21
24
  serializeError,
22
25
  UnhandledChildStepsError,
23
26
  } from './errors.js'
24
- import type { ObserveContext, Span, TelemetryAdapter } from './telemetry/adapter.js'
27
+ import type { ObserveContext, Span, TelemetryAdapter, Tracer, TracerSpan } from './telemetry/adapter.js'
28
+
29
+ // ============================================================================
30
+ // Noop Tracer (for fallback observe context)
31
+ // ============================================================================
32
+
33
+ const noopTracerSpan: TracerSpan = {
34
+ setAttribute() {
35
+ // No-op
36
+ },
37
+ setAttributes() {
38
+ // No-op
39
+ },
40
+ addEvent() {
41
+ // No-op
42
+ },
43
+ recordException() {
44
+ // No-op
45
+ },
46
+ setStatusOk() {
47
+ // No-op
48
+ },
49
+ setStatusError() {
50
+ // No-op
51
+ },
52
+ end() {
53
+ // No-op
54
+ },
55
+ isRecording() {
56
+ return false
57
+ },
58
+ }
59
+
60
+ const createNoopTracer = (name: string): Tracer => ({
61
+ name,
62
+ startSpan(): TracerSpan {
63
+ return noopTracerSpan
64
+ },
65
+ })
66
+
25
67
  import pRetry from './utils/p-retry.js'
26
68
  import waitForAbort from './utils/wait-for-abort.js'
27
69
 
@@ -146,12 +188,14 @@ export class StepManager {
146
188
  #telemetry: TelemetryAdapter
147
189
  #queue: fastq.queueAsPromised<TaskStep, any>
148
190
  #logger: Logger
149
- // each step name should be executed only once per action job
191
+ // each step name should be executed only once per parent (name + parentStepId)
150
192
  #historySteps = new Set<string>()
151
193
  // Store step spans for nested step tracking
152
194
  #stepSpans = new Map<string, Span>()
153
195
  // Store the job span for creating step spans
154
196
  #jobSpan: Span | null = null
197
+ // Factory function to create run functions with the correct parent step ID and abort signal
198
+ #runFnFactory: ((parentStepId: string | null, abortSignal: AbortSignal) => StepHandlerContext['run']) | null = null
155
199
 
156
200
  // ============================================================================
157
201
  // Constructor
@@ -169,10 +213,12 @@ export class StepManager {
169
213
  this.#telemetry = options.telemetry
170
214
  this.#stepStore = new StepStore(options.adapter)
171
215
  this.#queue = fastq.promise(async (task: TaskStep) => {
172
- if (this.#historySteps.has(task.name)) {
216
+ // Create composite key: name + parentStepId (allows same name under different parents)
217
+ const stepKey = `${task.parentStepId ?? 'root'}:${task.name}`
218
+ if (this.#historySteps.has(stepKey)) {
173
219
  throw new StepAlreadyExecutedError(task.name, this.#jobId, this.#actionName)
174
220
  }
175
- this.#historySteps.add(task.name)
221
+ this.#historySteps.add(stepKey)
176
222
  return this.#executeStep(task.name, task.cb, task.options, task.abortSignal, task.parentStepId, task.parallel)
177
223
  }, options.concurrencyLimit)
178
224
  }
@@ -185,6 +231,16 @@ export class StepManager {
185
231
  this.#jobSpan = span
186
232
  }
187
233
 
234
+ /**
235
+ * Set the run function factory for executing step definitions from inline steps.
236
+ * Called from ActionContext after it's initialized.
237
+ *
238
+ * @param factory - A function that creates run functions with the correct parent step ID and abort signal
239
+ */
240
+ setRunFnFactory(factory: (parentStepId: string | null, abortSignal: AbortSignal) => StepHandlerContext['run']): void {
241
+ this.#runFnFactory = factory
242
+ }
243
+
188
244
  // ============================================================================
189
245
  // Public API Methods
190
246
  // ============================================================================
@@ -235,6 +291,9 @@ export class StepManager {
235
291
  addSpanEvent: () => {
236
292
  // No-op
237
293
  },
294
+ getTracer: (name: string) => {
295
+ return createNoopTracer(name)
296
+ },
238
297
  }
239
298
  }
240
299
 
@@ -245,6 +304,21 @@ export class StepManager {
245
304
  * @returns Promise resolving to the step result
246
305
  */
247
306
  async push(task: TaskStep): Promise<any> {
307
+ // Warn about potential starvation when child steps are queued and all slots are occupied
308
+ if (task.parentStepId !== null && this.#queue.running() >= this.#queue.concurrency) {
309
+ this.#logger.warn(
310
+ {
311
+ jobId: this.#jobId,
312
+ actionName: this.#actionName,
313
+ stepName: task.name,
314
+ parentStepId: task.parentStepId,
315
+ running: this.#queue.running(),
316
+ waiting: this.#queue.length(),
317
+ concurrency: this.#queue.concurrency,
318
+ },
319
+ '[StepManager] Potential starvation: child step queued while all concurrency slots are occupied by parent steps. Consider increasing steps.concurrency.',
320
+ )
321
+ }
248
322
  return this.#queue.push(task)
249
323
  }
250
324
 
@@ -353,7 +427,11 @@ export class StepManager {
353
427
  // Create abort controller for this step's timeout
354
428
  const stepAbortController = new AbortController()
355
429
  const timeoutId = setTimeout(() => {
356
- const timeoutError = new StepTimeoutError(name, this.#jobId, expire)
430
+ const timeoutError = new StepTimeoutError(name, this.#jobId, expire, {
431
+ stepId: step?.id,
432
+ parentStepId,
433
+ actionName: this.#actionName,
434
+ })
357
435
  stepAbortController.abort(timeoutError)
358
436
  }, expire)
359
437
 
@@ -420,10 +498,12 @@ export class StepManager {
420
498
  .catch(() => {
421
499
  trackedChild.settled = true
422
500
  // Swallow the error here - it will be re-thrown to the caller via the returned promise
501
+ // Note: sibling steps will be aborted when the error propagates to the action level
423
502
  })
424
503
 
425
504
  return childPromise
426
505
  },
506
+ run: this.#runFnFactory!(step.id, childSignal),
427
507
  }
428
508
 
429
509
  try {
@@ -433,6 +513,7 @@ export class StepManager {
433
513
 
434
514
  let result: any = null
435
515
  let aborted = false
516
+ let callbackError: Error | null = null
436
517
 
437
518
  await Promise.race([
438
519
  abortPromise.promise.then(() => {
@@ -444,11 +525,25 @@ export class StepManager {
444
525
  result = res
445
526
  }
446
527
  })
528
+ .catch((err) => {
529
+ callbackError = err
530
+ })
447
531
  .finally(() => {
448
532
  abortPromise.release()
449
533
  }),
450
534
  ])
451
535
 
536
+ // If callback threw an error, abort children and wait for them before re-throwing
537
+ if (callbackError) {
538
+ if (childSteps.length > 0) {
539
+ // Abort all children with the callback error as reason
540
+ childAbortController.abort(callbackError)
541
+ // Wait for all children to settle
542
+ await Promise.allSettled(childSteps.map((c) => c.promise))
543
+ }
544
+ throw callbackError
545
+ }
546
+
452
547
  // If aborted, wait for child steps to settle before propagating
453
548
  if (aborted) {
454
549
  // Wait for all child steps to settle (they'll be aborted via signal propagation)
@@ -474,7 +569,12 @@ export class StepManager {
474
569
  )
475
570
 
476
571
  // Abort all pending children
477
- const unhandledError = new UnhandledChildStepsError(name, unsettledChildren.length)
572
+ const unhandledError = new UnhandledChildStepsError(name, unsettledChildren.length, {
573
+ stepId: step.id,
574
+ parentStepId,
575
+ jobId: this.#jobId,
576
+ actionName: this.#actionName,
577
+ })
478
578
  childAbortController.abort(unhandledError)
479
579
 
480
580
  // Wait for all children to settle (they'll reject with cancellation)
@@ -523,16 +623,46 @@ export class StepManager {
523
623
  if (
524
624
  isNonRetriableError(error) ||
525
625
  (error.cause && isNonRetriableError(error.cause)) ||
526
- (error instanceof Error && error.name === 'AbortError' && isNonRetriableError(error.cause))
626
+ (error instanceof Error && error.name === 'AbortError')
527
627
  ) {
528
- throw error
628
+ const err = isNonRetriableError(error)
629
+ ? error
630
+ : error instanceof Error && error.name === 'AbortError'
631
+ ? new NonRetriableError(error.message, { cause: error.cause })
632
+ : (error.cause as NonRetriableError)
633
+
634
+ if (Object.keys(err.metadata).length === 0) {
635
+ err.setMetadata({
636
+ stepId: step?.id,
637
+ parentStepId,
638
+ jobId: this.#jobId,
639
+ actionName: this.#actionName,
640
+ })
641
+ }
642
+ throw err
529
643
  }
530
644
 
531
645
  if (ctx.retriesLeft > 0 && step) {
646
+ this.#clearHistoryForStep(step.id)
532
647
  const delayed = await this.#stepStore.delay(step.id, ctx.finalDelay, serializeError(error))
533
648
  if (!delayed) {
534
649
  throw new Error(`Failed to delay step "${name}" for job "${this.#jobId}" action "${this.#actionName}"`)
535
650
  }
651
+ } else {
652
+ if (isTimeoutError(error)) {
653
+ ;(error as any).nonRetriable = true
654
+ throw error
655
+ }
656
+
657
+ const errorMessage = error instanceof Error ? error.message : String(error)
658
+ const err = new NonRetriableError(errorMessage, { cause: error })
659
+ err.setMetadata({
660
+ stepId: step?.id,
661
+ parentStepId,
662
+ jobId: this.#jobId,
663
+ actionName: this.#actionName,
664
+ })
665
+ throw err
536
666
  }
537
667
  },
538
668
  }).catch(async (error) => {
@@ -557,6 +687,19 @@ export class StepManager {
557
687
  throw error
558
688
  })
559
689
  }
690
+
691
+ /**
692
+ * Clear the history of nested steps for a given step.
693
+ * We do't need to clear the history for the root step because it's not a parent step, it's the action itself.
694
+ * @param stepId - The ID of the step to clear the history for
695
+ */
696
+ #clearHistoryForStep(stepId: string): void {
697
+ this.#historySteps.forEach((stepKey) => {
698
+ if (stepKey.startsWith(stepId)) {
699
+ this.#historySteps.delete(stepKey)
700
+ }
701
+ })
702
+ }
560
703
  }
561
704
 
562
705
  // ============================================================================
@@ -609,6 +752,11 @@ class ActionContext<TInput extends z.ZodObject, TOutput extends z.ZodObject, TVa
609
752
  }
610
753
  this.#input = job.input ?? {}
611
754
  this.step = this.step.bind(this)
755
+ this.run = this.run.bind(this)
756
+ // Set the run function factory so inline steps can call step definitions with correct parent
757
+ this.#stepManager.setRunFnFactory((parentStepId, abortSignal) => {
758
+ return (stepDef, input, options) => this.#runInternal(stepDef, input, options, parentStepId, abortSignal)
759
+ })
612
760
  }
613
761
 
614
762
  // ============================================================================
@@ -679,6 +827,7 @@ class ActionContext<TInput extends z.ZodObject, TOutput extends z.ZodObject, TVa
679
827
  ...this.#action.steps,
680
828
  ...options,
681
829
  })
830
+
682
831
  return this.#stepManager.push({
683
832
  name,
684
833
  cb,
@@ -688,4 +837,77 @@ class ActionContext<TInput extends z.ZodObject, TOutput extends z.ZodObject, TVa
688
837
  parallel: parsedOptions.parallel, // Pass parallel option
689
838
  })
690
839
  }
840
+
841
+ /**
842
+ * Execute a reusable step definition created with createStep().
843
+ * This is the public method called from action handlers.
844
+ *
845
+ * @param stepDef - The step definition to execute
846
+ * @param input - The input data for the step (validated against the step's input schema)
847
+ * @param options - Optional step configuration overrides
848
+ * @returns Promise resolving to the step result
849
+ */
850
+ async run<TStepInput extends z.ZodObject, TResult>(
851
+ stepDef: StepDefinition<TStepInput, TResult, TVariables>,
852
+ input: z.input<TStepInput>,
853
+ options: Partial<z.input<typeof StepOptionsSchema>> = {},
854
+ ): Promise<TResult> {
855
+ return this.#runInternal(stepDef, input, options, null, this.#abortSignal)
856
+ }
857
+
858
+ /**
859
+ * Internal method to execute a step definition with explicit parent step ID and abort signal.
860
+ * Used by both the public run method and the run functions passed to step contexts.
861
+ */
862
+ async #runInternal<TStepInput extends z.ZodObject, TResult>(
863
+ stepDef: StepDefinition<TStepInput, TResult, TVariables>,
864
+ input: z.input<TStepInput>,
865
+ options: Partial<z.input<typeof StepOptionsSchema>> = {},
866
+ parentStepId: string | null,
867
+ abortSignal: AbortSignal,
868
+ ): Promise<TResult> {
869
+ // Validate input against the step's schema if provided
870
+ // After parsing, validatedInput is z.output<TStepInput> (same as z.infer<TStepInput>)
871
+ const validatedInput: z.infer<TStepInput> = stepDef.input
872
+ ? stepDef.input.parse(input, {
873
+ error: () => 'Error parsing step input',
874
+ reportInput: true,
875
+ })
876
+ : (input as z.infer<TStepInput>)
877
+
878
+ // Resolve step name (static or dynamic)
879
+ const stepName = typeof stepDef.name === 'function' ? stepDef.name({ input: validatedInput }) : stepDef.name
880
+
881
+ // Merge options: action defaults -> step definition -> call-time overrides
882
+ const mergedOptions: z.input<typeof StepOptionsSchema> = {
883
+ ...this.#action.steps,
884
+ ...(stepDef.retry !== undefined && { retry: stepDef.retry }),
885
+ ...(stepDef.expire !== undefined && { expire: stepDef.expire }),
886
+ ...(stepDef.parallel !== undefined && { parallel: stepDef.parallel }),
887
+ ...options,
888
+ }
889
+
890
+ const parsedOptions = StepOptionsSchema.parse(mergedOptions)
891
+
892
+ // Create a wrapper callback that provides the extended context
893
+ const wrappedCb = async (baseCtx: StepHandlerContext): Promise<TResult> => {
894
+ const extendedCtx: StepDefinitionHandlerContext<TStepInput, TVariables> = {
895
+ ...baseCtx,
896
+ input: validatedInput,
897
+ var: this.#variables,
898
+ logger: this.#logger,
899
+ jobId: this.#jobId,
900
+ }
901
+ return stepDef.handler(extendedCtx)
902
+ }
903
+
904
+ return this.#stepManager.push({
905
+ name: stepName,
906
+ cb: wrappedCb,
907
+ options: parsedOptions,
908
+ abortSignal,
909
+ parentStepId,
910
+ parallel: parsedOptions.parallel,
911
+ })
912
+ }
691
913
  }
@@ -108,6 +108,106 @@ export interface AddSpanAttributeOptions {
108
108
  value: string | number | boolean
109
109
  }
110
110
 
111
+ /**
112
+ * Options for starting a custom span with the tracer.
113
+ */
114
+ export interface StartSpanOptions {
115
+ /**
116
+ * Span kind (internal, client, server, producer, consumer).
117
+ * @default 'internal'
118
+ */
119
+ kind?: 'internal' | 'client' | 'server' | 'producer' | 'consumer'
120
+
121
+ /**
122
+ * Initial attributes for the span.
123
+ */
124
+ attributes?: Record<string, string | number | boolean>
125
+
126
+ /**
127
+ * Parent span to use for context propagation.
128
+ * If not provided, uses the current active context.
129
+ */
130
+ parentSpan?: TracerSpan
131
+ }
132
+
133
+ /**
134
+ * A span created by the Tracer for manual instrumentation.
135
+ */
136
+ export interface TracerSpan {
137
+ /**
138
+ * Set an attribute on the span.
139
+ *
140
+ * @param key - The attribute key
141
+ * @param value - The attribute value
142
+ */
143
+ setAttribute(key: string, value: string | number | boolean): void
144
+
145
+ /**
146
+ * Set multiple attributes on the span.
147
+ *
148
+ * @param attributes - The attributes to set
149
+ */
150
+ setAttributes(attributes: Record<string, string | number | boolean>): void
151
+
152
+ /**
153
+ * Add an event to the span.
154
+ *
155
+ * @param name - The event name
156
+ * @param attributes - Optional event attributes
157
+ */
158
+ addEvent(name: string, attributes?: Record<string, string | number | boolean>): void
159
+
160
+ /**
161
+ * Record an exception on the span.
162
+ *
163
+ * @param error - The error to record
164
+ */
165
+ recordException(error: Error): void
166
+
167
+ /**
168
+ * Set the span status to OK.
169
+ */
170
+ setStatusOk(): void
171
+
172
+ /**
173
+ * Set the span status to error.
174
+ *
175
+ * @param message - Optional error message
176
+ */
177
+ setStatusError(message?: string): void
178
+
179
+ /**
180
+ * End the span.
181
+ * After calling this, no more operations can be performed on the span.
182
+ */
183
+ end(): void
184
+
185
+ /**
186
+ * Check if this span is recording.
187
+ */
188
+ isRecording(): boolean
189
+ }
190
+
191
+ /**
192
+ * A Tracer provides methods for creating spans.
193
+ * Similar to OpenTelemetry's Tracer interface.
194
+ */
195
+ export interface Tracer {
196
+ /**
197
+ * The name of this tracer.
198
+ */
199
+ readonly name: string
200
+
201
+ /**
202
+ * Start a new span.
203
+ *
204
+ * @param name - The name of the span
205
+ * @param options - Optional span configuration
206
+ * @returns A TracerSpan for manual instrumentation
207
+ */
208
+ startSpan(name: string, options?: StartSpanOptions): TracerSpan
209
+ }
210
+
111
211
  /**
112
212
  * Observe context provided to action and step handlers.
113
213
  */
@@ -136,6 +236,37 @@ export interface ObserveContext {
136
236
  * @param attributes - Optional event attributes
137
237
  */
138
238
  addSpanEvent(name: string, attributes?: Record<string, any>): void
239
+
240
+ /**
241
+ * Get a tracer for manual instrumentation.
242
+ * Similar to OpenTelemetry's `trace.getTracer()` method.
243
+ *
244
+ * @param name - The name of the tracer (typically your service or library name)
245
+ * @returns A Tracer for creating custom spans
246
+ *
247
+ * @example
248
+ * ```typescript
249
+ * const tracer = ctx.observe.getTracer('my-service')
250
+ *
251
+ * const span = tracer.startSpan('external-api-call', {
252
+ * kind: 'client',
253
+ * attributes: { 'api.endpoint': '/users' }
254
+ * })
255
+ *
256
+ * try {
257
+ * const result = await fetch('https://api.example.com/users')
258
+ * span.setStatusOk()
259
+ * return result
260
+ * } catch (error) {
261
+ * span.recordException(error)
262
+ * span.setStatusError(error.message)
263
+ * throw error
264
+ * } finally {
265
+ * span.end()
266
+ * }
267
+ * ```
268
+ */
269
+ getTracer(name: string): Tracer
139
270
  }
140
271
 
141
272
  // ============================================================================
@@ -369,6 +500,41 @@ export abstract class TelemetryAdapter {
369
500
  return this._addSpanAttribute(options)
370
501
  }
371
502
 
503
+ // ============================================================================
504
+ // Tracer Methods
505
+ // ============================================================================
506
+
507
+ /**
508
+ * Get a tracer for manual instrumentation.
509
+ * Similar to OpenTelemetry's `trace.getTracer()` method.
510
+ *
511
+ * @param name - The name of the tracer (typically your service or library name)
512
+ * @returns A Tracer for creating custom spans
513
+ *
514
+ * @example
515
+ * ```typescript
516
+ * const tracer = telemetry.getTracer('my-service')
517
+ *
518
+ * const span = tracer.startSpan('process-order', {
519
+ * attributes: { 'order.id': orderId }
520
+ * })
521
+ *
522
+ * try {
523
+ * // Do some work
524
+ * span.addEvent('order.validated')
525
+ * span.setStatusOk()
526
+ * } catch (error) {
527
+ * span.recordException(error)
528
+ * span.setStatusError(error.message)
529
+ * } finally {
530
+ * span.end()
531
+ * }
532
+ * ```
533
+ */
534
+ getTracer(name: string): Tracer {
535
+ return this._getTracer(name)
536
+ }
537
+
372
538
  // ============================================================================
373
539
  // Context Methods
374
540
  // ============================================================================
@@ -404,6 +570,9 @@ export abstract class TelemetryAdapter {
404
570
  this.#logger?.error(err, 'Error adding span event')
405
571
  })
406
572
  },
573
+ getTracer: (name: string) => {
574
+ return this.getTracer(name)
575
+ },
407
576
  }
408
577
  }
409
578
 
@@ -465,4 +634,9 @@ export abstract class TelemetryAdapter {
465
634
  * Internal method to add a span attribute.
466
635
  */
467
636
  protected abstract _addSpanAttribute(options: AddSpanAttributeOptions): Promise<void>
637
+
638
+ /**
639
+ * Internal method to get a tracer for manual instrumentation.
640
+ */
641
+ protected abstract _getTracer(name: string): Tracer
468
642
  }
@@ -9,8 +9,11 @@ export {
9
9
  type Span,
10
10
  type StartDatabaseSpanOptions,
11
11
  type StartJobSpanOptions,
12
+ type StartSpanOptions,
12
13
  type StartStepSpanOptions,
13
14
  TelemetryAdapter,
15
+ type Tracer,
16
+ type TracerSpan,
14
17
  } from './adapter.js'
15
18
  export { LocalTelemetryAdapter, type LocalTelemetryAdapterOptions, localTelemetryAdapter } from './local.js'
16
19
  export { NoopTelemetryAdapter, noopTelemetryAdapter } from './noop.js'
@@ -7,8 +7,11 @@ import {
7
7
  type Span,
8
8
  type StartDatabaseSpanOptions,
9
9
  type StartJobSpanOptions,
10
+ type StartSpanOptions,
10
11
  type StartStepSpanOptions,
11
12
  TelemetryAdapter,
13
+ type Tracer,
14
+ type TracerSpan,
12
15
  } from './adapter.js'
13
16
 
14
17
  // ============================================================================
@@ -304,6 +307,96 @@ export class LocalTelemetryAdapter extends TelemetryAdapter {
304
307
  },
305
308
  })
306
309
  }
310
+
311
+ // ============================================================================
312
+ // Tracer Methods
313
+ // ============================================================================
314
+
315
+ protected _getTracer(name: string): Tracer {
316
+ const adapter = this
317
+
318
+ return {
319
+ name,
320
+
321
+ startSpan(spanName: string, options?: StartSpanOptions): TracerSpan {
322
+ const spanId = `tracer:${name}:${globalThis.crypto.randomUUID()}`
323
+ const startTime = Date.now()
324
+ let ended = false
325
+ const attributes: Record<string, string | number | boolean> = {
326
+ ...options?.attributes,
327
+ }
328
+
329
+ // Note: Local adapter tracer spans don't have a jobId context,
330
+ // so they can't be stored in the database. They're essentially no-ops
331
+ // but provide a consistent API for code that needs a tracer.
332
+ // For actual metrics storage, use ctx.observe within action/step handlers.
333
+
334
+ const tracerSpan: TracerSpan = {
335
+ setAttribute(key: string, value: string | number | boolean): void {
336
+ if (!ended) {
337
+ attributes[key] = value
338
+ }
339
+ },
340
+
341
+ setAttributes(attrs: Record<string, string | number | boolean>): void {
342
+ if (!ended) {
343
+ Object.assign(attributes, attrs)
344
+ }
345
+ },
346
+
347
+ addEvent(eventName: string, eventAttrs?: Record<string, string | number | boolean>): void {
348
+ if (!ended) {
349
+ adapter.logger?.debug({ spanId, event: eventName, attributes: eventAttrs }, 'Tracer span event')
350
+ }
351
+ },
352
+
353
+ recordException(error: Error): void {
354
+ if (!ended) {
355
+ attributes['error.message'] = error.message
356
+ attributes['error.name'] = error.name
357
+ adapter.logger?.debug({ spanId, error: error.message }, 'Tracer span exception')
358
+ }
359
+ },
360
+
361
+ setStatusOk(): void {
362
+ if (!ended) {
363
+ // biome-ignore lint/complexity/useLiteralKeys: Index signature requires bracket notation
364
+ attributes['status'] = 'ok'
365
+ }
366
+ },
367
+
368
+ setStatusError(message?: string): void {
369
+ if (!ended) {
370
+ // biome-ignore lint/complexity/useLiteralKeys: Index signature requires bracket notation
371
+ attributes['status'] = 'error'
372
+ if (message) {
373
+ attributes['status.message'] = message
374
+ }
375
+ }
376
+ },
377
+
378
+ end(): void {
379
+ if (!ended) {
380
+ ended = true
381
+ const duration = Date.now() - startTime
382
+ adapter.logger?.debug(
383
+ { spanId, spanName, tracerName: name, durationMs: duration, attributes },
384
+ 'Tracer span ended',
385
+ )
386
+ }
387
+ },
388
+
389
+ isRecording(): boolean {
390
+ return !ended
391
+ },
392
+ }
393
+
394
+ adapter.logger?.debug({ spanId, spanName, tracerName: name }, 'Tracer span started')
395
+
396
+ return tracerSpan
397
+ },
398
+ }
399
+ }
307
400
  }
308
401
 
309
402
  /**