@kronos-ts/messaging 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/command-handling-module.d.ts.map +1 -1
  2. package/dist/command-handling-module.js +4 -12
  3. package/dist/command-handling-module.js.map +1 -1
  4. package/dist/correlation-data.d.ts +38 -8
  5. package/dist/correlation-data.d.ts.map +1 -1
  6. package/dist/correlation-data.js +57 -20
  7. package/dist/correlation-data.js.map +1 -1
  8. package/dist/event-gateway.d.ts.map +1 -1
  9. package/dist/event-gateway.js +1 -0
  10. package/dist/event-gateway.js.map +1 -1
  11. package/dist/gateway.d.ts.map +1 -1
  12. package/dist/gateway.js +3 -0
  13. package/dist/gateway.js.map +1 -1
  14. package/dist/handler-enhancer.d.ts +2 -1
  15. package/dist/handler-enhancer.d.ts.map +1 -1
  16. package/dist/handler-enhancer.js.map +1 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/message.d.ts +18 -0
  22. package/dist/message.d.ts.map +1 -1
  23. package/dist/send.d.ts.map +1 -1
  24. package/dist/send.js +1 -0
  25. package/dist/send.js.map +1 -1
  26. package/dist/span-factory.d.ts +32 -1
  27. package/dist/span-factory.d.ts.map +1 -1
  28. package/dist/span-factory.js +3 -0
  29. package/dist/span-factory.js.map +1 -1
  30. package/dist/streaming-event-processor.d.ts +7 -0
  31. package/dist/streaming-event-processor.d.ts.map +1 -1
  32. package/dist/streaming-event-processor.js +7 -1
  33. package/dist/streaming-event-processor.js.map +1 -1
  34. package/dist/subscribing-event-processor.d.ts +8 -0
  35. package/dist/subscribing-event-processor.d.ts.map +1 -1
  36. package/dist/subscribing-event-processor.js +7 -1
  37. package/dist/subscribing-event-processor.js.map +1 -1
  38. package/dist/tracing-command-bus.d.ts +8 -5
  39. package/dist/tracing-command-bus.d.ts.map +1 -1
  40. package/dist/tracing-command-bus.js +21 -19
  41. package/dist/tracing-command-bus.js.map +1 -1
  42. package/dist/tracing-handler-enhancer.d.ts +8 -2
  43. package/dist/tracing-handler-enhancer.d.ts.map +1 -1
  44. package/dist/tracing-handler-enhancer.js +45 -4
  45. package/dist/tracing-handler-enhancer.js.map +1 -1
  46. package/dist/tracking-event-processor.d.ts +7 -0
  47. package/dist/tracking-event-processor.d.ts.map +1 -1
  48. package/dist/tracking-event-processor.js +10 -1
  49. package/dist/tracking-event-processor.js.map +1 -1
  50. package/package.json +8 -3
  51. package/src/command-handling-module.ts +4 -12
  52. package/src/correlation-data.ts +67 -25
  53. package/src/event-gateway.ts +1 -0
  54. package/src/gateway.ts +3 -0
  55. package/src/handler-enhancer.ts +3 -1
  56. package/src/index.ts +2 -0
  57. package/src/message.ts +23 -2
  58. package/src/send.ts +1 -0
  59. package/src/span-factory.ts +37 -1
  60. package/src/streaming-event-processor.ts +13 -0
  61. package/src/subscribing-event-processor.ts +14 -0
  62. package/src/tracing-command-bus.ts +23 -19
  63. package/src/tracing-handler-enhancer.ts +56 -5
  64. package/src/tracking-event-processor.ts +16 -0
@@ -92,6 +92,68 @@ export function simpleCorrelationDataProvider(...metadataKeys: string[]): Correl
92
92
  }
93
93
  }
94
94
 
