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
|
@@ -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
|
+
}
|