From d0e977b2f2ebe82541685a9c7bc9b5a5023ab98c Mon Sep 17 00:00:00 2001 From: matheus1lva <831308+matheus1lva@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:55:27 -0300 Subject: [PATCH 1/4] feat: add OpenTelemetry error reporting --- .env.example | 5 ++++ bun.lock | 2 ++ src/index.ts | 2 ++ src/observability.ts | 69 ++++++++++++++++++++++++++++++++++++++++++++ src/types.ts | 3 ++ 5 files changed, 81 insertions(+) create mode 100644 src/observability.ts diff --git a/.env.example b/.env.example index da9afc2..6fabc57 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,8 @@ RPC_URL_8453=https://example-rpc RPC_URL_42161=https://example-rpc RPC_URL_80094=https://example-rpc RPC_URL_747474=https://example-rpc +# OpenTelemetry error reporting. Leave the endpoint unset to disable. +# Point at any OTLP/HTTP backend (Sentry OTLP, Grafana, Honeycomb, an OTel Collector, ...). +OTEL_EXPORTER_OTLP_ENDPOINT= +OTEL_EXPORTER_OTLP_HEADERS= +OTEL_SERVICE_NAME=yearn-prices diff --git a/bun.lock b/bun.lock index 03e58de..ad2d438 100644 --- a/bun.lock +++ b/bun.lock @@ -168,6 +168,8 @@ "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], + "@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="], "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], diff --git a/src/index.ts b/src/index.ts index 7d51fc1..8757031 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { createPool } from './db' import { readEdgeCache, writeEdgeCache } from './edge-cache' import { ApiError, jsonError } from './errors' import { optionsResponse, withCors } from './http' +import { captureError } from './observability' import { handleHealth } from './routes/health' import { handleBatchHistorical, handleHistorical, handleRangeHistorical, handleSpot, notFoundErrorHeaders } from './routes/prices' import type { Env } from './types' @@ -110,6 +111,7 @@ export default { error: error instanceof Error ? error.message : String(error), }), ) + captureError(ctx, env, error) return jsonError(new ApiError('INTERNAL_ERROR', 'Unexpected internal error'), withCors({ 'cache-control': CACHE_CONTROL_NO_STORE })) } }, diff --git a/src/observability.ts b/src/observability.ts new file mode 100644 index 0000000..fe37470 --- /dev/null +++ b/src/observability.ts @@ -0,0 +1,69 @@ +import type { Env } from './types' + +// Cloudflare Workers can't run the OpenTelemetry Node SDK, so errors are sent as +// OTLP/HTTP JSON log records via fetch. Vendor-neutral: point +// OTEL_EXPORTER_OTLP_ENDPOINT at any OTLP backend (Sentry OTLP, Grafana, a Collector, ...). +const SERVICE_NAME = 'yearn-prices' +const SEVERITY_ERROR = 17 // OTLP severityNumber for ERROR + +type OtlpAttribute = { key: string; value: { stringValue: string } } + +function attr(key: string, value: string): OtlpAttribute { + return { key, value: { stringValue: value } } +} + +// OTEL_EXPORTER_OTLP_HEADERS format: "key1=value1,key2=value2". +function parseHeaders(raw?: string): Record { + const headers: Record = { 'content-type': 'application/json' } + if (!raw) return headers + for (const pair of raw.split(',')) { + const idx = pair.indexOf('=') + if (idx > 0) headers[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim() + } + return headers +} + +function buildPayload(serviceName: string, err: Error): unknown { + const attributes = [attr('exception.type', err.name), attr('exception.message', err.message)] + if (err.stack) attributes.push(attr('exception.stacktrace', err.stack)) + + return { + resourceLogs: [ + { + resource: { attributes: [attr('service.name', serviceName)] }, + scopeLogs: [ + { + scope: { name: serviceName }, + logRecords: [ + { + timeUnixNano: String(Date.now() * 1_000_000), + severityNumber: SEVERITY_ERROR, + severityText: 'ERROR', + body: { stringValue: err.message }, + attributes, + }, + ], + }, + ], + }, + ], + } +} + +export function captureError(ctx: ExecutionContext, env: Env, error: unknown): void { + const endpoint = env.OTEL_EXPORTER_OTLP_ENDPOINT + if (!endpoint) return + + const err = error instanceof Error ? error : new Error(String(error)) + const url = `${endpoint.replace(/\/$/, '')}/v1/logs` + const body = JSON.stringify(buildPayload(env.OTEL_SERVICE_NAME || SERVICE_NAME, err)) + + // waitUntil lets the export finish after the response is returned (no added latency). + ctx.waitUntil( + fetch(url, { + method: 'POST', + headers: parseHeaders(env.OTEL_EXPORTER_OTLP_HEADERS), + body, + }).catch(() => {}), + ) +} diff --git a/src/types.ts b/src/types.ts index facfa57..105359d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,6 +11,9 @@ export type PriceSource = (typeof SOURCE_PRIORITY)[number] export interface Env { DATABASE_URL: string ENSO_API_KEY?: string + OTEL_EXPORTER_OTLP_ENDPOINT?: string + OTEL_EXPORTER_OTLP_HEADERS?: string + OTEL_SERVICE_NAME?: string [key: string]: string | undefined } From ee2411a7f5cf2cdc8ce59ada9477749f0d7cdaa9 Mon Sep 17 00:00:00 2001 From: matheus1lva <831308+matheus1lva@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:57:47 -0300 Subject: [PATCH 2/4] fix(otel): support Sentry logs endpoints --- .env.example | 7 ++-- src/index.ts | 1 + src/observability.ts | 22 +++++++++--- src/types.ts | 2 ++ test/observability.test.ts | 72 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 98 insertions(+), 6 deletions(-) create mode 100644 test/observability.test.ts diff --git a/.env.example b/.env.example index 6fabc57..df71964 100644 --- a/.env.example +++ b/.env.example @@ -12,8 +12,11 @@ RPC_URL_8453=https://example-rpc RPC_URL_42161=https://example-rpc RPC_URL_80094=https://example-rpc RPC_URL_747474=https://example-rpc -# OpenTelemetry error reporting. Leave the endpoint unset to disable. -# Point at any OTLP/HTTP backend (Sentry OTLP, Grafana, Honeycomb, an OTel Collector, ...). +# OpenTelemetry error reporting. Signal-specific values take precedence. +# Sentry provides the complete OTLP logs endpoint and auth header under Project Settings > Client Keys (DSN). +OTEL_EXPORTER_OTLP_LOGS_ENDPOINT= +OTEL_EXPORTER_OTLP_LOGS_HEADERS= +# Generic OTLP base URL. The worker appends /v1/logs. OTEL_EXPORTER_OTLP_ENDPOINT= OTEL_EXPORTER_OTLP_HEADERS= OTEL_SERVICE_NAME=yearn-prices diff --git a/src/index.ts b/src/index.ts index de231e5..e5f2b50 100644 --- a/src/index.ts +++ b/src/index.ts @@ -108,6 +108,7 @@ export default { const headers = error.code === 'NOT_FOUND' && notFoundCacheable ? withCors(notFoundErrorHeaders()) : withCors({ 'cache-control': CACHE_CONTROL_NO_STORE }) + if (error.status >= 500) captureError(ctx, env, error) return jsonError(error, headers) } diff --git a/src/observability.ts b/src/observability.ts index fe37470..b2b95e7 100644 --- a/src/observability.ts +++ b/src/observability.ts @@ -51,19 +51,33 @@ function buildPayload(serviceName: string, err: Error): unknown { } export function captureError(ctx: ExecutionContext, env: Env, error: unknown): void { - const endpoint = env.OTEL_EXPORTER_OTLP_ENDPOINT + const logsEndpoint = env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT + const endpoint = logsEndpoint || env.OTEL_EXPORTER_OTLP_ENDPOINT if (!endpoint) return const err = error instanceof Error ? error : new Error(String(error)) - const url = `${endpoint.replace(/\/$/, '')}/v1/logs` + const normalizedEndpoint = endpoint.replace(/\/$/, '') + const url = logsEndpoint ? normalizedEndpoint : `${normalizedEndpoint}/v1/logs` const body = JSON.stringify(buildPayload(env.OTEL_SERVICE_NAME || SERVICE_NAME, err)) // waitUntil lets the export finish after the response is returned (no added latency). ctx.waitUntil( fetch(url, { method: 'POST', - headers: parseHeaders(env.OTEL_EXPORTER_OTLP_HEADERS), + headers: parseHeaders(env.OTEL_EXPORTER_OTLP_LOGS_HEADERS || env.OTEL_EXPORTER_OTLP_HEADERS), body, - }).catch(() => {}), + }).then((response) => { + if (!response.ok) { + console.error(JSON.stringify({ + message: 'otel-export-error', + status: response.status, + })) + } + }).catch((exportError) => { + console.error(JSON.stringify({ + message: 'otel-export-error', + error: exportError instanceof Error ? exportError.message : String(exportError), + })) + }), ) } diff --git a/src/types.ts b/src/types.ts index c5f6745..9eecd08 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,6 +14,8 @@ export interface Env { ENSO_API_KEY?: string OTEL_EXPORTER_OTLP_ENDPOINT?: string OTEL_EXPORTER_OTLP_HEADERS?: string + OTEL_EXPORTER_OTLP_LOGS_ENDPOINT?: string + OTEL_EXPORTER_OTLP_LOGS_HEADERS?: string OTEL_SERVICE_NAME?: string [key: string]: string | undefined } diff --git a/test/observability.test.ts b/test/observability.test.ts new file mode 100644 index 0000000..79e5237 --- /dev/null +++ b/test/observability.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { captureError } from '../src/observability' +import type { Env } from '../src/types' + +function context(): ExecutionContext { + return { waitUntil: vi.fn() } as unknown as ExecutionContext +} + +async function pending(ctx: ExecutionContext): Promise { + await (ctx.waitUntil as ReturnType).mock.calls[0][0] +} + +describe('captureError', () => { + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('uses a signal-specific Sentry endpoint without appending another path', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })) + vi.stubGlobal('fetch', fetchMock) + const ctx = context() + const env = { + DATABASE_URL: 'postgres://x', + OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: 'https://o0.ingest.sentry.io/api/0/integration/otlp/v1/logs', + OTEL_EXPORTER_OTLP_LOGS_HEADERS: 'x-sentry-auth=sentry sentry_key=public-key', + } satisfies Env + + captureError(ctx, env, new Error('boom')) + await pending(ctx) + + expect(fetchMock).toHaveBeenCalledWith(env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT, expect.objectContaining({ + headers: expect.objectContaining({ 'x-sentry-auth': 'sentry sentry_key=public-key' }), + })) + }) + + it('appends the logs path to the generic OTLP base endpoint', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })) + vi.stubGlobal('fetch', fetchMock) + const ctx = context() + + captureError(ctx, { + DATABASE_URL: 'postgres://x', + OTEL_EXPORTER_OTLP_ENDPOINT: 'https://collector.example.test/', + }, new Error('boom')) + await pending(ctx) + + expect(fetchMock.mock.calls[0][0]).toBe('https://collector.example.test/v1/logs') + const payload = JSON.parse(fetchMock.mock.calls[0][1].body as string) + expect(payload.resourceLogs[0].scopeLogs[0].logRecords[0].attributes).toEqual( + expect.arrayContaining([ + { key: 'exception.type', value: { stringValue: 'Error' } }, + { key: 'exception.message', value: { stringValue: 'boom' } }, + ]), + ) + }) + + it('reports rejected exports without throwing into the request path', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 }))) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const ctx = context() + + captureError(ctx, { + DATABASE_URL: 'postgres://x', + OTEL_EXPORTER_OTLP_ENDPOINT: 'https://collector.example.test', + }, new Error('boom')) + await pending(ctx) + + expect(consoleError).toHaveBeenCalledWith(expect.stringContaining('otel-export-error')) + expect(consoleError).toHaveBeenCalledWith(expect.stringContaining('401')) + }) +}) From 8668b1296a011473011c767ca10fa6d73afda666 Mon Sep 17 00:00:00 2001 From: matheus1lva Date: Wed, 12 Aug 2026 20:06:59 -0300 Subject: [PATCH 3/4] fix(otel): drop orphan lock entry and harden export --- bun.lock | 2 -- src/edge-cache.ts | 18 +++++++++++++-- src/index.ts | 2 +- src/observability.ts | 8 +++++-- test/observability.test.ts | 46 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 69 insertions(+), 7 deletions(-) diff --git a/bun.lock b/bun.lock index ad2d438..03e58de 100644 --- a/bun.lock +++ b/bun.lock @@ -168,8 +168,6 @@ "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], - "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], - "@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="], "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], diff --git a/src/edge-cache.ts b/src/edge-cache.ts index 351a5d1..60400e2 100644 --- a/src/edge-cache.ts +++ b/src/edge-cache.ts @@ -1,3 +1,6 @@ +import { captureError } from './observability' +import type { Env } from './types' + // Cloudflare edge caching via the Cache API. // // A worker-generated Response with a Cache-Control header only drives the @@ -65,7 +68,12 @@ export async function readEdgeCache(request: Request): Promise { + captureError(ctx, env, error) + }), + ) } diff --git a/src/index.ts b/src/index.ts index e5f2b50..4ad60e0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -89,7 +89,7 @@ export default { const response = await routePriceRequest(request, env, pathname) if (request.method === 'GET') { - writeEdgeCache(ctx, request, response) + writeEdgeCache(ctx, env, request, response) } return response } catch (error) { diff --git a/src/observability.ts b/src/observability.ts index b2b95e7..6cc0af9 100644 --- a/src/observability.ts +++ b/src/observability.ts @@ -18,7 +18,11 @@ function parseHeaders(raw?: string): Record { if (!raw) return headers for (const pair of raw.split(',')) { const idx = pair.indexOf('=') - if (idx > 0) headers[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim() + if (idx > 0) { + headers[decodeURIComponent(pair.slice(0, idx).trim())] = decodeURIComponent( + pair.slice(idx + 1).trim(), + ) + } } return headers } @@ -36,7 +40,7 @@ function buildPayload(serviceName: string, err: Error): unknown { scope: { name: serviceName }, logRecords: [ { - timeUnixNano: String(Date.now() * 1_000_000), + timeUnixNano: String(BigInt(Date.now()) * 1_000_000n), severityNumber: SEVERITY_ERROR, severityText: 'ERROR', body: { stringValue: err.message }, diff --git a/test/observability.test.ts b/test/observability.test.ts index 79e5237..6df305c 100644 --- a/test/observability.test.ts +++ b/test/observability.test.ts @@ -56,6 +56,21 @@ describe('captureError', () => { }) it('reports rejected exports without throwing into the request path', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('socket hang up'))) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const ctx = context() + + captureError(ctx, { + DATABASE_URL: 'postgres://x', + OTEL_EXPORTER_OTLP_ENDPOINT: 'https://collector.example.test', + }, new Error('boom')) + await pending(ctx) + + expect(consoleError).toHaveBeenCalledWith(expect.stringContaining('otel-export-error')) + expect(consoleError).toHaveBeenCalledWith(expect.stringContaining('socket hang up')) + }) + + it('logs non-2xx export responses without throwing into the request path', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 }))) const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) const ctx = context() @@ -69,4 +84,35 @@ describe('captureError', () => { expect(consoleError).toHaveBeenCalledWith(expect.stringContaining('otel-export-error')) expect(consoleError).toHaveBeenCalledWith(expect.stringContaining('401')) }) + + it('is a no-op when no OTLP endpoint is configured', () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + const ctx = context() + + captureError(ctx, { DATABASE_URL: 'postgres://x' }, new Error('boom')) + + expect(ctx.waitUntil).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('percent-decodes OTLP header values and records an exact unix-nano timestamp', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })) + vi.stubGlobal('fetch', fetchMock) + vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_123) + const ctx = context() + + captureError(ctx, { + DATABASE_URL: 'postgres://x', + OTEL_EXPORTER_OTLP_ENDPOINT: 'https://collector.example.test', + OTEL_EXPORTER_OTLP_HEADERS: 'x-custom=foo%2Cbar', + }, new Error('boom')) + await pending(ctx) + + expect(fetchMock.mock.calls[0][1].headers['x-custom']).toBe('foo,bar') + const payload = JSON.parse(fetchMock.mock.calls[0][1].body as string) + expect(payload.resourceLogs[0].scopeLogs[0].logRecords[0].timeUnixNano).toBe( + String(1_700_000_000_123n * 1_000_000n), + ) + }) }) From badf38b7ad43418d30aa42ae24e223df154a4e08 Mon Sep 17 00:00:00 2001 From: matheus1lva <831308+matheus1lva@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:32:16 -0300 Subject: [PATCH 4/4] update env vars --- .github/workflows/deploy.yml | 12 +++++++++++- src/observability.ts | 10 +++++++++- test/observability.test.ts | 18 ++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ddbd925..c666148 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -38,6 +38,11 @@ jobs: RPC_URL_42161: op://webops-prod/yearn-price/RPC_URL_42161 RPC_URL_80094: op://webops-prod/yearn-price/RPC_URL_80094 RPC_URL_747474: op://webops-prod/yearn-price/RPC_URL_747474 + OTEL_EXPORTER_OTLP_ENDPOINT: op://webops-prod/yearn-price/OTEL_EXPORTER_OTLP_ENDPOINT + OTEL_EXPORTER_OTLP_HEADERS: op://webops-prod/yearn-price/OTEL_EXPORTER_OTLP_HEADERS + OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: op://webops-prod/yearn-price/OTEL_EXPORTER_OTLP_LOGS_ENDPOINT + OTEL_EXPORTER_OTLP_LOGS_HEADERS: op://webops-prod/yearn-price/OTEL_EXPORTER_OTLP_LOGS_HEADERS + OTEL_SERVICE_NAME: op://webops-prod/yearn-price/OTEL_SERVICE_NAME - name: Upload secrets to Cloudflare run: | jq -n '{ @@ -58,7 +63,12 @@ jobs: RPC_URL_8453: env.RPC_URL_8453, RPC_URL_42161: env.RPC_URL_42161, RPC_URL_80094: env.RPC_URL_80094, - RPC_URL_747474: env.RPC_URL_747474 + RPC_URL_747474: env.RPC_URL_747474, + OTEL_EXPORTER_OTLP_ENDPOINT: env.OTEL_EXPORTER_OTLP_ENDPOINT, + OTEL_EXPORTER_OTLP_HEADERS: env.OTEL_EXPORTER_OTLP_HEADERS, + OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT, + OTEL_EXPORTER_OTLP_LOGS_HEADERS: env.OTEL_EXPORTER_OTLP_LOGS_HEADERS, + OTEL_SERVICE_NAME: env.OTEL_SERVICE_NAME }' | bunx wrangler secret bulk - name: Run migrations run: bun run migrate diff --git a/src/observability.ts b/src/observability.ts index 6cc0af9..d774701 100644 --- a/src/observability.ts +++ b/src/observability.ts @@ -12,6 +12,14 @@ function attr(key: string, value: string): OtlpAttribute { return { key, value: { stringValue: value } } } +function decodeHeaderPart(value: string): string { + try { + return decodeURIComponent(value) + } catch { + return value + } +} + // OTEL_EXPORTER_OTLP_HEADERS format: "key1=value1,key2=value2". function parseHeaders(raw?: string): Record { const headers: Record = { 'content-type': 'application/json' } @@ -19,7 +27,7 @@ function parseHeaders(raw?: string): Record { for (const pair of raw.split(',')) { const idx = pair.indexOf('=') if (idx > 0) { - headers[decodeURIComponent(pair.slice(0, idx).trim())] = decodeURIComponent( + headers[decodeHeaderPart(pair.slice(0, idx).trim())] = decodeHeaderPart( pair.slice(idx + 1).trim(), ) } diff --git a/test/observability.test.ts b/test/observability.test.ts index 6df305c..85cc96b 100644 --- a/test/observability.test.ts +++ b/test/observability.test.ts @@ -115,4 +115,22 @@ describe('captureError', () => { String(1_700_000_000_123n * 1_000_000n), ) }) + + it('does not throw when OTLP headers contain a raw percent', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })) + vi.stubGlobal('fetch', fetchMock) + const ctx = context() + + expect(() => { + captureError(ctx, { + DATABASE_URL: 'postgres://x', + OTEL_EXPORTER_OTLP_ENDPOINT: 'https://collector.example.test', + OTEL_EXPORTER_OTLP_HEADERS: 'authorization=Api-Key 50%off', + }, new Error('boom')) + }).not.toThrow() + + await pending(ctx) + + expect(fetchMock.mock.calls[0][1].headers['authorization']).toBe('Api-Key 50%off') + }) })