95
+ // ---------------------------------------------------------------------------
96
+ // Extract phase (shared by the handler interceptor and the event processors)
97
+ // ---------------------------------------------------------------------------
98
+
99
+ /**
100
+ * Compute correlation data from the given providers for `message` and merge it
101
+ * into the active UnitOfWork's correlation-data resource (`CORRELATION_DATA_KEY`).
102
+ *
103
+ * This is the reusable "extract" step. It is run:
104
+ * - by {@link correlationDataHandlerInterceptor} for command/query handlers, and
105
+ * - by the event processors per-event before invoking event handlers, so an
106
+ * automation's outgoing commands/events inherit the triggering event's
107
+ * lineage.
108
+ *
109
+ * Each provider is called with the message. Exceptions are caught and logged
110
+ * (they don't break message processing). Results merge over any existing
111
+ * correlation data, so values contributed earlier (e.g. via
112
+ * {@link contributeCorrelationData}) are preserved unless a provider overrides
113
+ * the same key.
114
+ *
115
+ * Must be called inside an active UnitOfWork.
116
+ */
117
+ export function applyCorrelationData(
118
+ message: Message,
119
+ providers: ReadonlyArray<CorrelationDataProvider>,
120
+ ): void {
121
+ const correlationData: Record<string, string> = {}
122
+
123
+ for (const provider of providers) {
124
+ try {
125
+ Object.assign(correlationData, provider.correlationDataFor(message))
126
+ } catch (err) {
127
+ console.warn(
128
+ "Encountered exception creating correlation data from provider:",
129
+ err,
130
+ )
131
+ }
132
+ }
133
+
134
+ const existing = getActiveCorrelationData() ?? {}
135
+ setResource(CORRELATION_DATA_KEY, { ...existing, ...correlationData })
136
+ }
137
+
138
+ /**
139
+ * Contribute additional correlation data to the active UnitOfWork, merged over
140
+ * whatever is already present under `CORRELATION_DATA_KEY`. The merged set is
141
+ * applied to every message dispatched/appended from this UnitOfWork by the
142
+ * correlation-data dispatch interceptor and the event appender.
143
+ *
144
+ * Use this from a handler enhancer or handler to seed extra lineage keys that
145
+ * the built-in providers don't cover — for example an OpenTelemetry
146
+ * `traceparent` so the trace context rides along on outgoing messages. This is
147
+ * the supported alternative to mutating the object returned by
148
+ * {@link getActiveCorrelationData}.
149
+ *
150
+ * Throws `NoActiveUnitOfWork` when called outside an active UnitOfWork.
151
+ */
152
+ export function contributeCorrelationData(partial: Record<string, string>): void {
153
+ const existing = getActiveCorrelationData() ?? {}
154
+ setResource(CORRELATION_DATA_KEY, { ...existing, ...partial })
155
+ }
156
+
95
157
  // ---------------------------------------------------------------------------
96
158
  // Interceptor factory
97
159
  // ---------------------------------------------------------------------------
@@ -100,38 +162,18 @@ export function simpleCorrelationDataProvider(...metadataKeys: string[]): Correl
100
162
  * Creates a handler interceptor that extracts correlation data from the
101
163
  * incoming message and stores it in the ProcessingContext.
102
164
  *
