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
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Wire format shared by the producer (logger) and the tail-worker consumer.
3
+ *
4
+ * The producer emits JSON lines via `console.log`; the tail worker parses
5
+ * them and forwards to Axiom. Both sides import these types so the format
6
+ * is enforced by the type system rather than coordinated by string matching.
7
+ */
8
+
9
+ export const SPAN_EVENT_TYPE = 'span' as const
10
+
11
+ /** Tag identifying a record carrying fields to merge onto `invocation_summary`. */
12
+ export const SUMMARY_PROPERTIES_TYPE = 'summary_properties' as const
13
+
14
+ export type SpanKind = 'internal' | 'server' | 'client' | 'producer' | 'consumer'
15
+
16
+ export type SpanStatus = 'ok' | 'error'
17
+
18
+ export type AttributeValue = string | number | boolean
19
+
20
+ export type SpanException = {
21
+ type?: string
22
+ message: string
23
+ stacktrace?: string
24
+ escaped?: boolean
25
+ }
26
+
27
+ export interface SpanEvent {
28
+ type: typeof SPAN_EVENT_TYPE
29
+ trace_id: string
30
+ span_id: string
31
+ parent_span_id?: string
32
+ name: string
33
+ kind: SpanKind
34
+ /** Unix epoch nanoseconds, encoded as string to avoid JS number precision loss. */
35
+ startTimeUnixNano: string
36
+ endTimeUnixNano: string
37
+ status: SpanStatus
38
+ statusMessage?: string
39
+ service: string
40
+ environment?: string
41
+ attributes?: Record<string, AttributeValue>
42
+ /**
43
+ * Sampling verdict, fixed at trace root and propagated to every child span.
44
+ * The tail worker forwards spans to Axiom's traces store only when true.
45
+ */
46
+ sampled: boolean
47
+ /**
48
+ * Optional exception info attached when status === 'error'.
49
+ * Field names match OTel semantic conventions (`exception.type`,
50
+ * `exception.message`, `exception.stacktrace`, `exception.escaped`) so
51
+ * the tail worker maps them directly into the OTLP `exception` span event.
52
+ */
53
+ exception?: SpanException
54
+ }
55
+
56
+ export function isSpanEvent(value: unknown): value is SpanEvent {
57
+ if (typeof value !== 'object' || value === null) return false
58
+ const v = value as Record<string, unknown>
59
+ return (
60
+ v.type === SPAN_EVENT_TYPE &&
61
+ typeof v.trace_id === 'string' &&
62
+ typeof v.span_id === 'string' &&
63
+ typeof v.name === 'string' &&
64
+ typeof v.startTimeUnixNano === 'string' &&
65
+ typeof v.endTimeUnixNano === 'string' &&
66
+ typeof v.service === 'string'
67
+ )
68
+ }
69
+
70
+ const TRACEPARENT_RE = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/
71
+
72
+ export interface TraceParent {
73
+ trace_id: string
74
+ span_id: string
75
+ sampled: boolean
76
+ }
77
+
78
+ export function parseTraceparent(header: string | null | undefined): TraceParent | undefined {
79
+ if (!header) return undefined
80
+ const match = TRACEPARENT_RE.exec(header.trim().toLowerCase())
81
+ if (!match) return undefined
82
+ const [, trace_id, span_id, flags] = match
83
+ return { trace_id: trace_id!, span_id: span_id!, sampled: (parseInt(flags!, 16) & 1) === 1 }
84
+ }
85
+
86
+ export function formatTraceparent(trace_id: string, span_id: string, sampled: boolean): string {
87
+ return `00-${trace_id}-${span_id}-${sampled ? '01' : '00'}`
88
+ }
89
+
90
+ export function generateTraceId(): string {
91
+ return randomHex(32)
92
+ }
93
+
94
+ export function generateSpanId(): string {
95
+ return randomHex(16)
96
+ }
97
+
98
+ function randomHex(length: number): string {
99
+ const bytes = new Uint8Array(length / 2)
100
+ crypto.getRandomValues(bytes)
101
+ let out = ''
102
+ for (const b of bytes) out += b.toString(16).padStart(2, '0')
103
+ return out
104
+ }
@@ -0,0 +1,237 @@
1
+ import { isSpanEvent, type SpanEvent, SUMMARY_PROPERTIES_TYPE } from '../protocol'
2
+ import { sendSpansToAxiom } from './spans'
3
+
4
+ export interface AxiomConfig {
5
+ /** Axiom API token. */
6
+ axiomToken: string
7
+ /** Axiom dataset receiving both ingest events and OTLP traces. */
8
+ axiomDataset: string
9
+ /**
10
+ * Axiom ingest endpoint. Defaults to the US edge:
11
+ * `https://us-east-1.aws.edge.axiom.co/v1/ingest`.
12
+ * Override with the EU edge or self-hosted Axiom URL as needed.
13
+ * The dataset name is appended automatically.
14
+ */
15
+ ingestBaseUrl?: string
16
+ /**
17
+ * Axiom OTLP traces endpoint. Defaults to `https://api.axiom.co/v1/traces`.
18
+ */
19
+ tracesEndpoint?: string
20
+ }
21
+
22
+ export type TailHandler = (
23
+ events: TraceItem[],
24
+ env: unknown,
25
+ ctx: ExecutionContext
26
+ ) => Promise<void>
27
+
28
+ /**
29
+ * Build a Cloudflare Workers `tail()` handler that forwards logs, metrics,
30
+ * errors, and spans from any number of producing workers to Axiom.
31
+ *
32
+ * Configure each producing worker with:
33
+ * "tail_consumers": [{ "service": "<name-of-this-tail-worker>" }]
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * import { createTailHandler } from 'workers-axiom/tail'
38
+ *
39
+ * interface Env { AXIOM_TOKEN: string; AXIOM_DATASET: string }
40
+ *
41
+ * export default {
42
+ * tail: (events, env: Env, ctx) =>
43
+ * createTailHandler({
44
+ * axiomToken: env.AXIOM_TOKEN,
45
+ * axiomDataset: env.AXIOM_DATASET,
46
+ * })(events, env, ctx),
47
+ * }
48
+ * ```
49
+ */
50
+ export function createTailHandler(config: AxiomConfig): TailHandler {
51
+ const { axiomToken, axiomDataset } = config
52
+ const ingestBase = config.ingestBaseUrl ?? 'https://us-east-1.aws.edge.axiom.co/v1/ingest'
53
+
54
+ return async function tail(events, _env, _ctx) {
55
+ const axiomEvents: AxiomEvent[] = []
56
+ const spans: SpanEvent[] = []
57
+
58
+ for (const event of events) {
59
+ const worker = event.scriptName ?? 'unknown'
60
+ const eventTime = new Date(event.eventTimestamp ?? Date.now()).toISOString()
61
+
62
+ // Fields the app explicitly opted into surfacing on `invocation_summary`,
63
+ // collected from `summary_properties` records emitted via `logger.summary`.
64
+ // Last-write-wins.
65
+ const summaryProps: Record<string, unknown> = {}
66
+ // Last trace_id observed in this worker invocation. A single invocation
67
+ // can contain multiple traces; we keep the last one because it's the
68
+ // most specific to the invocation's outbound work.
69
+ let traceId: string | undefined
70
+
71
+ for (const log of event.logs) {
72
+ const logTime = new Date(log.timestamp).toISOString()
73
+
74
+ for (const msg of log.message) {
75
+ if (typeof msg !== 'string') continue
76
+
77
+ let parsed: unknown
78
+ try {
79
+ parsed = JSON.parse(msg)
80
+ } catch {
81
+ continue
82
+ }
83
+
84
+ if (typeof parsed !== 'object' || parsed === null) continue
85
+
86
+ // Span events go to Axiom's /v1/traces endpoint, not the ingest
87
+ // dataset. Don't forward as a regular log row.
88
+ if (isSpanEvent(parsed)) {
89
+ spans.push(parsed)
90
+ traceId = parsed.trace_id
91
+ continue
92
+ }
93
+
94
+ const record = parsed as Record<string, unknown>
95
+
96
+ if (typeof record.trace_id === 'string') {
97
+ traceId = record.trace_id
98
+ }
99
+
100
+ if (record.type === SUMMARY_PROPERTIES_TYPE) {
101
+ for (const [key, value] of Object.entries(record)) {
102
+ if (key === 'type' || key === 'trace_id') continue
103
+ summaryProps[key] = value
104
+ }
105
+ continue
106
+ }
107
+
108
+ axiomEvents.push({
109
+ _time: logTime,
110
+ worker,
111
+ ...record,
112
+ })
113
+ }
114
+ }
115
+
116
+ const requestInfo = extractRequestInfo(event.event)
117
+
118
+ axiomEvents.push({
119
+ _time: eventTime,
120
+ worker,
121
+ type: 'invocation_summary',
122
+ trace_id: traceId,
123
+ ...summaryProps,
124
+ ...requestInfo,
125
+ cpuTime: event.cpuTime,
126
+ wallTime: event.wallTime,
127
+ traceEvent: event.event as unknown,
128
+ })
129
+ }
130
+
131
+ // Run synchronously (not via waitUntil) so failures surface in tail logs
132
+ // and OTLP/ingest errors aren't silently swallowed by the runtime.
133
+ await Promise.all([
134
+ sendToAxiom({ events: axiomEvents, axiomToken, axiomDataset, ingestBase }),
135
+ sendSpansToAxiom({
136
+ spans,
137
+ axiomToken,
138
+ axiomDataset,
139
+ ...(config.tracesEndpoint !== undefined && { tracesEndpoint: config.tracesEndpoint }),
140
+ }),
141
+ ])
142
+ }
143
+ }
144
+
145
+ interface AxiomEvent {
146
+ _time: string
147
+ worker: string
148
+ [key: string]: unknown
149
+ }
150
+
151
+ interface TraceItemFetchEvent {
152
+ request?: {
153
+ method?: string
154
+ url?: string
155
+ headers?: Record<string, string>
156
+ cf?: {
157
+ country?: string
158
+ city?: string
159
+ asn?: number
160
+ }
161
+ }
162
+ response?: {
163
+ status?: number
164
+ }
165
+ }
166
+
167
+ interface ExtractedFields {
168
+ reqMethod?: string
169
+ reqUrl?: string
170
+ reqPath?: string
171
+ reqHost?: string
172
+ reqIp?: string
173
+ reqCountry?: string
174
+ reqCity?: string
175
+ reqAsn?: number
176
+ reqUserAgent?: string
177
+ reqCfRay?: string
178
+ resStatus?: number
179
+ }
180
+
181
+ function extractRequestInfo(event: unknown): ExtractedFields | null {
182
+ const fetchEvent = event as TraceItemFetchEvent | undefined
183
+ if (!fetchEvent?.request) return null
184
+
185
+ const req = fetchEvent.request
186
+ const headers = req.headers ?? {}
187
+ const cf = req.cf ?? {}
188
+
189
+ let path: string | undefined
190
+ try {
191
+ if (req.url) path = new URL(req.url).pathname
192
+ } catch {
193
+ // Invalid URL
194
+ }
195
+
196
+ return {
197
+ reqMethod: req.method,
198
+ reqUrl: req.url,
199
+ reqPath: path,
200
+ reqHost: headers['host'],
201
+ reqIp: headers['cf-connecting-ip'],
202
+ reqCountry: cf.country,
203
+ reqCity: cf.city,
204
+ reqAsn: cf.asn,
205
+ reqUserAgent: headers['user-agent'],
206
+ reqCfRay: headers['cf-ray'],
207
+ resStatus: fetchEvent.response?.status,
208
+ }
209
+ }
210
+
211
+ async function sendToAxiom(params: {
212
+ events: AxiomEvent[]
213
+ axiomToken: string
214
+ axiomDataset: string
215
+ ingestBase: string
216
+ }): Promise<void> {
217
+ const { events, axiomToken, axiomDataset, ingestBase } = params
218
+ if (events.length === 0) return
219
+
220
+ const response = await fetch(`${ingestBase}/${axiomDataset}`, {
221
+ method: 'POST',
222
+ headers: {
223
+ 'Content-Type': 'application/json',
224
+ Authorization: `Bearer ${axiomToken}`,
225
+ },
226
+ body: JSON.stringify(events),
227
+ })
228
+
229
+ if (!response.ok) {
230
+ const text = await response.text().catch(() => '<no body>')
231
+ throw new Error(
232
+ `Failed to send logs to Axiom: ${response.status} ${response.statusText} — ${text}`
233
+ )
234
+ }
235
+ }
236
+
237
+ export { sendSpansToAxiom } from './spans'
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Span forwarding to Axiom's OTLP `/v1/traces` endpoint.
3
+ *
4
+ * Spans arrive as `console.log` JSON events with the {@link SpanEvent} wire format.
5
+ * The sampling verdict is decided once at the trace root by the producing logger
6
+ * and stamped on every span via `SpanEvent.sampled`; the tail worker is a pure
7
+ * router that forwards only `sampled === true` spans. Logs/metrics/errors are
8
+ * forwarded unconditionally by the caller — sampling gates traces, not log visibility.
9
+ */
10
+ import type { SpanEvent } from '../protocol'
11
+
12
+ const SPAN_KIND_TO_OTLP: Record<SpanEvent['kind'], number> = {
13
+ internal: 1,
14
+ server: 2,
15
+ client: 3,
16
+ producer: 4,
17
+ consumer: 5,
18
+ }
19
+
20
+ export interface SendSpansInput {
21
+ spans: SpanEvent[]
22
+ axiomToken: string
23
+ axiomDataset: string
24
+ /** Full URL of Axiom's OTLP traces endpoint. Defaults to `https://api.axiom.co/v1/traces`. */
25
+ tracesEndpoint?: string
26
+ }
27
+
28
+ export async function sendSpansToAxiom(input: SendSpansInput): Promise<void> {
29
+ const { spans, axiomToken, axiomDataset } = input
30
+ const endpoint = input.tracesEndpoint ?? 'https://api.axiom.co/v1/traces'
31
+ const kept = spans.filter((s) => s.sampled)
32
+ if (kept.length === 0) return
33
+
34
+ const body = toOtlpPayload(kept)
35
+ const response = await fetch(endpoint, {
36
+ method: 'POST',
37
+ headers: {
38
+ 'Content-Type': 'application/json',
39
+ Authorization: `Bearer ${axiomToken}`,
40
+ 'X-Axiom-Dataset': axiomDataset,
41
+ },
42
+ body: JSON.stringify(body),
43
+ })
44
+
45
+ if (!response.ok) {
46
+ const text = await response.text().catch(() => '<no body>')
47
+ throw new Error(
48
+ `Failed to send spans to Axiom: ${response.status} ${response.statusText} — ${text}`
49
+ )
50
+ }
51
+ }
52
+
53
+ interface OtlpResourceSpans {
54
+ resource: { attributes: OtlpAttribute[] }
55
+ scopeSpans: { scope: { name: string }; spans: OtlpSpan[] }[]
56
+ }
57
+
58
+ interface OtlpAttribute {
59
+ key: string
60
+ value: { stringValue: string } | { intValue: number } | { boolValue: boolean }
61
+ }
62
+
63
+ interface OtlpSpan {
64
+ traceId: string
65
+ spanId: string
66
+ parentSpanId?: string
67
+ name: string
68
+ kind: number
69
+ startTimeUnixNano: string
70
+ endTimeUnixNano: string
71
+ status: { code: number; message?: string }
72
+ attributes: OtlpAttribute[]
73
+ events?: { timeUnixNano: string; name: string; attributes: OtlpAttribute[] }[]
74
+ }
75
+
76
+ /**
77
+ * Group spans by `(service, environment)` so each unique pair becomes its own
78
+ * OTLP `ResourceSpans` entry. Axiom's Traces dashboard groups by `service.name`
79
+ * from the resource block, so this grouping is what makes per-service filtering work.
80
+ */
81
+ function toOtlpPayload(spans: SpanEvent[]): { resourceSpans: OtlpResourceSpans[] } {
82
+ const byResource = new Map<string, SpanEvent[]>()
83
+ for (const span of spans) {
84
+ const key = `${span.service} ${span.environment ?? ''}`
85
+ const list = byResource.get(key)
86
+ if (list) list.push(span)
87
+ else byResource.set(key, [span])
88
+ }
89
+
90
+ const resourceSpans: OtlpResourceSpans[] = []
91
+ for (const group of byResource.values()) {
92
+ const first = group[0]!
93
+ const resource = {
94
+ attributes: [
95
+ attr('service.name', first.service),
96
+ ...(first.environment ? [attr('deployment.environment', first.environment)] : []),
97
+ ],
98
+ }
99
+ resourceSpans.push({
100
+ resource,
101
+ scopeSpans: [{ scope: { name: first.service }, spans: group.map(toOtlpSpan) }],
102
+ })
103
+ }
104
+ return { resourceSpans }
105
+ }
106
+
107
+ function toOtlpSpan(span: SpanEvent): OtlpSpan {
108
+ const attributes = Object.entries(span.attributes ?? {}).map(([key, value]) => attr(key, value))
109
+ const result: OtlpSpan = {
110
+ traceId: span.trace_id,
111
+ spanId: span.span_id,
112
+ parentSpanId: span.parent_span_id,
113
+ name: span.name,
114
+ kind: SPAN_KIND_TO_OTLP[span.kind],
115
+ startTimeUnixNano: span.startTimeUnixNano,
116
+ endTimeUnixNano: span.endTimeUnixNano,
117
+ status: { code: span.status === 'error' ? 2 : 1, message: span.statusMessage },
118
+ attributes,
119
+ }
120
+ if (span.exception) {
121
+ const eventAttrs: OtlpAttribute[] = []
122
+ for (const [key, value] of Object.entries(span.exception)) {
123
+ if (value === undefined) continue
124
+ eventAttrs.push(attr(`exception.${key}`, value as string | number | boolean))
125
+ }
126
+ result.events = [
127
+ { timeUnixNano: span.endTimeUnixNano, name: 'exception', attributes: eventAttrs },
128
+ ]
129
+ }
130
+ return result
131
+ }
132
+
133
+ function attr(key: string, value: string | number | boolean): OtlpAttribute {
134
+ if (typeof value === 'string') return { key, value: { stringValue: value } }
135
+ if (typeof value === 'boolean') return { key, value: { boolValue: value } }
136
+ return { key, value: { intValue: value } }
137
+ }