workers-axiom 0.1.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 (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +156 -0
  3. package/dist/producer/index.d.ts +4 -0
  4. package/dist/producer/index.d.ts.map +1 -0
  5. package/dist/producer/index.js +5 -0
  6. package/dist/producer/index.js.map +1 -0
  7. package/dist/producer/logger.d.ts +146 -0
  8. package/dist/producer/logger.d.ts.map +1 -0
  9. package/dist/producer/logger.js +179 -0
  10. package/dist/producer/logger.js.map +1 -0
  11. package/dist/producer/tracing/clock.d.ts +21 -0
  12. package/dist/producer/tracing/clock.d.ts.map +1 -0
  13. package/dist/producer/tracing/clock.js +17 -0
  14. package/dist/producer/tracing/clock.js.map +1 -0
  15. package/dist/producer/tracing/index.d.ts +3 -0
  16. package/dist/producer/tracing/index.d.ts.map +1 -0
  17. package/dist/producer/tracing/index.js +3 -0
  18. package/dist/producer/tracing/index.js.map +1 -0
  19. package/dist/producer/tracing/span.d.ts +63 -0
  20. package/dist/producer/tracing/span.d.ts.map +1 -0
  21. package/dist/producer/tracing/span.js +94 -0
  22. package/dist/producer/tracing/span.js.map +1 -0
  23. package/dist/protocol/index.d.ts +58 -0
  24. package/dist/protocol/index.d.ts.map +1 -0
  25. package/dist/protocol/index.js +50 -0
  26. package/dist/protocol/index.js.map +1 -0
  27. package/dist/tail/index.d.ts +43 -0
  28. package/dist/tail/index.d.ts.map +1 -0
  29. package/dist/tail/index.js +155 -0
  30. package/dist/tail/index.js.map +1 -0
  31. package/dist/tail/spans.d.ts +19 -0
  32. package/dist/tail/spans.d.ts.map +1 -0
  33. package/dist/tail/spans.js +93 -0
  34. package/dist/tail/spans.js.map +1 -0
  35. package/package.json +41 -0
  36. package/src/producer/index.ts +28 -0
  37. package/src/producer/logger.ts +343 -0
  38. package/src/producer/tracing/clock.ts +38 -0
  39. package/src/producer/tracing/index.ts +8 -0
  40. package/src/producer/tracing/span.ts +148 -0
  41. package/src/protocol/index.ts +104 -0
  42. package/src/tail/index.ts +237 -0
  43. package/src/tail/spans.ts +137 -0
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "workers-axiom",
3
+ "version": "0.1.0",
4
+ "description": "Structured logging, tracing, and metrics for Cloudflare Workers, with an Axiom tail-worker sink.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/sapt-ai-org/workers-axiom.git"
10
+ },
11
+ "exports": {
12
+ "./producer": {
13
+ "types": "./dist/producer/index.d.ts",
14
+ "import": "./dist/producer/index.js",
15
+ "default": "./dist/producer/index.js"
16
+ },
17
+ "./tail": {
18
+ "types": "./dist/tail/index.d.ts",
19
+ "import": "./dist/tail/index.js",
20
+ "default": "./dist/tail/index.js"
21
+ },
22
+ "./protocol": {
23
+ "types": "./dist/protocol/index.d.ts",
24
+ "import": "./dist/protocol/index.js",
25
+ "default": "./dist/protocol/index.js"
26
+ }
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "src"
31
+ ],
32
+ "scripts": {
33
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
34
+ "typecheck": "tsc --noEmit",
35
+ "prepublishOnly": "pnpm run build"
36
+ },
37
+ "devDependencies": {
38
+ "@cloudflare/workers-types": "^4.20250101.0",
39
+ "typescript": "^5.6.0"
40
+ }
41
+ }
@@ -0,0 +1,28 @@
1
+ export {
2
+ LogLevel,
3
+ createLogger,
4
+ noopLogger,
5
+ withTrace,
6
+ type KVLike,
7
+ type LogContext,
8
+ type Logger,
9
+ type LoggerOptions,
10
+ type WithTraceOptions,
11
+ } from './logger'
12
+
13
+ export type { SpanOptions, TraceContext } from './tracing'
14
+
15
+ // Re-export wire-format types that consumers commonly need when extending
16
+ // the producer (custom attributes, propagation helpers in middleware, etc.).
17
+ export {
18
+ SPAN_EVENT_TYPE,
19
+ SUMMARY_PROPERTIES_TYPE,
20
+ isSpanEvent,
21
+ parseTraceparent,
22
+ formatTraceparent,
23
+ type AttributeValue,
24
+ type SpanEvent,
25
+ type SpanKind,
26
+ type SpanStatus,
27
+ type TraceParent,
28
+ } from '../protocol'
@@ -0,0 +1,343 @@
1
+ import {
2
+ type AttributeValue,
3
+ formatTraceparent,
4
+ generateTraceId,
5
+ parseTraceparent,
6
+ type SpanKind,
7
+ SUMMARY_PROPERTIES_TYPE,
8
+ } from '../protocol'
9
+ import {
10
+ type Clock,
11
+ createClock,
12
+ runSpan,
13
+ type SpanOptions,
14
+ toSpanException,
15
+ type TraceContext,
16
+ } from './tracing'
17
+
18
+ /**
19
+ * Bag of correlation fields (requestId, actorId, etc.). Has no semantic meaning
20
+ * to the logger — it's just merged into emitted JSON. First-class metadata
21
+ * (`service`, `environment`, trace IDs) lives on the logger directly, not here.
22
+ */
23
+ export type LogContext = Record<string, unknown>
24
+
25
+ export interface Logger {
26
+ /** OTel `service.name`. */
27
+ readonly service: string
28
+ /** OTel `deployment.environment`, if set. */
29
+ readonly environment: string | undefined
30
+ readonly context: Readonly<LogContext>
31
+ readonly trace: TraceContext
32
+ debug(message: string): void
33
+ info(message: string): void
34
+ warn(message: string): void
35
+ error(error: unknown, message?: string): void
36
+ metric(event: string, data?: Record<string, unknown>): void
37
+ /**
38
+ * Emit fields the tail worker should merge onto this invocation's
39
+ * `invocation_summary` event. Use for cross-cutting correlation fields
40
+ * (`requestId`, `trace_id`, identity, etc.) that should appear on the
41
+ * per-invocation summary regardless of which log/metric also carried them.
42
+ *
43
+ * Last-write-wins on collision per invocation in the tail worker.
44
+ */
45
+ summary(properties: Record<string, unknown>): void
46
+ /** Extend the correlation context. Returns a new logger sharing trace state. */
47
+ child(context: LogContext): Logger
48
+ /**
49
+ * Run an async function inside a new span. The child logger passed to `fn`
50
+ * has the new span's traceId/spanId bound, so any logs/metrics emitted
51
+ * inside automatically correlate with the span.
52
+ *
53
+ * Errors thrown by `fn` are recorded on the span and re-thrown unchanged.
54
+ * Errors matched by the `isExpectedError` predicate (configured at logger
55
+ * creation) are recorded as `status: 'ok'` — they're expected user-facing
56
+ * failures, not span failures.
57
+ */
58
+ span<T>(name: string, options: SpanOptions, fn: (logger: Logger) => Promise<T>): Promise<T>
59
+ /**
60
+ * Returns headers to propagate the current trace context on outbound calls.
61
+ * Always include the result on outbound `fetch` calls (including service
62
+ * bindings) to maintain a single distributed trace across services.
63
+ */
64
+ tracingHeaders(): Headers
65
+ }
66
+
67
+ const BASE_LOG_LEVEL_KEY = 'logLevel'
68
+
69
+ export const LogLevel = {
70
+ debug: 0,
71
+ info: 1,
72
+ warn: 2,
73
+ error: 3,
74
+ } as const
75
+
76
+ export type LogLevel = keyof typeof LogLevel
77
+
78
+ export function isValidLogLevel(value: unknown): value is LogLevel {
79
+ return typeof value === 'string' && value in LogLevel
80
+ }
81
+
82
+ export interface KVLike {
83
+ get(key: string): Promise<string | null>
84
+ }
85
+
86
+ export interface LoggerOptions {
87
+ /**
88
+ * Logical service name. Required on every logger because OpenTelemetry
89
+ * mandates `service.name` on every span; loggers without it would produce
90
+ * spans tagged `unknown_service` by collectors.
91
+ */
92
+ service: string
93
+ /**
94
+ * Deployment environment. Maps to OTel `deployment.environment` on spans.
95
+ * When set to `'development'`, metrics are suppressed and logs are
96
+ * pretty-printed instead of emitted as JSON.
97
+ */
98
+ environment?: string
99
+ /**
100
+ * Explicit log level. Used as the fallback when `kv` is also provided but
101
+ * doesn't contain a valid level. Defaults to `'info'`.
102
+ */
103
+ level?: LogLevel
104
+ /** Additional correlation fields (requestId, etc.). */
105
+ context?: LogContext
106
+ /**
107
+ * Predicate identifying expected/business errors that should not mark spans
108
+ * as failed. Typical use: `(err) => err instanceof MyExpectedError`. The
109
+ * library stays domain-free; the predicate is the explicit contract.
110
+ */
111
+ isExpectedError?: (err: unknown) => boolean
112
+ /**
113
+ * Inbound headers carrying `traceparent`. When present, the logger continues
114
+ * the inbound trace; otherwise it starts a fresh one. Pass at HTTP entrypoints.
115
+ */
116
+ headers?: Headers
117
+ /**
118
+ * Probability (0..1) of sampling a freshly minted trace. The verdict is
119
+ * decided once at the trace root, propagated to all descendants via
120
+ * `traceparent`, and stamped onto every emitted record. Inbound traces
121
+ * inherit the upstream verdict and ignore this. Defaults to 1 (sample all).
122
+ *
123
+ * Logs/metrics/errors are always forwarded regardless — only span forwarding
124
+ * to Axiom's traces store is gated by the verdict.
125
+ */
126
+ sampleRate?: number
127
+ /**
128
+ * KV namespace for log-level lookup. When set, the logger reads `logLevel`
129
+ * (or `logLevel:{logLevelKey}` if a suffix is provided) from KV and uses
130
+ * that level if valid; otherwise falls back to `level`. Forced to `'debug'`
131
+ * in development.
132
+ */
133
+ kv?: KVLike
134
+ /** Optional KV key suffix; looks up `logLevel:{logLevelKey}` instead of `logLevel`. */
135
+ logLevelKey?: string
136
+ }
137
+
138
+ /**
139
+ * Create a new logger. If `options.headers` carries a `traceparent`, the trace
140
+ * is continued; otherwise a fresh trace starts.
141
+ *
142
+ * Async because the log level may be resolved from KV. If `options.kv` is
143
+ * omitted, no I/O happens but the function is still async for shape consistency.
144
+ *
145
+ * Most entrypoints should use `withTrace` instead, which calls `createLogger`
146
+ * and wraps the handler in a root span. `createLogger` is the right primitive
147
+ * only when there's no enclosing async scope to open a span around.
148
+ */
149
+ export async function createLogger(options: LoggerOptions): Promise<Logger> {
150
+ const level = await resolveLogLevel(options)
151
+ const incoming = options.headers
152
+ ? parseTraceparent(options.headers.get('traceparent'))
153
+ : undefined
154
+ const sampled = incoming?.sampled ?? Math.random() < (options.sampleRate ?? 1)
155
+ return _createLogger({ ...options, level }, createClock(), {
156
+ trace_id: incoming?.trace_id ?? generateTraceId(),
157
+ span_id: undefined,
158
+ parent_span_id: incoming?.span_id,
159
+ sampled,
160
+ })
161
+ }
162
+
163
+ async function resolveLogLevel(opts: {
164
+ environment?: string
165
+ kv?: KVLike
166
+ logLevelKey?: string
167
+ level?: LogLevel
168
+ }): Promise<LogLevel> {
169
+ if (opts.environment === 'development') return 'debug'
170
+ if (!opts.kv) return opts.level ?? 'info'
171
+ const key = opts.logLevelKey ? `${BASE_LOG_LEVEL_KEY}:${opts.logLevelKey}` : BASE_LOG_LEVEL_KEY
172
+ const value = await opts.kv.get(key)
173
+ return isValidLogLevel(value) ? value : (opts.level ?? 'info')
174
+ }
175
+
176
+ function _createLogger(options: LoggerOptions, clock: Clock, trace: TraceContext): Logger {
177
+ const { service, environment, isExpectedError, level = 'info' } = options
178
+ const context: LogContext = { ...options.context }
179
+
180
+ const isDev = environment === 'development'
181
+
182
+ const shouldLog = (logLevel: LogLevel): boolean => {
183
+ return LogLevel[logLevel] >= LogLevel[level]
184
+ }
185
+
186
+ const emit = (entry: Record<string, unknown>) => {
187
+ // eslint-disable-next-line no-console
188
+ console.log(
189
+ JSON.stringify({
190
+ service,
191
+ environment,
192
+ ...context,
193
+ trace_id: trace.trace_id,
194
+ span_id: trace.span_id,
195
+ parent_span_id: trace.parent_span_id,
196
+ sampled: trace.sampled,
197
+ ...entry,
198
+ })
199
+ )
200
+ }
201
+
202
+ const log = (logLevel: LogLevel, message: string) => {
203
+ if (!shouldLog(logLevel)) return
204
+ if (isDev) {
205
+ const levelTag = logLevel.toUpperCase().padEnd(5)
206
+ // eslint-disable-next-line no-console
207
+ console.log(`${levelTag} ${message}`)
208
+ } else {
209
+ emit({ type: 'log', level: logLevel, message })
210
+ }
211
+ }
212
+
213
+ return {
214
+ service,
215
+ environment,
216
+ context,
217
+ trace,
218
+
219
+ debug(message: string) {
220
+ log('debug', message)
221
+ },
222
+
223
+ info(message: string) {
224
+ log('info', message)
225
+ },
226
+
227
+ warn(message: string) {
228
+ log('warn', message)
229
+ },
230
+
231
+ error(error: unknown, message?: string) {
232
+ if (!shouldLog('error')) return
233
+ const exception = error instanceof Error ? toSpanException(error) : undefined
234
+ const body = message ?? (error instanceof Error ? error.message : String(error))
235
+ if (isDev) {
236
+ // eslint-disable-next-line no-console
237
+ console.log(`ERROR ${body}${exception?.stacktrace ? `\n${exception.stacktrace}` : ''}`)
238
+ } else {
239
+ emit({ type: 'error', message: body, ...(exception !== undefined ? { exception } : {}) })
240
+ }
241
+ },
242
+
243
+ metric(event: string, data?: Record<string, unknown>) {
244
+ if (isDev) return
245
+ emit({ type: 'metric', event, ...data })
246
+ },
247
+
248
+ summary(properties: Record<string, unknown>) {
249
+ // Bypass `emit` so trace-context, service, environment, and inherited
250
+ // context don't ride along. Summary records are invocation-scoped and
251
+ // exist only to feed `invocation_summary` in the tail worker.
252
+ // eslint-disable-next-line no-console
253
+ console.log(JSON.stringify({ type: SUMMARY_PROPERTIES_TYPE, ...properties }))
254
+ },
255
+
256
+ child(newContext: LogContext): Logger {
257
+ return _createLogger(
258
+ { ...options, context: { ...context, ...newContext } },
259
+ clock,
260
+ trace
261
+ )
262
+ },
263
+
264
+ async span<T>(
265
+ name: string,
266
+ spanOptions: SpanOptions,
267
+ fn: (logger: Logger) => Promise<T>
268
+ ): Promise<T> {
269
+ return runSpan({
270
+ parent: trace,
271
+ resource: { service, environment },
272
+ name,
273
+ options: spanOptions,
274
+ clock,
275
+ isExpectedError,
276
+ fn: (childCtx) => fn(_createLogger(options, clock, childCtx)),
277
+ onUnexpectedError: (err, childCtx) =>
278
+ _createLogger(options, clock, childCtx).error(err, `span "${name}" failed`),
279
+ })
280
+ },
281
+
282
+ tracingHeaders(): Headers {
283
+ const headers = new Headers()
284
+ if (trace.span_id !== undefined) {
285
+ headers.set('traceparent', formatTraceparent(trace.trace_id, trace.span_id, trace.sampled))
286
+ }
287
+ return headers
288
+ },
289
+ }
290
+ }
291
+
292
+ export interface WithTraceOptions extends LoggerOptions {
293
+ /** Span name. Use the entrypoint's logical operation, e.g. `api.fetch`, `scheduled.tick`. */
294
+ name: string
295
+ /** Span kind. Defaults to `'server'`. Use `'consumer'` for queue/cron. */
296
+ kind?: SpanKind
297
+ /** Attributes to attach to the root span. */
298
+ attributes?: Record<string, AttributeValue>
299
+ }
300
+
301
+ /**
302
+ * Entrypoint helper: build a logger and wrap the handler in a root span in one
303
+ * call. The root span is what local child spans (`logger.span(...)`) parent to,
304
+ * so without it those children are orphaned in the trace viewer.
305
+ *
306
+ * For HTTP entrypoints, pass `headers` to continue any inbound `traceparent`.
307
+ * For queue/cron entrypoints, omit `headers` and the trace starts fresh.
308
+ * Pass `kv` to resolve the log level from KV.
309
+ */
310
+ export async function withTrace<T>(
311
+ options: WithTraceOptions,
312
+ fn: (logger: Logger) => Promise<T>,
313
+ hooks?: { onError?: (err: unknown, logger: Logger) => T | Promise<T> }
314
+ ): Promise<T> {
315
+ const { name, kind = 'server', attributes, ...loggerOptions } = options
316
+ const logger = await createLogger(loggerOptions)
317
+ try {
318
+ return await logger.span(name, { kind, attributes }, fn)
319
+ } catch (err) {
320
+ if (hooks?.onError) {
321
+ return await hooks.onError(err, logger)
322
+ }
323
+ throw err
324
+ }
325
+ }
326
+
327
+ /** No-op logger for tests and environments where logging should be suppressed. */
328
+ export const noopLogger: Logger = {
329
+ service: '',
330
+ environment: undefined,
331
+ context: {},
332
+ trace: { trace_id: '', span_id: undefined, parent_span_id: undefined, sampled: false },
333
+ debug: () => {},
334
+ info: () => {},
335
+ warn: () => {},
336
+ error: () => {},
337
+ metric: () => {},
338
+ summary: () => {},
339
+ child: () => noopLogger,
340
+ span: <T>(_name: string, _options: SpanOptions, fn: (logger: Logger) => Promise<T>) =>
341
+ fn(noopLogger),
342
+ tracingHeaders: () => new Headers(),
343
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Monotonic-anchored wall clock for span timing.
3
+ *
4
+ * Span timestamps come from a single anchor captured at root-logger creation:
5
+ * - `unixMsAtAnchor` — Date.now() at anchor time (wall clock)
6
+ * - `perfAtAnchor` — performance.now() at anchor time (monotonic, sub-ms)
7
+ *
8
+ * Subsequent reads compute `unixMsAtAnchor + (performance.now() - perfAtAnchor)`.
9
+ * This preserves sub-ms ordering within a request and avoids drift if Date.now()
10
+ * jumps mid-request (rare on Workers, but free to guard against).
11
+ *
12
+ * Cross-service clock skew is not addressed here — Workers in different colos may
13
+ * have small wall-clock differences. Tracing UIs tolerate this; it's not fixable
14
+ * from inside the worker.
15
+ */
16
+ export interface Clock {
17
+ /** Returns the current time as Unix epoch nanoseconds (string, no precision loss). */
18
+ nowUnixNano(): string
19
+ }
20
+
21
+ export function createClock(): Clock {
22
+ const unixMsAtAnchor = Date.now()
23
+ const perfAtAnchor = performanceNowOrFallback()
24
+
25
+ return {
26
+ nowUnixNano(): string {
27
+ const elapsedMs = performanceNowOrFallback() - perfAtAnchor
28
+ const unixMs = unixMsAtAnchor + elapsedMs
29
+ return BigInt(Math.trunc(unixMs * 1_000_000)).toString()
30
+ },
31
+ }
32
+ }
33
+
34
+ function performanceNowOrFallback(): number {
35
+ return typeof performance !== 'undefined' && typeof performance.now === 'function'
36
+ ? performance.now()
37
+ : Date.now()
38
+ }
@@ -0,0 +1,8 @@
1
+ export { createClock, type Clock } from './clock'
2
+ export {
3
+ runSpan,
4
+ toSpanException,
5
+ type ChildTraceContext,
6
+ type SpanOptions,
7
+ type TraceContext,
8
+ } from './span'
@@ -0,0 +1,148 @@
1
+ import {
2
+ type AttributeValue,
3
+ generateSpanId,
4
+ SPAN_EVENT_TYPE,
5
+ type SpanEvent,
6
+ type SpanException,
7
+ type SpanKind,
8
+ } from '../../protocol'
9
+ import type { Clock } from './clock'
10
+
11
+ export interface SpanOptions {
12
+ kind: SpanKind
13
+ attributes?: Record<string, AttributeValue>
14
+ }
15
+
16
+ /**
17
+ * Trace identifiers carried by a logger.
18
+ *
19
+ * `span_id` is the id of the span this logger is currently bound to. A root
20
+ * logger created by `createLogger` is not yet bound to any span; its `span_id`
21
+ * is `undefined`. Once `logger.span(...)` runs, the child logger inside is
22
+ * bound to a real span and its `span_id` is defined — see {@link ChildTraceContext}.
23
+ *
24
+ * `parent_span_id` is the upstream span this trace continues (across services,
25
+ * via inbound `traceparent`). For root loggers with no inbound trace it's
26
+ * `undefined`; for loggers inside `runSpan` it's the outer logger's `span_id`
27
+ * (or, if the outer was a root logger with no bound span, the outer's
28
+ * `parent_span_id` — i.e. the upstream service's span).
29
+ *
30
+ * `sampled` is decided once at the trace root and inherited unchanged by every
31
+ * descendant span. The tail worker uses it to decide whether to forward spans
32
+ * to Axiom's traces store; logs/metrics/errors are always forwarded regardless.
33
+ */
34
+ export interface TraceContext {
35
+ readonly trace_id: string
36
+ readonly span_id: string | undefined
37
+ readonly parent_span_id: string | undefined
38
+ readonly sampled: boolean
39
+ }
40
+
41
+ /**
42
+ * Trace identifiers for a logger bound to a span created inside `runSpan`.
43
+ * Always has a real `span_id`.
44
+ */
45
+ export interface ChildTraceContext extends TraceContext {
46
+ readonly span_id: string
47
+ }
48
+
49
+ export interface RunSpanInput<T> {
50
+ parent: TraceContext
51
+ resource: { service: string; environment: string | undefined }
52
+ name: string
53
+ options: SpanOptions
54
+ clock: Clock
55
+ isExpectedError?: (err: unknown) => boolean
56
+ fn: (child: ChildTraceContext) => Promise<T>
57
+ onUnexpectedError?: (err: unknown, child: ChildTraceContext) => void
58
+ }
59
+
60
+ /**
61
+ * Run an async function inside a span. Emits a SpanEvent on completion (success or
62
+ * failure). Errors matched by `isExpectedError` are recorded as `status: 'ok'` but
63
+ * still re-thrown — they're expected user-facing failures, not span failures.
64
+ */
65
+ export async function runSpan<T>(input: RunSpanInput<T>): Promise<T> {
66
+ const { parent, resource, name, options, clock, isExpectedError, fn, onUnexpectedError } = input
67
+ const child: ChildTraceContext = {
68
+ trace_id: parent.trace_id,
69
+ span_id: generateSpanId(),
70
+ parent_span_id: parent.span_id ?? parent.parent_span_id,
71
+ sampled: parent.sampled,
72
+ }
73
+
74
+ const startTimeUnixNano = clock.nowUnixNano()
75
+ const base = {
76
+ ...child,
77
+ ...resource,
78
+ name,
79
+ kind: options.kind,
80
+ attributes: options.attributes,
81
+ }
82
+
83
+ try {
84
+ const result = await fn(child)
85
+ emitSpan({ ...base, status: 'ok', startTimeUnixNano, endTimeUnixNano: clock.nowUnixNano() })
86
+ return result
87
+ } catch (err) {
88
+ const expected = isExpectedError?.(err) === true
89
+ const exception = expected ? undefined : toSpanException(err)
90
+ emitSpan({
91
+ ...base,
92
+ status: expected ? 'ok' : 'error',
93
+ statusMessage: exception?.message,
94
+ exception,
95
+ startTimeUnixNano,
96
+ endTimeUnixNano: clock.nowUnixNano(),
97
+ })
98
+ if (!expected) onUnexpectedError?.(err, child)
99
+ throw err
100
+ }
101
+ }
102
+
103
+ function emitSpan(input: Omit<SpanEvent, 'type'>): void {
104
+ const event: SpanEvent = { type: SPAN_EVENT_TYPE, ...input }
105
+ // eslint-disable-next-line no-console
106
+ console.log(JSON.stringify(event))
107
+ }
108
+
109
+ /**
110
+ * Build the OTel-shaped exception info from any thrown value. Field names
111
+ * (`type`, `message`, `stacktrace`, `escaped`) match OTel semantic conventions
112
+ * so the tail worker can map them directly to OTLP `exception.*` attributes.
113
+ */
114
+ export function toSpanException(err: unknown): SpanException {
115
+ if (err instanceof Error) {
116
+ return {
117
+ type: err.name,
118
+ message: err.message,
119
+ stacktrace: err.stack,
120
+ escaped: true,
121
+ }
122
+ }
123
+ return { message: serializeUnknown(err), escaped: true }
124
+ }
125
+
126
+ function serializeUnknown(value: unknown): string {
127
+ if (value === null) return 'null'
128
+ if (value === undefined) return 'undefined'
129
+ if (typeof value === 'string') return value
130
+ if (value instanceof Error) {
131
+ return JSON.stringify({
132
+ ...value,
133
+ name: value.name,
134
+ message: value.message,
135
+ stack: value.stack,
136
+ })
137
+ }
138
+ try {
139
+ return JSON.stringify(value)
140
+ } catch {
141
+ if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
142
+ return String(value)
143
+ }
144
+ if (typeof value === 'symbol') return value.toString()
145
+ if (typeof value === 'function') return '[function]'
146
+ return '[unserializable]'
147
+ }
148
+ }