103
- * This is the "extract" phase of the dual-interceptor pattern.
104
- *
105
- * Each provider is called with the message. Exceptions are caught and
106
- * logged (they don't break message processing). Results are merged —
107
- * later providers override earlier ones on key conflicts.
108
- *
109
- * The correlation data is stored as a ProcessingContext resource under
110
- * `CORRELATION_DATA_KEY`, where the dispatch interceptor reads it.
165
+ * This is the "extract" phase of the dual-interceptor pattern, delegating to
166
+ * {@link applyCorrelationData}. The correlation data is stored as a
167
+ * ProcessingContext resource under `CORRELATION_DATA_KEY`, where the dispatch
168
+ * interceptor reads it.
111
169
  */
112
170
  export function correlationDataHandlerInterceptor(
113
171
  providers: ReadonlyArray<CorrelationDataProvider>,
114
172
  ): HandlerInterceptor {
115
173
  return (message, next) => {
116
- const correlationData: Record<string, string> = {}
117
-
118
- for (const provider of providers) {
119
- try {
120
- const data = provider.correlationDataFor(message)
121
- Object.assign(correlationData, data)
122
- } catch (err) {
123
- console.warn(
124
- "Encountered exception creating correlation data from provider:",
125
- err,
126
- )
127
- }
128
- }
129
-
130
- // Store in ALS-backed processing state.
131
174
  // CTX-01 / Plan 03-03: HandlerInterceptor no longer threads ProcessingContext;
132
175
  // resource writes go directly through the module-level ALS accessor.
133
- setResource(CORRELATION_DATA_KEY, correlationData)
134
-
176
+ applyCorrelationData(message, providers)
135
177
  return next()
136
178
  }
137
179
  }
@@ -28,6 +28,7 @@ export function createEventGateway(eventSink: EventSink): EventGateway {
28
28
  async publish(descriptor, payload, metadata = {}) {
29
29
  const tags = descriptor.tags ? descriptor.tags(payload) : []
30
30
  const event: EventMessage = {
31
+ kind: "event",
31
32
  identifier: generateIdentifier(),
32
33
  name: descriptor.name,
33
34
  version: descriptor.version,
package/src/gateway.ts CHANGED
@@ -86,6 +86,7 @@ export function createCommandGateway(bus: CommandBus): CommandGateway {
86
86
  async send(descriptor, payload, metadata) {
87
87
  const resolvedMetadata = metadata ?? emptyMetadata()
88
88
  return bus.dispatch({
89
+ kind: "command",
89
90
  identifier: generateIdentifier(),
90
91
  name: descriptor.name,
91
92
  payload,
@@ -114,6 +115,7 @@ export function createQueryGateway(
114
115
  // bus.query, which handles its own UoW.
115
116
  return unitOfWorkRunner(resolvedMetadata, () =>
116
117
  bus.query({
118
+ kind: "query",
117
119
  identifier: generateIdentifier(),
118
120
  name: descriptor.name,
119
121
  payload,
@@ -125,6 +127,7 @@ export function createQueryGateway(
125
127
 
126
128
  subscriptionQuery(descriptor, payload, metadata) {
127
129
  return bus.subscriptionQuery({
130
+ kind: "query",
128
131
  identifier: generateIdentifier(),
129
132
  name: descriptor.name,
130
133
  payload,
@@ -1,10 +1,12 @@
1
+ import type { MessageKind } from "./message.js"
2
+
1
3
  /**
2
4
  * Metadata about a handler being enhanced. Allows enhancers to
3
5
  * selectively wrap based on handler type, message name, etc.
4
6
  */
5
7
  export interface HandlerMetadata {
6
8
  /** The type of message this handler processes. */
7
- readonly messageType: "command" | "event" | "query"
9
+ readonly messageType: MessageKind
8
10
  /** The qualified name of the message (e.g., "university.courses.CreateCourse"). */
9
11
  readonly messageName: string
10
12
  /** The name of the handler group or module (e.g., "course-commands"). */
package/src/index.ts CHANGED
@@ -109,6 +109,8 @@ export {
109
109
  type CorrelationDataProvider,
110
110
  CORRELATION_DATA_KEY,
111
111
  getActiveCorrelationData,
112
+ applyCorrelationData,
113
+ contributeCorrelationData,
112
114
  messageOriginProvider,
113
115
  simpleCorrelationDataProvider,
114
116
  correlationDataHandlerInterceptor,
package/src/message.ts CHANGED
@@ -1,10 +1,26 @@
1
1
  import type { QualifiedName, Metadata } from "@kronos-ts/common"
2
2
 
3
+ /**
4
+ * Discriminates a message by its dispatch category.
5
+ *
6
+ * TypeScript erases interfaces at runtime, so `CommandMessage`, `EventMessage`,
7
+ * and `QueryMessage` — which are otherwise shape-identical — cannot be told
8
+ * apart with `instanceof` the way Axon Framework's nominal interfaces can.
9
+ * This field is the structural-typing equivalent of that `instanceof` check:
10
+ * it lets a reusable handler interceptor branch on message category without
11
+ * being pinned to a single bus.
12
+ */
13
+ export type MessageKind = "command" | "event" | "query"
14
+
3
15
  /**
4
16
  * A message carrying a payload and metadata, identified by a unique ID
5
17
  * and routed by its qualified name.
18
+ *
19
+ * `kind` is derived at construction/reconstruction time, never persisted —
20
+ * each bus and event-store reconstruction site sets it from context.
6
21
  */
7
22
  export interface Message<P = unknown> {
23
+ readonly kind: MessageKind
8
24
  readonly identifier: string
9
25
  readonly name: QualifiedName
10
26
  readonly payload: P
@@ -15,7 +31,9 @@ export interface Message<P = unknown> {
15
31
  /**
16
32
  * A command message — dispatched to exactly one handler, may return a result.
17
33
  */
18
- export interface CommandMessage<P = unknown> extends Message<P> {}
34
+ export interface CommandMessage<P = unknown> extends Message<P> {
35
+ readonly kind: "command"
36
+ }
19
37
 
20
38
  /**
21
39
  * A command result message — the response from handling a command.
@@ -31,6 +49,7 @@ export interface CommandResultMessage<R = unknown> {
31
49
  * An event message — published to all interested handlers.
32
50
  */
33
51
  export interface EventMessage<P = unknown> extends Message<P> {
52
+ readonly kind: "event"
34
53
  readonly version: string
35
54
  readonly tags: ReadonlyArray<{ readonly key: string; readonly value: string }>
36
55
  }
@@ -43,4 +62,6 @@ export interface SequencedEventMessage<P = unknown> extends EventMessage<P> {
43
62
  /**
44
63
  * A query message — dispatched to handler(s) that can answer it.
45
64
  */
46
- export interface QueryMessage<P = unknown> extends Message<P> {}
65
+ export interface QueryMessage<P = unknown> extends Message<P> {
66
+ readonly kind: "query"
67
+ }
package/src/send.ts CHANGED
@@ -35,6 +35,7 @@ export const send: CommandDispatchFunction = async (descriptor, payload) => {
35
35
  const bus = state.resources.get(COMMAND_BUS_KEY.symbol) as CommandBus | undefined
36
36
  if (!bus) throw new Error("No command bus configured")
37
37
  return bus.dispatch({
38
+ kind: "command",
38
39
  identifier: generateIdentifier(),
39
40
  name: descriptor.name,
40
41
  payload,
@@ -12,6 +12,16 @@ export interface Span {
12
12
  end(): void
13
13
  /** Record an error on the span and end it. */
14
14
  recordException(error: Error): void
15
+ /**
16
+ * Run `fn` with this span set as the active trace context, returning `fn`'s
17
+ * result. Spans created — and trace context read (`propagateContext` /
18
+ * `currentTraceContext`) — inside `fn` are parented to this span. When `fn`
19
+ * is async the span stays active across its awaits.
20
+ *
21
+ * Optional: callers must fall back to invoking `fn` directly when a Span
22
+ * implementation doesn't provide it.
23
+ */
24
+ runActive?<T>(fn: () => T): T
15
25
  }
16
26
 
17
27
  /**
@@ -37,10 +47,22 @@ export interface SpanFactory {
37
47
 
38
48
  /**
39
49
  * Create a span for handling a message (consumer side).
40
- * Extracts trace context from the parent message's metadata.
50
+ * Extracts trace context from the parent message's metadata and continues
51
+ * that trace (the new span is a child in the same trace).
41
52
  */
42
53
  createHandlerSpan(operationName: string, parentMessage: Message): Span
43
54
 
55
+ /**
56
+ * Like {@link createHandlerSpan}, but starts a NEW trace linked to the parent
57
+ * message's trace context instead of continuing it. Use for asynchronously
58
+ * handled messages (e.g. streaming/tracking event processors) where joining a
59
+ * possibly long-finished originating trace would be misleading — the link
60
+ * preserves correlation without false nesting.
61
+ *
62
+ * Optional: callers fall back to {@link createHandlerSpan} when absent.
63
+ */
64
+ createLinkedHandlerSpan?(operationName: string, parentMessage: Message): Span
65
+
44
66
  /**
45
67
  * Create a span for dispatching a message (producer side).
46
68
  * Links to the parent message's trace context.
@@ -56,6 +78,17 @@ export interface SpanFactory {
56
78
  */
57
79
  propagateContext<M extends Message>(message: M): M
58
80
 
81
+ /**
82
+ * Returns the currently active trace context as propagation headers (e.g. the
83
+ * W3C `traceparent`), or an empty object when no span is active. Used to store
84
+ * the handler's trace context on the UnitOfWork (via `contributeCorrelationData`)
85
+ * so it rides along on appended and dispatched messages — including those
86
+ * published at commit time, after the handler span has ended.
87
+ *
88
+ * Optional: callers treat absence as "no trace context".
89
+ */
90
+ currentTraceContext?(): Record<string, string>
91
+
59
92
  /** Register a custom span attribute provider. */
60
93
  registerSpanAttributeProvider(provider: SpanAttributesProvider): void
61
94
  }
@@ -68,14 +101,17 @@ export function noOpSpanFactory(): SpanFactory {
68
101
  start() { return this },
69
102
  end() {},
70
103
  recordException() {},
104
+ runActive<T>(fn: () => T): T { return fn() },
71
105
  }
72
106
 
73
107
  return {
74
108
  createRootTrace() { return noOpSpan },
75
109
  createHandlerSpan() { return noOpSpan },
110
+ createLinkedHandlerSpan() { return noOpSpan },
76
111
  createDispatchSpan() { return noOpSpan },
77
112
  createInternalSpan() { return noOpSpan },
78
113
  propagateContext<M extends Message>(message: M) { return message },
114
+ currentTraceContext() { return {} },
79
115
  registerSpanAttributeProvider() {},
80
116
  }
81
117
  }
@@ -25,6 +25,7 @@ import {
25
25
  advanceToken,
26
26
  } from "./tracking-token.js"
27
27
  import { REPLAY_STATE_KEY } from "./replay-token.js"
28
+ import { applyCorrelationData, type CorrelationDataProvider } from "./correlation-data.js"
28
29
  import { setResource, onPrepareCommit } from "./processing-state.js"
29
30
  import type { CommandBus } from "./command-bus.js"
30
31
  import type { QueryBus } from "./query-bus.js"
@@ -84,6 +85,12 @@ export interface StreamingEventProcessorOptions {
84
85
  queryBus?: QueryBus
85
86
  /** Event scheduler injected into ALS at handler-invocation entry (read by schedule()). */
86
87
  eventScheduler?: EventScheduler
88
+ /**
89
+ * Correlation data providers run against each event before its handlers are
90
+ * invoked, so commands/events dispatched from an event handler inherit the
91
+ * triggering event's correlationId/causationId.
92
+ */
93
+ correlationDataProviders?: ReadonlyArray<CorrelationDataProvider>
87
94
  /** Optional per-event callback fired inside the UoW before handler invocation (e.g. monitoring). */
88
95
  onEventDelivery?: () => void
89
96
  unitOfWorkRunner?: UoWRunner
@@ -127,6 +134,7 @@ export function createStreamingEventProcessor(
127
134
  commandBus,
128
135
  queryBus,
129
136
  eventScheduler,
137
+ correlationDataProviders,
130
138
  onEventDelivery,
131
139
  unitOfWorkRunner = runInNewUoW,
132
140
  tokenStore,
@@ -330,6 +338,11 @@ export function createStreamingEventProcessor(
330
338
  if (commandBus !== undefined) setResource(COMMAND_BUS_KEY, commandBus)
331
339
  if (queryBus !== undefined) setResource(QUERY_BUS_KEY, queryBus)
332
340
  if (eventScheduler !== undefined) setResource(EVENT_SCHEDULER_KEY, eventScheduler)
341
+ // Seed correlation data from the triggering event so an automation's
342
+ // outgoing commands/events inherit its lineage.
343
+ if (correlationDataProviders && correlationDataProviders.length > 0) {
344
+ applyCorrelationData(event, correlationDataProviders)
345
+ }
333
346
  // Optional per-event callback (e.g. monitoring hooks registered inside the UoW).
334
347
  if (onEventDelivery) onEventDelivery()
335
348
 
@@ -10,6 +10,7 @@ import type { SubscribableEventSource } from "./event-bus.js"
10
10
  import type { CommandBus } from "./command-bus.js"
11
11
  import type { QueryBus } from "./query-bus.js"
12
12
  import type { HandlerEnhancerDefinition } from "./handler-enhancer.js"
13
+ import { applyCorrelationData, type CorrelationDataProvider } from "./correlation-data.js"
13
14
  import { setResource } from "./processing-state.js"
14
15
  import { STATE_MANAGER_KEY, EVENT_SCHEDULER_KEY } from "@kronos-ts/eventsourcing"
15
16
  import type { EventScheduler } from "./event-scheduler.js"
@@ -56,6 +57,13 @@ export interface SubscribingEventProcessorOptions {
56
57
  queryBus?: QueryBus
57
58
  /** Event scheduler injected into ALS at handler-invocation entry (read by schedule()). */
58
59
  eventScheduler?: EventScheduler
60
+ /**
61
+ * Correlation data providers run against each event before its handlers are
62
+ * invoked. Their output is seeded into the UoW so commands/events dispatched
63
+ * from an event handler inherit the triggering event's
64
+ * correlationId/causationId. Empty/undefined → no seeding.
65
+ */
66
+ correlationDataProviders?: ReadonlyArray<CorrelationDataProvider>
59
67
  /** Optional per-event callback fired inside the UoW before handler invocation (e.g. monitoring). */
60
68
  onEventDelivery?: () => void
61
69
  unitOfWorkRunner?: UoWRunner
@@ -87,6 +95,7 @@ export function createSubscribingEventProcessor(
87
95
  commandBus,
88
96
  queryBus,
89
97
  eventScheduler,
98
+ correlationDataProviders,
90
99
  onEventDelivery,
91
100
  unitOfWorkRunner = runInNewUoW,
92
101
  errorHandler = loggingErrorHandler(name),
@@ -141,6 +150,11 @@ export function createSubscribingEventProcessor(
141
150
  if (commandBus !== undefined) setResource(COMMAND_BUS_KEY, commandBus)
142
151
  if (queryBus !== undefined) setResource(QUERY_BUS_KEY, queryBus)
143
152
  if (eventScheduler !== undefined) setResource(EVENT_SCHEDULER_KEY, eventScheduler)
153
+ // Seed correlation data from the triggering event so an automation's
154
+ // outgoing commands/events inherit its lineage.
155
+ if (correlationDataProviders && correlationDataProviders.length > 0) {
156
+ applyCorrelationData(event, correlationDataProviders)
157
+ }
144
158
  // Optional per-event callback (e.g. monitoring hooks registered inside the UoW).
145
159
  if (onEventDelivery) onEventDelivery()
146
160
 
@@ -1,18 +1,26 @@
1
1
  import type { CommandBus } from "./command-bus.js"
2
2
  import type { CommandMessage } from "./message.js"
3
- import type { SpanFactory } from "./span-factory.js"
3
+ import type { SpanFactory, Span } from "./span-factory.js"
4
+
5
+ /** Run `fn` with `span` active, falling back to a plain call when the span lacks runActive. */
6
+ function runActive<R>(span: Span, fn: () => R): R {
7
+ return span.runActive ? span.runActive(fn) : fn()
8
+ }
4
9
 
5
10
  /**
6
- * A {@link CommandBus} decorator that wraps dispatch and handler invocations
7
- * with tracing spans.
11
+ * A {@link CommandBus} decorator that traces command dispatch (the producer
12
+ * side).
8
13
  *
9
14
  * Dispatch creates a "dispatch" span and propagates trace context into the
10
- * message metadata. Subscribe wraps each handler with a "handle" span.
11
- *
15
+ * message metadata, so the handler links back to it across the bus boundary.
16
+ * The handler ("handle") span is created by {@link tracingHandlerEnhancerDefinition},
17
+ * the single authority for handler-side spans across command/query/event
18
+ * handlers — so this decorator does not wrap subscribe, avoiding a duplicate
19
+ * command handle span.
12
20
  *
13
21
  * @param delegate The underlying command bus to decorate.
14
22
  * @param spanFactory The span factory for creating tracing spans.
15
- * @returns A decorated command bus with tracing instrumentation.
23
+ * @returns A decorated command bus with dispatch tracing.
16
24
  */
17
25
  export function createTracingCommandBus(
18
26
  delegate: CommandBus,
@@ -22,8 +30,12 @@ export function createTracingCommandBus(
22
30
  async dispatch(message: CommandMessage): Promise<unknown> {
23
31
  const span = spanFactory.createDispatchSpan(`dispatch(${String(message.name)})`, message).start()
24
32
  try {
25
- const propagated = spanFactory.propagateContext(message)
26
- const result = await delegate.dispatch(propagated)
33
+ // Propagate inside the active dispatch span so the outgoing message
34
+ // carries this span's trace context and the handler links to it.
35
+ const result = await runActive(span, () => {
36
+ const propagated = spanFactory.propagateContext(message)
37
+ return delegate.dispatch(propagated)
38
+ })
27
39
  span.end()
28
40
  return result
29
41
  } catch (err) {
@@ -36,17 +48,9 @@ export function createTracingCommandBus(
36
48
  commandName: string,
37
49
  handler: (message: CommandMessage) => Promise<unknown>,
38
50
  ): void {
39
- delegate.subscribe(commandName, async (msg: CommandMessage) => {
40
- const span = spanFactory.createHandlerSpan(`handle(${commandName})`, msg).start()
41
- try {
42
- const result = await handler(msg)
43
- span.end()
44
- return result
45
- } catch (err) {
46
- span.recordException(err instanceof Error ? err : new Error(String(err)))
47
- throw err
48
- }
49
- })
51
+ // Handler-side spans are owned by tracingHandlerEnhancerDefinition; pass
52
+ // the handler through untouched so commands get exactly one handle span.
53
+ delegate.subscribe(commandName, handler)
50
54
  },
51
55
  }
52
56
  }
@@ -1,12 +1,20 @@
1
1
  import type { HandlerEnhancerDefinition, HandlerMetadata } from "./handler-enhancer.js"
2
- import type { SpanFactory } from "./span-factory.js"
2
+ import type { SpanFactory, Span } from "./span-factory.js"
3
+ import type { Message } from "./message.js"
4
+ import { contributeCorrelationData } from "./correlation-data.js"
3
5
 
4
6
  /**
5
7
  * Handler enhancer that wraps message handler invocations with tracing spans.
6
8
  *
7
- * Creates an internal span per handler invocation, recording the handler
8
- * name and message type as context. Errors are recorded on the span.
9
+ * The span is created from the message being handled (extracting any trace
10
+ * context from its metadata) so the handler re-parents onto the dispatcher's
11
+ * trace across the message boundary. Event handlers start a new trace linked to
12
+ * the triggering event; command/query handlers continue the current trace.
9
13
  *
14
+ * The handler runs inside the span's active context, and the active trace
15
+ * context is captured onto the UnitOfWork (via contributeCorrelationData) so
16
+ * appended and dispatched messages carry it — including events published at
17
+ * commit time, after the span has ended.
10
18
  */
11
19
  export function tracingHandlerEnhancerDefinition(
12
20
  spanFactory: SpanFactory,
@@ -19,9 +27,27 @@ export function tracingHandlerEnhancerDefinition(
19
27
  const spanName = `${metadata.handlerGroup}.${metadata.messageName}`
20
28
 
21
29
  return (async (...args: any[]) => {
22
- const span = spanFactory.createInternalSpan(spanName).start()
30
+ const message = args[0]
31
+ const span = createSpan(spanFactory, spanName, message, metadata).start()
32
+ const runActive: <R>(fn: () => R) => R = span.runActive
33
+ ? span.runActive.bind(span)
34
+ : (fn) => fn()
35
+
23
36
  try {
24
- const result = await handler(...args)
37
+ const result = await runActive(() => {
38
+ // Store the active trace context on the UnitOfWork so outgoing and
39
+ // appended messages carry it. Best-effort: tracing must never break
40
+ // handling, and there may be no active UnitOfWork.
41
+ try {
42
+ const traceContext = spanFactory.currentTraceContext?.()
43
+ if (traceContext && Object.keys(traceContext).length > 0) {
44
+ contributeCorrelationData(traceContext)
45
+ }
46
+ } catch {
47
+ // no active UnitOfWork or no tracing context — skip
48
+ }
49
+ return handler(...args)
50
+ })
25
51
  span.end()
26
52
  return result
27
53
  } catch (error) {
@@ -32,3 +58,28 @@ export function tracingHandlerEnhancerDefinition(
32
58
  },
33
59
  }
34
60
  }
61
+
62
+ function isMessage(value: unknown): value is Message {
63
+ return (
64
+ typeof value === "object" &&
65
+ value !== null &&
66
+ "metadata" in value &&
67
+ "identifier" in value
68
+ )
69
+ }
70
+
71
+ function createSpan(
72
+ spanFactory: SpanFactory,
73
+ spanName: string,
74
+ message: unknown,
75
+ metadata: HandlerMetadata,
76
+ ): Span {
77
+ if (!isMessage(message)) {
78
+ // No message to re-parent from (defensive — wired handlers always receive one).
79
+ return spanFactory.createInternalSpan(spanName)
80
+ }
81
+ if (metadata.messageType === "event" && spanFactory.createLinkedHandlerSpan) {
82
+ return spanFactory.createLinkedHandlerSpan(spanName, message)
83
+ }
84
+ return spanFactory.createHandlerSpan(spanName, message)
85
+ }
@@ -22,6 +22,7 @@ import {
22
22
  advanceToken,
23
23
  } from "./tracking-token.js"
24
24
  import { REPLAY_STATE_KEY } from "./replay-token.js"
25
+ import { applyCorrelationData, type CorrelationDataProvider } from "./correlation-data.js"
25
26
  import { setResource, onPrepareCommit } from "./processing-state.js"
26
27
  import type { HandlerEnhancerDefinition } from "./handler-enhancer.js"
27
28
  import type { CommandBus } from "./command-bus.js"
@@ -76,6 +77,12 @@ export interface TrackingEventProcessorOptions {
76
77
  queryBus?: QueryBus
77
78
  /** Event scheduler injected into ALS at handler-invocation entry (read by schedule()). */
78
79
  eventScheduler?: EventScheduler
80
+ /**
81
+ * Correlation data providers run against each event before its handlers are
82
+ * invoked, so commands/events dispatched from an event handler inherit the
83
+ * triggering event's correlationId/causationId.
84
+ */
85
+ correlationDataProviders?: ReadonlyArray<CorrelationDataProvider>
79
86
  /** Optional per-event callback fired inside the UoW before handler invocation (e.g. monitoring). */
80
87
  onEventDelivery?: () => void
81
88
  unitOfWorkRunner?: UoWRunner
@@ -151,6 +158,7 @@ export function createTrackingEventProcessor(
151
158
  commandBus,
152
159
  queryBus,
153
160
  eventScheduler,
161
+ correlationDataProviders,
154
162
  onEventDelivery,
155
163
  unitOfWorkRunner = runInNewUoW,
156
164
  tokenStore,
@@ -341,6 +349,11 @@ export function createTrackingEventProcessor(
341
349
  if (commandBus !== undefined) setResource(COMMAND_BUS_KEY, commandBus)
342
350
  if (queryBus !== undefined) setResource(QUERY_BUS_KEY, queryBus)
343
351
  if (eventScheduler !== undefined) setResource(EVENT_SCHEDULER_KEY, eventScheduler)
352
+ // Seed correlation data from the triggering event so an automation's
353
+ // outgoing commands/events inherit its lineage.
354
+ if (correlationDataProviders && correlationDataProviders.length > 0) {
355
+ applyCorrelationData(event, correlationDataProviders)
356
+ }
344
357
  // Optional per-event callback (e.g. monitoring hooks registered inside the UoW).
345
358
  if (onEventDelivery) onEventDelivery()
346
359
 
@@ -375,6 +388,9 @@ export function createTrackingEventProcessor(
375
388
  if (commandBus !== undefined) setResource(COMMAND_BUS_KEY, commandBus)
376
389
  if (queryBus !== undefined) setResource(QUERY_BUS_KEY, queryBus)
377
390
  if (eventScheduler !== undefined) setResource(EVENT_SCHEDULER_KEY, eventScheduler)
391
+ if (correlationDataProviders && correlationDataProviders.length > 0) {
392
+ applyCorrelationData(event, correlationDataProviders)
393
+ }
378
394
 
379
395
  const position =
380
396
  typeof letter.diagnostics.position === "number" ? BigInt(letter.diagnostics.position) : 0n