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.
- package/LICENSE +21 -0
- package/README.md +156 -0
- package/dist/producer/index.d.ts +4 -0
- package/dist/producer/index.d.ts.map +1 -0
- package/dist/producer/index.js +5 -0
- package/dist/producer/index.js.map +1 -0
- package/dist/producer/logger.d.ts +146 -0
- package/dist/producer/logger.d.ts.map +1 -0
- package/dist/producer/logger.js +179 -0
- package/dist/producer/logger.js.map +1 -0
- package/dist/producer/tracing/clock.d.ts +21 -0
- package/dist/producer/tracing/clock.d.ts.map +1 -0
- package/dist/producer/tracing/clock.js +17 -0
- package/dist/producer/tracing/clock.js.map +1 -0
- package/dist/producer/tracing/index.d.ts +3 -0
- package/dist/producer/tracing/index.d.ts.map +1 -0
- package/dist/producer/tracing/index.js +3 -0
- package/dist/producer/tracing/index.js.map +1 -0
- package/dist/producer/tracing/span.d.ts +63 -0
- package/dist/producer/tracing/span.d.ts.map +1 -0
- package/dist/producer/tracing/span.js +94 -0
- package/dist/producer/tracing/span.js.map +1 -0
- package/dist/protocol/index.d.ts +58 -0
- package/dist/protocol/index.d.ts.map +1 -0
- package/dist/protocol/index.js +50 -0
- package/dist/protocol/index.js.map +1 -0
- package/dist/tail/index.d.ts +43 -0
- package/dist/tail/index.d.ts.map +1 -0
- package/dist/tail/index.js +155 -0
- package/dist/tail/index.js.map +1 -0
- package/dist/tail/spans.d.ts +19 -0
- package/dist/tail/spans.d.ts.map +1 -0
- package/dist/tail/spans.js +93 -0
- package/dist/tail/spans.js.map +1 -0
- package/package.json +41 -0
- package/src/producer/index.ts +28 -0
- package/src/producer/logger.ts +343 -0
- package/src/producer/tracing/clock.ts +38 -0
- package/src/producer/tracing/index.ts +8 -0
- package/src/producer/tracing/span.ts +148 -0
- package/src/protocol/index.ts +104 -0
- package/src/tail/index.ts +237 -0
- 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,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
|
+
}
|