Skip to content

Commit d0e977b

Browse files
committed
feat: add OpenTelemetry error reporting
1 parent b81db8e commit d0e977b

5 files changed

Lines changed: 81 additions & 0 deletions

File tree

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,8 @@ RPC_URL_8453=https://example-rpc
1212
RPC_URL_42161=https://example-rpc
1313
RPC_URL_80094=https://example-rpc
1414
RPC_URL_747474=https://example-rpc
15+
# OpenTelemetry error reporting. Leave the endpoint unset to disable.
16+
# Point at any OTLP/HTTP backend (Sentry OTLP, Grafana, Honeycomb, an OTel Collector, ...).
17+
OTEL_EXPORTER_OTLP_ENDPOINT=
18+
OTEL_EXPORTER_OTLP_HEADERS=
19+
OTEL_SERVICE_NAME=yearn-prices

bun.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createPool } from './db'
44
import { readEdgeCache, writeEdgeCache } from './edge-cache'
55
import { ApiError, jsonError } from './errors'
66
import { optionsResponse, withCors } from './http'
7+
import { captureError } from './observability'
78
import { handleHealth } from './routes/health'
89
import { handleBatchHistorical, handleHistorical, handleRangeHistorical, handleSpot, notFoundErrorHeaders } from './routes/prices'
910
import type { Env } from './types'
@@ -110,6 +111,7 @@ export default {
110111
error: error instanceof Error ? error.message : String(error),
111112
}),
112113
)
114+
captureError(ctx, env, error)
113115
return jsonError(new ApiError('INTERNAL_ERROR', 'Unexpected internal error'), withCors({ 'cache-control': CACHE_CONTROL_NO_STORE }))
114116
}
115117
},

src/observability.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type { Env } from './types'
2+
3+
// Cloudflare Workers can't run the OpenTelemetry Node SDK, so errors are sent as
4+
// OTLP/HTTP JSON log records via fetch. Vendor-neutral: point
5+
// OTEL_EXPORTER_OTLP_ENDPOINT at any OTLP backend (Sentry OTLP, Grafana, a Collector, ...).
6+
const SERVICE_NAME = 'yearn-prices'
7+
const SEVERITY_ERROR = 17 // OTLP severityNumber for ERROR
8+
9+
type OtlpAttribute = { key: string; value: { stringValue: string } }
10+
11+
function attr(key: string, value: string): OtlpAttribute {
12+
return { key, value: { stringValue: value } }
13+
}
14+
15+
// OTEL_EXPORTER_OTLP_HEADERS format: "key1=value1,key2=value2".
16+
function parseHeaders(raw?: string): Record<string, string> {
17+
const headers: Record<string, string> = { 'content-type': 'application/json' }
18+
if (!raw) return headers
19+
for (const pair of raw.split(',')) {
20+
const idx = pair.indexOf('=')
21+
if (idx > 0) headers[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim()
22+
}
23+
return headers
24+
}
25+
26+
function buildPayload(serviceName: string, err: Error): unknown {
27+
const attributes = [attr('exception.type', err.name), attr('exception.message', err.message)]
28+
if (err.stack) attributes.push(attr('exception.stacktrace', err.stack))
29+
30+
return {
31+
resourceLogs: [
32+
{
33+
resource: { attributes: [attr('service.name', serviceName)] },
34+
scopeLogs: [
35+
{
36+
scope: { name: serviceName },
37+
logRecords: [
38+
{
39+
timeUnixNano: String(Date.now() * 1_000_000),
40+
severityNumber: SEVERITY_ERROR,
41+
severityText: 'ERROR',
42+
body: { stringValue: err.message },
43+
attributes,
44+
},
45+
],
46+
},
47+
],
48+
},
49+
],
50+
}
51+
}
52+
53+
export function captureError(ctx: ExecutionContext, env: Env, error: unknown): void {
54+
const endpoint = env.OTEL_EXPORTER_OTLP_ENDPOINT
55+
if (!endpoint) return
56+
57+
const err = error instanceof Error ? error : new Error(String(error))
58+
const url = `${endpoint.replace(/\/$/, '')}/v1/logs`
59+
const body = JSON.stringify(buildPayload(env.OTEL_SERVICE_NAME || SERVICE_NAME, err))
60+
61+
// waitUntil lets the export finish after the response is returned (no added latency).
62+
ctx.waitUntil(
63+
fetch(url, {
64+
method: 'POST',
65+
headers: parseHeaders(env.OTEL_EXPORTER_OTLP_HEADERS),
66+
body,
67+
}).catch(() => {}),
68+
)
69+
}

src/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ export type PriceSource = (typeof SOURCE_PRIORITY)[number]
1111
export interface Env {
1212
DATABASE_URL: string
1313
ENSO_API_KEY?: string
14+
OTEL_EXPORTER_OTLP_ENDPOINT?: string
15+
OTEL_EXPORTER_OTLP_HEADERS?: string
16+
OTEL_SERVICE_NAME?: string
1417
[key: string]: string | undefined
1518
}
1619

0 commit comments

Comments
 (0)