|
1 | | -import { type Span, SpanStatusCode, trace } from "@opentelemetry/api"; |
2 | | -import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto"; |
3 | | -import { resourceFromAttributes } from "@opentelemetry/resources"; |
4 | | -import { NodeSDK } from "@opentelemetry/sdk-node"; |
5 | | -import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-node"; |
6 | | -import { |
7 | | - ATTR_SERVICE_NAME, |
8 | | - ATTR_SERVICE_VERSION, |
9 | | -} from "@opentelemetry/semantic-conventions"; |
10 | 1 | import { log } from "evlog"; |
11 | 2 | import { useLogger as getRequestLogger } from "evlog/elysia"; |
12 | | -import pkg from "../../package.json"; |
13 | 3 |
|
14 | | -let sdk: NodeSDK | null = null; |
15 | | - |
16 | | -export function initTracing(): void { |
17 | | - if (sdk) { |
18 | | - return; |
19 | | - } |
20 | | - |
21 | | - const exporter = new OTLPTraceExporter({ |
22 | | - url: "https://api.axiom.co/v1/traces", |
23 | | - headers: { |
24 | | - Authorization: `Bearer ${process.env.AXIOM_TOKEN}`, |
25 | | - "X-Axiom-Dataset": process.env.AXIOM_DATASET ?? "links", |
26 | | - }, |
27 | | - }); |
28 | | - |
29 | | - sdk = new NodeSDK({ |
30 | | - resource: resourceFromAttributes({ |
31 | | - [ATTR_SERVICE_NAME]: "links", |
32 | | - [ATTR_SERVICE_VERSION]: pkg.version, |
33 | | - }), |
34 | | - spanProcessor: new BatchSpanProcessor(exporter, { |
35 | | - scheduledDelayMillis: 1000, |
36 | | - exportTimeoutMillis: 30_000, |
37 | | - maxExportBatchSize: 512, |
38 | | - maxQueueSize: 2048, |
39 | | - }), |
40 | | - }); |
41 | | - |
42 | | - sdk.start(); |
43 | | -} |
44 | | - |
45 | | -export async function shutdownTracing(): Promise<void> { |
46 | | - if (sdk) { |
47 | | - await sdk.shutdown(); |
48 | | - sdk = null; |
49 | | - } |
50 | | -} |
51 | | - |
52 | | -function getTracer() { |
53 | | - return trace.getTracer("links"); |
54 | | -} |
55 | | - |
56 | | -export function record<T>(name: string, fn: () => Promise<T> | T): Promise<T> { |
57 | | - const tracer = getTracer(); |
58 | | - return tracer.startActiveSpan(name, async (span) => { |
59 | | - const startTime = Date.now(); |
60 | | - try { |
61 | | - const result = await fn(); |
62 | | - const duration = Date.now() - startTime; |
63 | | - span.setAttribute("duration_ms", duration); |
64 | | - |
65 | | - if (duration > 100) { |
66 | | - span.setAttribute("slow", true); |
67 | | - } |
68 | | - |
69 | | - span.setStatus({ code: SpanStatusCode.OK }); |
70 | | - return result; |
71 | | - } catch (error) { |
72 | | - const duration = Date.now() - startTime; |
73 | | - span.setAttribute("duration_ms", duration); |
74 | | - span.setStatus({ |
75 | | - code: SpanStatusCode.ERROR, |
76 | | - message: error instanceof Error ? error.message : String(error), |
77 | | - }); |
78 | | - span.recordException( |
79 | | - error instanceof Error ? error : new Error(String(error)) |
80 | | - ); |
81 | | - throw error; |
82 | | - } finally { |
83 | | - span.end(); |
84 | | - } |
85 | | - }); |
| 4 | +/** |
| 5 | + * Run a named operation. Request-level timing and HTTP metadata are emitted by |
| 6 | + * evlog on the wide event. |
| 7 | + */ |
| 8 | +export function record<T>(_name: string, fn: () => Promise<T> | T): Promise<T> { |
| 9 | + return Promise.resolve().then(() => fn()); |
86 | 10 | } |
87 | 11 |
|
| 12 | +/** |
| 13 | + * Merge structured fields into the active request wide event (evlog). |
| 14 | + */ |
88 | 15 | export function mergeWideEvent( |
89 | 16 | fields: Record<string, string | number | boolean> |
90 | 17 | ): void { |
91 | | - setAttributes(fields); |
92 | 18 | try { |
93 | 19 | getRequestLogger().set(fields as Record<string, unknown>); |
94 | 20 | } catch { |
95 | | - // Outside request context — OTel attributes already set above |
96 | | - } |
97 | | -} |
98 | | - |
99 | | -export function captureError( |
100 | | - error: unknown, |
101 | | - attributes?: Record<string, string | number | boolean> |
102 | | -): void { |
103 | | - const errorObj = error instanceof Error ? error : new Error(String(error)); |
104 | | - |
105 | | - if (attributes?.error_step != null) { |
106 | | - mergeWideEvent({ request_error: true, ...attributes }); |
107 | | - } |
108 | | - |
109 | | - log.error({ |
110 | | - links: "captureError", |
111 | | - error: errorObj.message, |
112 | | - stack: errorObj.stack, |
113 | | - ...(attributes ?? {}), |
114 | | - }); |
115 | | - |
116 | | - const span = trace.getActiveSpan(); |
117 | | - if (!span) { |
118 | | - return; |
119 | | - } |
120 | | - |
121 | | - span.recordException(errorObj); |
122 | | - span.setStatus({ code: SpanStatusCode.ERROR }); |
123 | | - |
124 | | - if (attributes) { |
125 | | - for (const [key, value] of Object.entries(attributes)) { |
126 | | - span.setAttribute(key, value); |
127 | | - } |
| 21 | + // Outside request context |
128 | 22 | } |
129 | 23 | } |
130 | 24 |
|
| 25 | +/** |
| 26 | + * Merge structured fields, filtering out null/undefined values. |
| 27 | + */ |
131 | 28 | export function setAttributes( |
132 | 29 | attributes: Record<string, string | number | boolean | null | undefined> |
133 | 30 | ): void { |
134 | | - const span = trace.getActiveSpan(); |
135 | | - if (span) { |
136 | | - for (const [key, value] of Object.entries(attributes)) { |
137 | | - if (value !== null && value !== undefined) { |
138 | | - span.setAttribute(key, value); |
139 | | - } |
| 31 | + const filtered: Record<string, string | number | boolean> = {}; |
| 32 | + for (const [key, value] of Object.entries(attributes)) { |
| 33 | + if (value !== null && value !== undefined) { |
| 34 | + filtered[key] = value; |
140 | 35 | } |
141 | 36 | } |
| 37 | + mergeWideEvent(filtered); |
142 | 38 | } |
143 | 39 |
|
144 | | -export function startRequestSpan( |
145 | | - method: string, |
146 | | - path: string, |
147 | | - route?: string |
148 | | -): Span { |
149 | | - const tracer = getTracer(); |
150 | | - return tracer.startSpan(`${method} ${route ?? path}`, { |
151 | | - kind: 1, |
152 | | - attributes: { |
153 | | - http_method: method, |
154 | | - http_route: route ?? path, |
155 | | - http_target: path, |
156 | | - }, |
157 | | - }); |
158 | | -} |
159 | | - |
160 | | -export function endRequestSpan( |
161 | | - span: Span, |
162 | | - statusCode: number, |
163 | | - startTime: number |
| 40 | +/** |
| 41 | + * Attach an error to the active request wide event when inside the evlog |
| 42 | + * middleware; otherwise emit a global structured log line. |
| 43 | + */ |
| 44 | +export function captureError( |
| 45 | + error: unknown, |
| 46 | + attributes?: Record<string, string | number | boolean> |
164 | 47 | ): void { |
165 | | - const duration = Date.now() - startTime; |
166 | | - span.setAttribute("http_status_code", statusCode); |
167 | | - span.setAttribute("http_response_duration_ms", duration); |
168 | | - |
169 | | - if (duration > 100) { |
170 | | - span.setAttribute("http_slow", true); |
| 48 | + const err = error instanceof Error ? error : new Error(String(error)); |
| 49 | + if (attributes?.error_step != null) { |
| 50 | + mergeWideEvent({ request_error: true, ...attributes }); |
| 51 | + } |
| 52 | + try { |
| 53 | + const requestLog = getRequestLogger(); |
| 54 | + if (attributes) { |
| 55 | + requestLog.error(err, attributes as Record<string, unknown>); |
| 56 | + } else { |
| 57 | + requestLog.error(err); |
| 58 | + } |
| 59 | + } catch { |
| 60 | + log.error({ |
| 61 | + service: "links", |
| 62 | + error_message: err.message, |
| 63 | + ...(attributes ?? {}), |
| 64 | + }); |
171 | 65 | } |
172 | | - |
173 | | - span.setStatus({ |
174 | | - code: statusCode >= 400 ? SpanStatusCode.ERROR : SpanStatusCode.OK, |
175 | | - message: statusCode >= 400 ? `HTTP ${statusCode}` : undefined, |
176 | | - }); |
177 | | - span.end(); |
178 | 66 | } |
0 commit comments