From ffe012ee354cc8ba43f81eccc24c511b560c7e7a Mon Sep 17 00:00:00 2001 From: Ihor Kolobanov Date: Sun, 10 May 2026 09:37:36 +0200 Subject: [PATCH 1/3] test(server): pin Eden Treaty inference contract for createModule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a regression test asserting that createModule + Eden Treaty preserves per-route body/query schemas. Catches both failure modes observed pre-1.0.2 against @getvision/server@1.0.1: - "stomp" (every depth-1 POST resolves to one shared body shape) — caught via cross-route distinctness assertion. - intersection-merge (each route body becomes BodyA & BodyB & ...) — caught via per-route Equal<> probe. The contract is enforced at type level — `bun run typecheck:test` fails to compile if the prefix generic on createModule is ever removed. Verified by temporarily reverting and observing 8 type errors, then restoring. Test infra wiring: - new test/test:watch/typecheck:test scripts in @getvision/server - separate tsconfig.test.json so production build still excludes tests - @elysia/eden added as devDependency for the regression assertions No source changes — fix already shipped in 1.0.2 (commit 002b468). This is purely defensive scaffolding to keep the contract from regressing. --- bun.lock | 7 +- packages/server/package.json | 4 + .../__tests__/eden-treaty-inference.test.ts | 209 ++++++++++++++++++ packages/server/tsconfig.json | 2 +- packages/server/tsconfig.test.json | 9 + 5 files changed, 227 insertions(+), 4 deletions(-) create mode 100644 packages/server/src/__tests__/eden-treaty-inference.test.ts create mode 100644 packages/server/tsconfig.test.json diff --git a/bun.lock b/bun.lock index 10ecc2f..78b9f83 100644 --- a/bun.lock +++ b/bun.lock @@ -139,7 +139,7 @@ }, "examples/nextjs": { "name": "nextjs-vision-example", - "version": "0.0.2", + "version": "0.0.3", "dependencies": { "@elysia/eden": "^1.4.10", "@getvision/core": "workspace:*", @@ -159,7 +159,7 @@ }, "examples/vision": { "name": "vision-basic-example", - "version": "0.0.24", + "version": "0.0.25", "dependencies": { "@elysia/eden": "^1.4.10", "@getvision/server": "workspace:*", @@ -278,7 +278,7 @@ }, "packages/server": { "name": "@getvision/server", - "version": "1.0.1", + "version": "1.0.2", "dependencies": { "@getvision/core": "workspace:*", "bullmq": "^5.62.0", @@ -286,6 +286,7 @@ "zod": "^4.1.11", }, "devDependencies": { + "@elysia/eden": "1.4.10", "@repo/eslint-config": "workspace:*", "@repo/typescript-config": "workspace:*", "@types/bun": "^1.3.5", diff --git a/packages/server/package.json b/packages/server/package.json index 9ba790c..0a7e698 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -21,6 +21,9 @@ "dev": "tsc --watch", "build": "tsc --emitDeclarationOnly && bun build ./src/*.ts --outdir ./dist --target bun --format esm --external '*' --sourcemap=external", "typecheck": "tsc --noEmit", + "typecheck:test": "tsc -p tsconfig.test.json", + "test": "bun test", + "test:watch": "bun test --watch", "lint": "eslint . --max-warnings 0" }, "license": "MIT", @@ -34,6 +37,7 @@ "elysia": "catalog:" }, "devDependencies": { + "@elysia/eden": "1.4.10", "@repo/eslint-config": "workspace:*", "@repo/typescript-config": "workspace:*", "@types/node": "^20.14.9", diff --git a/packages/server/src/__tests__/eden-treaty-inference.test.ts b/packages/server/src/__tests__/eden-treaty-inference.test.ts new file mode 100644 index 0000000..5b2074d --- /dev/null +++ b/packages/server/src/__tests__/eden-treaty-inference.test.ts @@ -0,0 +1,209 @@ +/** + * Regression test pinning down the Eden Treaty inference contract for + * `createModule`-based Vision apps. + * + * Background — verified empirically against `@getvision/server@1.0.1` consumed + * by the kodjin-analytics codebase (Elysia 1.4.28, @elysia/eden 1.4.10, TS + * 5.9.3): + * + * The 1.0.1 `createModule` signature was non-generic: + * + * export function createModule(opts?: { prefix?: string }) { + * return new Elysia({ prefix: opts?.prefix }).decorate(...)... + * } + * + * Because `prefix` was widened to `string`, every module's `Elysia` static + * type was `Elysia` — Eden Treaty had no way to discriminate + * which module owned which path, and the body / query schemas of same-depth + * routes (e.g. all `.post('/')` calls) collapsed: `apiClient.queries.post`, + * `apiClient.dashboards.post`, `apiClient.folders.post` all resolved to a + * single shared body type instead of each route's own schema. + * + * The fix (commit 002b468) turned the prefix into a const generic: + * + * export function createModule( + * opts?: { prefix?: Prefix } + * ) { + * return new Elysia({ prefix: opts?.prefix }).decorate(...)... + * } + * + * The `.decorate()` chain was preserved — the original ADR theory that the + * function-body wrapper itself caused widening turned out to be incomplete: + * the widened `prefix: string` was the actual culprit. After the fix, prefix + * literals (`/foos`, `/bars`) propagate into the Elysia type and Eden Treaty + * disambiguates per-route schemas correctly. + * + * What this test enforces: + * 1. Each route's body type EQUALS its own schema (no widening). + * 2. Distinct same-depth routes have DISTINCT body types — protects against + * the exact "stomp" mode observed pre-fix where all routes ended up with + * one shared body shape. + * + * If a future change to `createModule` (or an upstream Elysia / Eden version + * bump) regresses either property, `bun run typecheck:test` fails at CI. + */ + +import { describe, expect, test } from 'bun:test' +import { treaty } from '@elysia/eden' +import Elysia from 'elysia' +import { z } from 'zod' + +import { createModule } from '../vision-app' + +// --------------------------------------------------------------------------- +// Type-level helpers (compile-time assertions) +// --------------------------------------------------------------------------- + +type Equal = + (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 ? true : false + +type Expect = T + +// `true` if `Body` equals `Expected` exactly. `false` if it widened (e.g. into +// an intersection with sibling-route bodies). +type BodyMatches = Equal + +// --------------------------------------------------------------------------- +// Fixture: two modules with DISTINCT body schemas at the same depth +// --------------------------------------------------------------------------- + +const FoosBody = z.object({ + fooName: z.string(), + fooCount: z.number(), +}) +type FoosBody = z.infer + +const BarsBody = z.object({ + barLabel: z.string(), + barEnabled: z.boolean(), +}) +type BarsBody = z.infer + +const foosModule = createModule({ prefix: '/foos' }).post( + '/', + ({ body }) => ({ created: body.fooName }), + { body: FoosBody } +) + +const barsModule = createModule({ prefix: '/bars' }).post( + '/', + ({ body }) => ({ created: body.barLabel }), + { body: BarsBody } +) + +// Compose at root (mimicking real consumer: createVision().use(...).use(...)) +// We use a bare `new Elysia()` here instead of `createVision` to keep the test +// dependency-free and focused on the createModule boundary. +const app = new Elysia().use(foosModule).use(barsModule) + +type AppType = typeof app + +// --------------------------------------------------------------------------- +// Type-level assertions (these MUST compile) +// --------------------------------------------------------------------------- + +const apiClient = treaty('http://localhost') + +// Extract the body input parameter of each .post() on the Eden client. +// +// Eden Treaty signature: `post(body, options?) => Promise<...>`. The first +// parameter is the body. If the createModule bug is present, this type +// becomes `FoosBody & BarsBody` for BOTH calls. +type FoosPostBody = Parameters[0] +type BarsPostBody = Parameters[0] + +// THE assertion. If these flip to `false`, Eden inference is broken. +// +// Note: Eden may decorate the body with a few internal fields (e.g. a +// `$` symbol or formdata helpers). We only care that the user-facing fields +// of the body match the route's schema — so we check key compatibility via +// assignability rather than strict equality. + +// 1. `FoosBody` must be assignable to `FoosPostBody` (route accepts its own body). +const _foosAcceptsOwnBody: FoosBody = {} as FoosPostBody +void _foosAcceptsOwnBody + +const _barsAcceptsOwnBody: BarsBody = {} as BarsPostBody +void _barsAcceptsOwnBody + +// 2. The CRITICAL one: `FoosBody` alone (without bar fields) must be a valid +// input for the foos endpoint. If types merged into intersection, you'd be +// forced to also supply `barLabel` + `barEnabled`, and this assignment would +// fail to compile. +const _foosBodyIsSufficient: FoosPostBody = { + fooName: 'x', + fooCount: 1, +} as FoosBody +void _foosBodyIsSufficient + +const _barsBodyIsSufficient: BarsPostBody = { + barLabel: 'y', + barEnabled: true, +} as BarsBody +void _barsBodyIsSufficient + +// 3. Negative check: `FoosBody` must NOT require `barLabel`. Construct a body +// with only foo fields — no `as` cast. If intersection-merge regression +// happens, TS will reject this literal because `barLabel` and `barEnabled` +// are missing. +const _foosLiteralWithoutBarFields: FoosPostBody = { + fooName: 'x', + fooCount: 1, +} +void _foosLiteralWithoutBarFields + +const _barsLiteralWithoutFooFields: BarsPostBody = { + barLabel: 'y', + barEnabled: true, +} +void _barsLiteralWithoutFooFields + +// 4. Positive equality probe (paranoia): under healthy inference, both checks +// below are `true`. They're declared as type aliases so any regression shows +// up as a "Type 'false' is not assignable to type 'true'" error on the +// `Expect<...>` line. +type _FoosBodyShape = Expect> +type _BarsBodyShape = Expect> + +// 5. Cross-route distinctness: this is the assertion that catches the +// pre-fix "stomp" mode (every depth-1 POST resolved to a single shared +// body shape). If the prefix generic is ever removed, FoosPostBody and +// BarsPostBody collapse to the same type and `Equal<...>` flips to `true`, +// so we assert it must be `false`. +type Not = T extends true ? false : true +type _DistinctBodies = Expect>> + +// Reference the aliases so unused-locals rules don't strip them. +type _Probes = [_FoosBodyShape, _BarsBodyShape, _DistinctBodies] +const _probes = null as unknown as _Probes +void _probes + +// --------------------------------------------------------------------------- +// Runtime smoke test +// --------------------------------------------------------------------------- + +describe('createModule + Eden Treaty inference', () => { + test('foos route accepts its own body shape at runtime', async () => { + const res = await app.handle( + new Request('http://localhost/foos', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ fooName: 'a', fooCount: 1 }), + }) + ) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ created: 'a' }) + }) + + test('bars route accepts its own body shape at runtime', async () => { + const res = await app.handle( + new Request('http://localhost/bars', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ barLabel: 'b', barEnabled: true }), + }) + ) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ created: 'b' }) + }) +}) diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index b52d4d5..b1aad83 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -5,5 +5,5 @@ "rootDir": "src" }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "src/__tests__", "**/*.test.ts", "**/__tests__/**"] } diff --git a/packages/server/tsconfig.test.json b/packages/server/tsconfig.test.json new file mode 100644 index 0000000..2f4a872 --- /dev/null +++ b/packages/server/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "@repo/typescript-config/base.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} From dfb629800e0c16f7ef2806cee607e747954586c3 Mon Sep 17 00:00:00 2001 From: Ihor Kolobanov Date: Wed, 27 May 2026 19:48:23 +0200 Subject: [PATCH 2/3] feat(core): add OTLP trace exporter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a generic exporter mechanism that fans completed traces out to OTLP/HTTP backends (BetterStack, Honeycomb, Grafana Tempo, an OTel Collector, …). The destination is purely endpoint + headers, so one exporter targets any OTLP-compatible backend. - core: `TraceExporter` interface + `VisionServerOptions.exporters`; fan-out in `completeTrace` (isolated per exporter) and flush on `stop()` - core: `OtlpTraceExporter` (OTLP/JSON over HTTP) with batching, random spec-compliant ids via Web Crypto, and Trace→OTLP transform (synthetic root SERVER span + nested INTERNAL spans, logs as span events) - server: forward `vision.exporters` into VisionCore; re-export OtlpTraceExporter - docs: README + docs-site config/section; mark OpenTelemetry export shipped --- apps/docs/content/docs/roadmap.mdx | 2 +- apps/docs/content/docs/server/index.mdx | 28 +++ packages/core/package.json | 5 + packages/core/src/core.ts | 21 ++- packages/core/src/exporters/index.ts | 2 + .../otlp/__tests__/transform.test.ts | 160 ++++++++++++++++++ packages/core/src/exporters/otlp/convert.ts | 45 +++++ packages/core/src/exporters/otlp/exporter.ts | 93 ++++++++++ packages/core/src/exporters/otlp/ids.ts | 27 +++ packages/core/src/exporters/otlp/index.ts | 4 + .../core/src/exporters/otlp/otlp-types.ts | 69 ++++++++ packages/core/src/exporters/otlp/transform.ts | 124 ++++++++++++++ packages/core/src/exporters/types.ts | 14 ++ packages/core/src/index.ts | 1 + packages/core/src/server/websocket.ts | 2 +- packages/core/src/types/index.ts | 8 + packages/server/README.md | 43 ++++- .../__tests__/exporter-integration.test.ts | 39 +++++ packages/server/src/index.ts | 2 +- packages/server/src/vision-app.ts | 9 +- 20 files changed, 692 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/exporters/index.ts create mode 100644 packages/core/src/exporters/otlp/__tests__/transform.test.ts create mode 100644 packages/core/src/exporters/otlp/convert.ts create mode 100644 packages/core/src/exporters/otlp/exporter.ts create mode 100644 packages/core/src/exporters/otlp/ids.ts create mode 100644 packages/core/src/exporters/otlp/index.ts create mode 100644 packages/core/src/exporters/otlp/otlp-types.ts create mode 100644 packages/core/src/exporters/otlp/transform.ts create mode 100644 packages/core/src/exporters/types.ts create mode 100644 packages/server/src/__tests__/exporter-integration.test.ts diff --git a/apps/docs/content/docs/roadmap.mdx b/apps/docs/content/docs/roadmap.mdx index 47cd61c..8a4b4a3 100644 --- a/apps/docs/content/docs/roadmap.mdx +++ b/apps/docs/content/docs/roadmap.mdx @@ -125,7 +125,7 @@ Vision is actively developed with a focus on universal protocol support. Here's - Distributed tracing support **Export & Integration** -- OpenTelemetry export +- OpenTelemetry export ✅ (OTLP/HTTP — `@getvision/server` `vision.exporters`) - Jaeger/Zipkin compatibility - Custom export formats - Webhook integrations diff --git a/apps/docs/content/docs/server/index.mdx b/apps/docs/content/docs/server/index.mdx index 7353134..4a5ee73 100644 --- a/apps/docs/content/docs/server/index.mdx +++ b/apps/docs/content/docs/server/index.mdx @@ -252,6 +252,7 @@ const app = createVision({ maxTraces: 1000, maxLogs: 10000, logging: true, + // exporters: [...] // forward traces to OTLP backends — see below }, pubsub: { devMode: true, // in-memory queue — no Redis required @@ -260,6 +261,33 @@ const app = createVision({ }) ``` +## Exporting Traces (OTLP) + +Forward every completed trace to any OpenTelemetry-compatible backend — BetterStack, Honeycomb, Grafana Tempo, Datadog, an OTel Collector — by adding an `OtlpTraceExporter` to `vision.exporters`. Export runs alongside the local Dashboard, so traces appear in both. + +```typescript +import { createVision, OtlpTraceExporter } from '@getvision/server' + +createVision({ + service: { name: 'my-api' }, + vision: { + exporters: [ + new OtlpTraceExporter({ + endpoint: 'https:///v1/traces', // OTLP/HTTP traces endpoint + headers: { Authorization: 'Bearer ' }, // backend auth + serviceName: 'my-api', + }), + ], + }, +}) +``` + +The exporter speaks OTLP/JSON over HTTP, so the destination is purely a matter of `endpoint` + `headers` — switching backends is a config change, not a code change. Traces are buffered and flushed in batches, and a failing exporter is isolated so it never affects request handling. + +Each HTTP request becomes a synthetic root span (`SERVER`) with your `c.span(...)` calls as nested `INTERNAL` spans, and the trace's logs attached as span events. + +For a custom sink, implement the `TraceExporter` interface (`export(trace)` plus an optional `shutdown()`) and add it to `vision.exporters` — `OtlpTraceExporter` is just the built-in implementation. + ## Next Steps - [Modules](/docs/server/modules) — build and compose modules diff --git a/packages/core/package.json b/packages/core/package.json index 45c1647..b3c9189 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -25,6 +25,11 @@ "import": "./dist/tracing/index.js", "default": "./dist/tracing/index.js" }, + "./exporters": { + "types": "./dist/exporters/index.d.ts", + "import": "./dist/exporters/index.js", + "default": "./dist/exporters/index.js" + }, "./types": { "types": "./dist/types/index.d.ts", "import": "./dist/types/index.js", diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index 1d6fb72..ab93b20 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -12,6 +12,7 @@ import type { DashboardEvent, LogLevel, LogEntry, + TraceExporter, } from './types/index' /** @@ -25,6 +26,7 @@ export class VisionCore { private consoleInterceptor?: ConsoleInterceptor private routes: RouteMetadata[] = [] private services: ServiceGroup[] = [] + private exporters: TraceExporter[] private startPromise?: Promise private appStatus: AppStatus = { name: 'Unknown', @@ -38,6 +40,7 @@ export class VisionCore { this.traceStore = new TraceStore(options.maxTraces) this.tracer = new Tracer() this.logStore = new LogStore(options.maxLogs) + this.exporters = options.exporters ?? [] // Optional console intercept if (options.captureConsole !== false) { @@ -225,6 +228,21 @@ export class VisionCore { if (trace) { this.broadcast({ type: 'trace.new', data: trace }) + this.exportTrace(trace) + } + } + + /** + * Fan a completed trace out to all configured exporters. Each exporter is + * isolated so a throwing/misbehaving sink can't break request handling. + */ + private exportTrace(trace: Trace): void { + for (const exporter of this.exporters) { + try { + exporter.export(trace) + } catch { + // Exporters own their own error reporting; never let one disrupt others. + } } } @@ -366,9 +384,10 @@ export class VisionCore { await this.stop() } - /** Stop the Dashboard port. Idempotent. */ + /** Stop the Dashboard port and flush exporters. Idempotent. */ async stop(): Promise { this.startPromise = undefined + await Promise.allSettled(this.exporters.map((exporter) => exporter.shutdown?.())) await this.server.stop() } } diff --git a/packages/core/src/exporters/index.ts b/packages/core/src/exporters/index.ts new file mode 100644 index 0000000..922048b --- /dev/null +++ b/packages/core/src/exporters/index.ts @@ -0,0 +1,2 @@ +export type { TraceExporter } from './types' +export * from './otlp' diff --git a/packages/core/src/exporters/otlp/__tests__/transform.test.ts b/packages/core/src/exporters/otlp/__tests__/transform.test.ts new file mode 100644 index 0000000..6a596aa --- /dev/null +++ b/packages/core/src/exporters/otlp/__tests__/transform.test.ts @@ -0,0 +1,160 @@ +import { describe, test, expect } from 'bun:test' +import type { Trace } from '../../../types' +import { traceToOtlpSpans, tracesToOtlpPayload } from '../transform' +import { newSpanId, newTraceId } from '../ids' +import { msToUnixNano, toAnyValue } from '../convert' + +function makeTrace(overrides: Partial = {}): Trace { + return { + id: 'trace-1', + timestamp: 1_700_000_000_000, + method: 'GET', + path: '/users', + statusCode: 200, + duration: 50, + spans: [], + ...overrides, + } +} + +describe('id generation', () => { + test('trace id is 32 hex chars, span id is 16', () => { + expect(newTraceId()).toMatch(/^[0-9a-f]{32}$/) + expect(newSpanId()).toMatch(/^[0-9a-f]{16}$/) + }) + + test('ids are random (two calls differ)', () => { + expect(newTraceId()).not.toBe(newTraceId()) + expect(newSpanId()).not.toBe(newSpanId()) + }) +}) + +describe('value conversion', () => { + test('msToUnixNano keeps int64 precision as a string', () => { + expect(msToUnixNano(1_700_000_000_000)).toBe('1700000000000000000') + }) + + test('toAnyValue maps JS primitives to OTLP AnyValue', () => { + expect(toAnyValue('x')).toEqual({ stringValue: 'x' }) + expect(toAnyValue(true)).toEqual({ boolValue: true }) + expect(toAnyValue(42)).toEqual({ intValue: '42' }) + expect(toAnyValue(1.5)).toEqual({ doubleValue: 1.5 }) + expect(toAnyValue(null)).toBeUndefined() + expect(toAnyValue(undefined)).toBeUndefined() + }) +}) + +describe('traceToOtlpSpans', () => { + test('synthesizes a SERVER root span from the trace', () => { + const [root] = traceToOtlpSpans(makeTrace()) + expect(root.kind).toBe(2) + expect(root.name).toBe('GET /users') + expect(root.traceId).toMatch(/^[0-9a-f]{32}$/) + expect(root.spanId).toMatch(/^[0-9a-f]{16}$/) + expect(root.endTimeUnixNano).toBe(msToUnixNano(1_700_000_000_050)) + expect(root.attributes).toContainEqual({ + key: 'http.request.method', + value: { stringValue: 'GET' }, + }) + expect(root.attributes).toContainEqual({ + key: 'http.response.status_code', + value: { intValue: '200' }, + }) + }) + + test('child spans without parentId reparent onto the synthetic root', () => { + const trace = makeTrace({ + spans: [ + { + id: 'span-1', + traceId: 'trace-1', + name: 'db.query', + startTime: 1_700_000_000_010, + endTime: 1_700_000_000_030, + duration: 20, + }, + ], + }) + const [root, child] = traceToOtlpSpans(trace) + expect(child.kind).toBe(1) + expect(child.traceId).toBe(root.traceId) + expect(child.parentSpanId).toBe(root.spanId) + }) + + test('explicit parentId resolves to the parent span id', () => { + const trace = makeTrace({ + spans: [ + { + id: 'span-1', + traceId: 'trace-1', + name: 'parent', + startTime: 1_700_000_000_010, + endTime: 1_700_000_000_025, + }, + { + id: 'span-2', + traceId: 'trace-1', + parentId: 'span-1', + name: 'nested', + startTime: 1_700_000_000_015, + endTime: 1_700_000_000_020, + }, + ], + }) + const [, parent, child] = traceToOtlpSpans(trace) + expect(child.parentSpanId).toBe(parent.spanId) + }) + + test('5xx status marks the root span as error, 4xx does not', () => { + expect(traceToOtlpSpans(makeTrace({ statusCode: 503 }))[0].status).toEqual({ code: 2 }) + expect(traceToOtlpSpans(makeTrace({ statusCode: 404 }))[0].status).toBeUndefined() + }) + + test('error attribute on a child span produces an error status', () => { + const trace = makeTrace({ + spans: [ + { + id: 'span-1', + traceId: 'trace-1', + name: 'boom', + startTime: 1_700_000_000_010, + endTime: 1_700_000_000_011, + attributes: { error: true, 'error.message': 'kaboom' }, + }, + ], + }) + const [, child] = traceToOtlpSpans(trace) + expect(child.status).toEqual({ code: 2, message: 'kaboom' }) + }) + + test('trace logs become events on the root span', () => { + const trace = makeTrace({ + logs: [ + { + id: 'log-1', + timestamp: 1_700_000_000_005, + level: 'error', + message: 'something happened', + }, + ], + }) + const [root] = traceToOtlpSpans(trace) + expect(root.events?.[0]).toMatchObject({ + name: 'log', + timeUnixNano: msToUnixNano(1_700_000_000_005), + }) + }) +}) + +describe('tracesToOtlpPayload', () => { + test('wraps spans under one resource/scope with the given resource attributes', () => { + const resource = [{ key: 'service.name', value: { stringValue: 'api' } }] + const payload = tracesToOtlpPayload([makeTrace(), makeTrace({ id: 'trace-2' })], resource) + + expect(payload.resourceSpans).toHaveLength(1) + expect(payload.resourceSpans[0].resource?.attributes).toEqual(resource) + expect(payload.resourceSpans[0].scopeSpans[0].scope?.name).toBe('@getvision/core') + // 2 traces × 1 synthetic root span each. + expect(payload.resourceSpans[0].scopeSpans[0].spans).toHaveLength(2) + }) +}) diff --git a/packages/core/src/exporters/otlp/convert.ts b/packages/core/src/exporters/otlp/convert.ts new file mode 100644 index 0000000..c667079 --- /dev/null +++ b/packages/core/src/exporters/otlp/convert.ts @@ -0,0 +1,45 @@ +import type { OtlpAnyValue, OtlpKeyValue } from './otlp-types' + +/** Milliseconds (epoch) → int64 nanoseconds as a decimal string (no float precision loss). */ +export function msToUnixNano(ms: number): string { + return (BigInt(Math.round(ms)) * 1_000_000n).toString() +} + +/** Convert an arbitrary JS value into an OTLP AnyValue, or `undefined` to drop it. */ +export function toAnyValue(value: unknown): OtlpAnyValue | undefined { + if (value === null || value === undefined) return undefined + + switch (typeof value) { + case 'string': + return { stringValue: value } + case 'boolean': + return { boolValue: value } + case 'number': + return Number.isInteger(value) + ? { intValue: String(value) } + : { doubleValue: value } + case 'bigint': + return { intValue: value.toString() } + case 'object': { + if (Array.isArray(value)) { + const values = value + .map(toAnyValue) + .filter((v): v is OtlpAnyValue => v !== undefined) + return { arrayValue: { values } } + } + return { kvlistValue: { values: toAttributes(value as Record) } } + } + default: + return { stringValue: String(value) } + } +} + +/** Convert a flat record into OTLP key/value attributes, dropping nullish entries. */ +export function toAttributes(record: Record): OtlpKeyValue[] { + const attributes: OtlpKeyValue[] = [] + for (const [key, raw] of Object.entries(record)) { + const value = toAnyValue(raw) + if (value !== undefined) attributes.push({ key, value }) + } + return attributes +} diff --git a/packages/core/src/exporters/otlp/exporter.ts b/packages/core/src/exporters/otlp/exporter.ts new file mode 100644 index 0000000..beda9ea --- /dev/null +++ b/packages/core/src/exporters/otlp/exporter.ts @@ -0,0 +1,93 @@ +import type { Trace } from '../../types' +import type { TraceExporter } from '../types' +import { toAttributes } from './convert' +import { tracesToOtlpPayload } from './transform' +import type { OtlpKeyValue } from './otlp-types' + +export interface OtlpExporterOptions { + /** OTLP/HTTP traces endpoint, e.g. `https:///v1/traces`. */ + endpoint: string + /** Extra headers, typically auth (e.g. `{ Authorization: 'Bearer ' }`). */ + headers?: Record + /** `service.name` resource attribute. Defaults to `'unknown_service'`. */ + serviceName?: string + /** Additional resource attributes (e.g. `deployment.environment`). */ + resourceAttributes?: Record + /** Flush when this many traces are buffered. Default 100. */ + maxQueueSize?: number + /** Background flush interval in ms. Default 5000. */ + flushIntervalMs?: number + /** Per-request timeout in ms. Default 10000. */ + timeoutMs?: number + /** Notified on transport/HTTP failures. Defaults to a no-op (silent). */ + onError?: (error: unknown) => void +} + +/** + * Buffers completed traces and ships them to any OTLP/HTTP-compatible backend + * (BetterStack, Honeycomb, Grafana, an OTel Collector, …) as OTLP/JSON. The + * destination is purely a matter of `endpoint` + `headers`. + */ +export class OtlpTraceExporter implements TraceExporter { + private readonly endpoint: string + private readonly headers: Record + private readonly resource: OtlpKeyValue[] + private readonly maxQueueSize: number + private readonly timeoutMs: number + private readonly onError: (error: unknown) => void + private queue: Trace[] = [] + private timer?: ReturnType + + constructor(options: OtlpExporterOptions) { + this.endpoint = options.endpoint + this.headers = { 'content-type': 'application/json', ...options.headers } + this.resource = toAttributes({ + 'service.name': options.serviceName ?? 'unknown_service', + ...options.resourceAttributes, + }) + this.maxQueueSize = options.maxQueueSize ?? 100 + this.timeoutMs = options.timeoutMs ?? 10_000 + this.onError = options.onError ?? (() => {}) + + this.timer = setInterval(() => void this.flush(), options.flushIntervalMs ?? 5_000) + // Don't keep the process alive just for the flush timer. + this.timer.unref?.() + } + + export(trace: Trace): void { + this.queue.push(trace) + if (this.queue.length >= this.maxQueueSize) void this.flush() + } + + async flush(): Promise { + if (this.queue.length === 0) return + + const batch = this.queue + this.queue = [] + const payload = tracesToOtlpPayload(batch, this.resource) + + try { + const response = await fetch(this.endpoint, { + method: 'POST', + headers: this.headers, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(this.timeoutMs), + }) + if (!response.ok) { + this.onError( + new Error(`OTLP export failed: ${response.status} ${response.statusText}`) + ) + } + } catch (error) { + this.onError(error) + } + } + + async shutdown(): Promise { + if (this.timer) { + clearInterval(this.timer) + this.timer = undefined + } + await this.flush() + } +} diff --git a/packages/core/src/exporters/otlp/ids.ts b/packages/core/src/exporters/otlp/ids.ts new file mode 100644 index 0000000..c1992ae --- /dev/null +++ b/packages/core/src/exporters/otlp/ids.ts @@ -0,0 +1,27 @@ +/** + * OTLP requires a 16-byte trace id and 8-byte span id. Following the OpenTelemetry + * SDKs, we mint them as cryptographically-random ids rather than deriving them from + * Vision's nanoid — the transform keeps a per-trace `visionId → otlpId` map so + * parent/child references stay consistent within a batch. + * + * Randomness comes from the standard Web Crypto API (`globalThis.crypto`), which is + * available in Bun, Node 18+, and edge runtimes — no runtime-specific import. + */ + +function randomHex(bytes: number): string { + const buffer = new Uint8Array(bytes) + globalThis.crypto.getRandomValues(buffer) + let hex = '' + for (const byte of buffer) hex += byte.toString(16).padStart(2, '0') + return hex +} + +/** New random 16-byte (32 hex char) OTLP trace id. */ +export function newTraceId(): string { + return randomHex(16) +} + +/** New random 8-byte (16 hex char) OTLP span id. */ +export function newSpanId(): string { + return randomHex(8) +} diff --git a/packages/core/src/exporters/otlp/index.ts b/packages/core/src/exporters/otlp/index.ts new file mode 100644 index 0000000..f2401de --- /dev/null +++ b/packages/core/src/exporters/otlp/index.ts @@ -0,0 +1,4 @@ +export { OtlpTraceExporter } from './exporter' +export type { OtlpExporterOptions } from './exporter' +export { traceToOtlpSpans, tracesToOtlpPayload } from './transform' +export type * from './otlp-types' diff --git a/packages/core/src/exporters/otlp/otlp-types.ts b/packages/core/src/exporters/otlp/otlp-types.ts new file mode 100644 index 0000000..540c710 --- /dev/null +++ b/packages/core/src/exporters/otlp/otlp-types.ts @@ -0,0 +1,69 @@ +/** + * Minimal TypeScript shapes for the OTLP/JSON trace wire format. + * + * Notes on the JSON encoding (differs from the protobuf JSON mapping): + * - `traceId` / `spanId` are lowercase hex strings, NOT base64. + * - `*UnixNano` fields are int64 encoded as decimal strings. + * + * @see https://opentelemetry.io/docs/specs/otlp/#otlphttp + */ + +export interface OtlpAnyValue { + stringValue?: string + boolValue?: boolean + intValue?: string + doubleValue?: number + arrayValue?: { values: OtlpAnyValue[] } + kvlistValue?: { values: OtlpKeyValue[] } +} + +export interface OtlpKeyValue { + key: string + value: OtlpAnyValue +} + +export interface OtlpEvent { + timeUnixNano: string + name: string + attributes?: OtlpKeyValue[] +} + +/** STATUS_CODE_UNSET = 0, OK = 1, ERROR = 2 */ +export interface OtlpStatus { + code: 0 | 1 | 2 + message?: string +} + +/** SpanKind enum values per the OTLP spec. */ +export const SpanKind = { + INTERNAL: 1, + SERVER: 2, + CLIENT: 3, +} as const + +export interface OtlpSpan { + traceId: string + spanId: string + parentSpanId?: string + name: string + kind: number + startTimeUnixNano: string + endTimeUnixNano: string + attributes?: OtlpKeyValue[] + events?: OtlpEvent[] + status?: OtlpStatus +} + +export interface OtlpScopeSpans { + scope?: { name: string; version?: string } + spans: OtlpSpan[] +} + +export interface OtlpResourceSpans { + resource?: { attributes: OtlpKeyValue[] } + scopeSpans: OtlpScopeSpans[] +} + +export interface OtlpTracePayload { + resourceSpans: OtlpResourceSpans[] +} diff --git a/packages/core/src/exporters/otlp/transform.ts b/packages/core/src/exporters/otlp/transform.ts new file mode 100644 index 0000000..ee388b9 --- /dev/null +++ b/packages/core/src/exporters/otlp/transform.ts @@ -0,0 +1,124 @@ +import type { LogEntry, Span, SpanEvent, Trace } from '../../types' +import { msToUnixNano, toAttributes } from './convert' +import { newSpanId, newTraceId } from './ids' +import { + SpanKind, + type OtlpEvent, + type OtlpKeyValue, + type OtlpSpan, + type OtlpStatus, + type OtlpTracePayload, +} from './otlp-types' + +const SCOPE = { name: '@getvision/core' } + +/** HTTP server-span status: only 5xx counts as an error (4xx is a client problem). */ +function httpStatus(statusCode?: number): OtlpStatus | undefined { + if (statusCode !== undefined && statusCode >= 500) return { code: 2 } + return undefined +} + +/** Child-span status derived from the `error` attribute set by `createSpanHelper`. */ +function spanStatus(span: Span): OtlpStatus | undefined { + if (span.attributes?.error === true) { + const message = span.attributes['error.message'] + return { code: 2, message: message !== undefined ? String(message) : undefined } + } + return undefined +} + +function spanEventToOtlp(event: SpanEvent): OtlpEvent { + return { + timeUnixNano: msToUnixNano(event.timestamp), + name: event.name, + attributes: event.attributes ? toAttributes(event.attributes) : undefined, + } +} + +function logToOtlpEvent(log: LogEntry): OtlpEvent { + return { + timeUnixNano: msToUnixNano(log.timestamp), + name: 'log', + attributes: toAttributes({ + 'log.severity': log.level, + 'log.message': log.message, + ...log.context, + }), + } +} + +function rootSpan(trace: Trace, traceId: string, rootSpanId: string): OtlpSpan { + const attributes: OtlpKeyValue[] = [ + { key: 'http.request.method', value: { stringValue: trace.method } }, + { key: 'url.path', value: { stringValue: trace.path } }, + ] + if (trace.statusCode !== undefined) { + attributes.push({ + key: 'http.response.status_code', + value: { intValue: String(trace.statusCode) }, + }) + } + if (trace.metadata) attributes.push(...toAttributes(trace.metadata)) + + return { + traceId, + spanId: rootSpanId, + name: `${trace.method} ${trace.path}`, + kind: SpanKind.SERVER, + startTimeUnixNano: msToUnixNano(trace.timestamp), + endTimeUnixNano: msToUnixNano(trace.timestamp + (trace.duration ?? 0)), + attributes, + events: trace.logs?.map(logToOtlpEvent), + status: httpStatus(trace.statusCode), + } +} + +function childSpan( + span: Span, + traceId: string, + rootSpanId: string, + spanIds: Map +): OtlpSpan { + const parentSpanId = span.parentId ? spanIds.get(span.parentId) ?? rootSpanId : rootSpanId + return { + traceId, + spanId: spanIds.get(span.id) ?? newSpanId(), + parentSpanId, + name: span.name, + kind: SpanKind.INTERNAL, + startTimeUnixNano: msToUnixNano(span.startTime), + endTimeUnixNano: msToUnixNano(span.endTime ?? span.startTime + (span.duration ?? 0)), + attributes: span.attributes ? toAttributes(span.attributes) : undefined, + events: span.events?.map(spanEventToOtlp), + status: spanStatus(span), + } +} + +/** Map a single Vision trace to its OTLP spans (one synthetic root + children). */ +export function traceToOtlpSpans(trace: Trace): OtlpSpan[] { + const traceId = newTraceId() + const rootSpanId = newSpanId() + // Assign each child a stable id up front so parentId references resolve. + const spanIds = new Map() + for (const span of trace.spans) spanIds.set(span.id, newSpanId()) + + return [ + rootSpan(trace, traceId, rootSpanId), + ...trace.spans.map((span) => childSpan(span, traceId, rootSpanId, spanIds)), + ] +} + +/** Build a complete OTLP/JSON trace payload for a batch of traces under one resource. */ +export function tracesToOtlpPayload( + traces: Trace[], + resourceAttributes: OtlpKeyValue[] +): OtlpTracePayload { + return { + resourceSpans: [ + { + resource: { attributes: resourceAttributes }, + scopeSpans: [{ scope: SCOPE, spans: traces.flatMap(traceToOtlpSpans) }], + }, + ], + } +} diff --git a/packages/core/src/exporters/types.ts b/packages/core/src/exporters/types.ts new file mode 100644 index 0000000..a9652ef --- /dev/null +++ b/packages/core/src/exporters/types.ts @@ -0,0 +1,14 @@ +import type { Trace } from '../types' + +/** + * A sink for completed traces. Implementations receive each trace as soon as it + * finishes (right after the Dashboard broadcast) and are responsible for their + * own buffering/transport. Errors thrown from `export` are swallowed by the core + * so a failing exporter never affects request handling. + */ +export interface TraceExporter { + /** Called once per completed trace. Should not block; buffer and flush async. */ + export(trace: Trace): void + /** Flush any buffered traces and release resources. Invoked on `core.stop()`. */ + shutdown?(): Promise +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 521a794..ac0368e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,6 +2,7 @@ export { VisionCore } from './core' export { VisionWebSocketServer } from './server/index' export * from './tracing/index' export * from './validation/index' +export * from './exporters/index' export { generateZodTemplate, generateValibotTemplate, generateTemplate } from './utils/template-generator/index' export { autoDetectPackageInfo, diff --git a/packages/core/src/server/websocket.ts b/packages/core/src/server/websocket.ts index 39de4ed..5b773f5 100644 --- a/packages/core/src/server/websocket.ts +++ b/packages/core/src/server/websocket.ts @@ -56,7 +56,7 @@ export class VisionWebSocketServer { private wss: WebSocketServer private clients = new Set() private rpc: JsonRpcHandler - private options: Required> & { + private options: Required> & { apiUrl?: string } /** diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 121f55b..be8402a 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -3,6 +3,7 @@ */ import { LogEntry } from "./logs"; import type { ValidationSchema } from '../validation'; +import type { TraceExporter } from '../exporters/types'; // JSON-RPC 2.0 Protocol export interface JsonRpcRequest { @@ -169,6 +170,12 @@ export interface VisionServerOptions { * Elysia integration uses this so `app.ready()` owns the lifecycle). */ autoStart?: boolean + /** + * Sinks that receive every completed trace (e.g. an OTLP exporter forwarding + * to BetterStack/Honeycomb/Grafana). Failures are isolated and never affect + * request handling. + */ + exporters?: TraceExporter[] } // Adapter interface @@ -201,3 +208,4 @@ export interface AdapterResponse { export * from './logs' export * from './adapter-options' +export type { TraceExporter } from '../exporters/types' diff --git a/packages/server/README.md b/packages/server/README.md index da340a9..fd68d7b 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -211,7 +211,8 @@ const app = new Vision({ port: 9500, // Dashboard port maxTraces: 1000, // Max traces to store maxLogs: 10000, // Max logs to store - logging: true // Console logging + logging: true, // Console logging + // exporters: [...] // Forward traces to OTLP backends — see "OTLP Trace Export" }, pubsub: { devMode: true, // In-memory BullMQ for local dev @@ -246,6 +247,46 @@ const app = new Vision({ }) ``` +### OTLP Trace Export + +Forward every completed trace to any OpenTelemetry-compatible backend — BetterStack, Honeycomb, Grafana Tempo, Datadog, an OTel Collector, and so on — by adding an `OtlpTraceExporter` to `vision.exporters`. Export runs alongside the local Dashboard, so traces show up in both. + +```typescript +import { createVision, OtlpTraceExporter } from '@getvision/server' + +createVision({ + service: { name: 'my-api' }, + vision: { + exporters: [ + new OtlpTraceExporter({ + endpoint: 'https:///v1/traces', // OTLP/HTTP traces endpoint + headers: { Authorization: 'Bearer ' }, // backend auth + serviceName: 'my-api', + }), + ], + }, +}) +``` + +The exporter speaks OTLP/JSON over HTTP — the destination is purely a matter of `endpoint` + `headers`, so the same code targets any OTLP backend (switching from BetterStack to Honeycomb is a URL + header change, not a code change). Traces are buffered and flushed in batches; a failing exporter is isolated and never affects request handling. + +Vision models each HTTP request as a synthetic root span (`SERVER`) with your `c.span(...)` calls as nested `INTERNAL` spans, and attaches the trace's logs as span events. + +**`OtlpTraceExporter` options** + +| Option | Default | Description | +| --- | --- | --- | +| `endpoint` | _(required)_ | OTLP/HTTP traces endpoint, e.g. `https:///v1/traces` | +| `headers` | `{}` | Extra headers, typically auth (e.g. `{ Authorization: 'Bearer ' }`) | +| `serviceName` | `'unknown_service'` | `service.name` resource attribute | +| `resourceAttributes` | `{}` | Additional resource attributes (e.g. `{ 'deployment.environment': 'prod' }`) | +| `maxQueueSize` | `100` | Flush once this many traces are buffered | +| `flushIntervalMs` | `5000` | Background flush interval (ms) | +| `timeoutMs` | `10000` | Per-request timeout (ms) | +| `onError` | no-op | Called on transport/HTTP failures (defaults to silent) | + +> Need a custom sink (custom format, webhook, second dashboard)? Implement the `TraceExporter` interface (`export(trace)` + optional `shutdown()`) and add it to `vision.exporters` — `OtlpTraceExporter` is just the built-in one. + ### `app.service(name)` Create a new service builder. diff --git a/packages/server/src/__tests__/exporter-integration.test.ts b/packages/server/src/__tests__/exporter-integration.test.ts new file mode 100644 index 0000000..470b85f --- /dev/null +++ b/packages/server/src/__tests__/exporter-integration.test.ts @@ -0,0 +1,39 @@ +/** + * Pins the public API contract for trace exporters in `@getvision/server`: + * + * 1. `OtlpTraceExporter` is reachable as a value from the package entry and + * satisfies the `TraceExporter` contract. + * 2. `VisionConfig.vision.exporters` accepts a `TraceExporter[]` (the field + * that `buildVisionCore` forwards into `VisionCore`). + * + * The runtime fan-out (a completed trace reaching each exporter) is covered by + * the unit tests in `@getvision/core`. + */ + +import { describe, expect, test } from 'bun:test' +import type { Trace, TraceExporter } from '@getvision/core' + +import { OtlpTraceExporter, type VisionConfig } from '../index' + +describe('exporter wiring', () => { + test('OtlpTraceExporter is exported and implements TraceExporter', () => { + const exporter: TraceExporter = new OtlpTraceExporter({ + endpoint: 'http://localhost/v1/traces', + serviceName: 'test', + }) + expect(typeof exporter.export).toBe('function') + expect(typeof exporter.shutdown).toBe('function') + }) + + test('VisionConfig.vision.exporters accepts a TraceExporter[]', () => { + const capturing: TraceExporter = { + export(_trace: Trace) {}, + } + const config = { + service: { name: 'cfg' }, + vision: { enabled: false, exporters: [capturing] }, + } satisfies VisionConfig + + expect(config.vision.exporters).toHaveLength(1) + }) +}) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 7ff284d..4152365 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -81,5 +81,5 @@ export { t } from 'elysia' export type { Static, TSchema } from 'elysia' // Re-export from core for convenience -export { VisionCore } from '@getvision/core' +export { VisionCore, OtlpTraceExporter } from '@getvision/core' export type * from '@getvision/core' diff --git a/packages/server/src/vision-app.ts b/packages/server/src/vision-app.ts index d9c2116..f2644cc 100644 --- a/packages/server/src/vision-app.ts +++ b/packages/server/src/vision-app.ts @@ -1,6 +1,6 @@ import { Elysia } from 'elysia' import { VisionCore, runInTraceContext, generateTemplate } from '@getvision/core' -import type { LogLevel, RouteMetadata } from '@getvision/core' +import type { LogLevel, RouteMetadata, TraceExporter } from '@getvision/core' import { AsyncLocalStorage } from 'async_hooks' import { existsSync } from 'fs' import { spawn, spawnSync, type ChildProcess } from 'child_process' @@ -178,6 +178,12 @@ export interface VisionConfig { maxLogs?: number logging?: boolean apiUrl?: string + /** + * Sinks that receive every completed trace — e.g. an `OtlpTraceExporter` + * forwarding to BetterStack/Honeycomb/Grafana or an OTel Collector. Failures + * are isolated and never affect request handling. + */ + exporters?: TraceExporter[] } pubsub?: { redis?: { @@ -1497,6 +1503,7 @@ function buildVisionCore(config: VisionConfig): VisionCore | null { maxTraces: config.vision?.maxTraces ?? 1000, maxLogs: config.vision?.maxLogs ?? 10000, apiUrl: config.vision?.apiUrl, + exporters: config.vision?.exporters, // Defer port binding until `ready()` — this is the whole point of // the refactor: no network I/O during module evaluation. autoStart: false, From 2bef505df3a9fbbdd6cabd92139a19bf34c2a2dd Mon Sep 17 00:00:00 2001 From: Ihor Kolobanov Date: Wed, 27 May 2026 20:52:06 +0200 Subject: [PATCH 3/3] fix(core): bound OTLP exporter queue and retry failed batches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on the new OTLP exporter: - `maxQueueSize` (default 2048) is now a true hard cap — excess traces are dropped and reported via `onError` so the queue can't grow without bound when the backend is slow or down. Flush threshold is split out as `maxExportBatchSize` (default 512), matching the OTel SDK shape. - Failed batches are re-buffered for the next flush (respecting the cap) instead of silently dropped on a single 5xx/network error. - A `flushing` guard serializes concurrent flush() calls so the interval + threshold triggers can't stack N in-flight POSTs against the same endpoint. - Headers merge case-insensitively, so a user-supplied `Content-Type` actually overrides the default rather than producing both keys. - `onError` defaults to `console.warn` (was a silent no-op) and `VisionCore.exportTrace` warns on thrown exporters, so silent failures don't make local debugging impossible. - Public surface narrowed: the exporters barrel exports only `TraceExporter`, `OtlpTraceExporter`, and `OtlpExporterOptions`. - Tightened `transform.ts`: comment on the `error === true` contract with `createSpanHelper`; dropped the dead `?? newSpanId()` fallback. Adds behavioral tests covering overflow drop, eager flush, re-buffer on 5xx, serialized flush, and case-insensitive header merge. --- packages/core/src/core.ts | 6 +- packages/core/src/exporters/index.ts | 3 +- .../exporters/otlp/__tests__/exporter.test.ts | 207 ++++++++++++++++++ packages/core/src/exporters/otlp/exporter.ts | 101 ++++++++- packages/core/src/exporters/otlp/index.ts | 2 - packages/core/src/exporters/otlp/transform.ts | 12 +- packages/server/README.md | 9 +- 7 files changed, 322 insertions(+), 18 deletions(-) create mode 100644 packages/core/src/exporters/otlp/__tests__/exporter.test.ts diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index ab93b20..b5ab62e 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -240,8 +240,10 @@ export class VisionCore { for (const exporter of this.exporters) { try { exporter.export(trace) - } catch { - // Exporters own their own error reporting; never let one disrupt others. + } catch (error) { + // Surface a warning so silent failures don't make "where are my traces?" + // impossible to debug locally, but never let a throw disrupt the others. + console.warn('[VisionCore] Trace exporter threw:', error) } } } diff --git a/packages/core/src/exporters/index.ts b/packages/core/src/exporters/index.ts index 922048b..8c55fcd 100644 --- a/packages/core/src/exporters/index.ts +++ b/packages/core/src/exporters/index.ts @@ -1,2 +1,3 @@ export type { TraceExporter } from './types' -export * from './otlp' +export { OtlpTraceExporter } from './otlp/exporter' +export type { OtlpExporterOptions } from './otlp/exporter' diff --git a/packages/core/src/exporters/otlp/__tests__/exporter.test.ts b/packages/core/src/exporters/otlp/__tests__/exporter.test.ts new file mode 100644 index 0000000..6e977e5 --- /dev/null +++ b/packages/core/src/exporters/otlp/__tests__/exporter.test.ts @@ -0,0 +1,207 @@ +/** + * Behavioral tests for `OtlpTraceExporter` covering the bits the + * pure-transform tests don't reach: queue bounds, re-buffer on failure, + * serialized flush, and header merge. + * + * We stub `globalThis.fetch` per test so nothing hits the network. + */ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test' +import type { Trace } from '../../../types' +import { OtlpTraceExporter } from '../exporter' + +function makeTrace(id: string): Trace { + return { + id, + timestamp: 1_700_000_000_000, + method: 'GET', + path: '/x', + statusCode: 200, + duration: 1, + spans: [], + } +} + +type FetchCall = { url: string; init: RequestInit } + +interface FetchStub { + calls: FetchCall[] + restore: () => void +} + +function stubFetch(handler: (call: FetchCall) => Promise | Response): FetchStub { + const original = globalThis.fetch + const calls: FetchCall[] = [] + globalThis.fetch = (async (input: any, init: any) => { + const call = { url: String(input), init: init ?? {} } + calls.push(call) + return handler(call) + }) as typeof fetch + return { + calls, + restore: () => { + globalThis.fetch = original + }, + } +} + +let stub: FetchStub | undefined + +beforeEach(() => { + stub = undefined +}) + +afterEach(() => { + stub?.restore() +}) + +describe('OtlpTraceExporter — queue bounds', () => { + test('drops new traces once maxQueueSize is reached and surfaces via onError', async () => { + stub = stubFetch(() => new Response('', { status: 200 })) + const errors: unknown[] = [] + const exporter = new OtlpTraceExporter({ + endpoint: 'http://test/v1/traces', + maxQueueSize: 2, + maxExportBatchSize: 99, // suppress threshold-triggered flush + onError: (e) => errors.push(e), + }) + + exporter.export(makeTrace('a')) + exporter.export(makeTrace('b')) + exporter.export(makeTrace('c')) // should drop + exporter.export(makeTrace('d')) // should drop + + expect(errors).toHaveLength(2) + expect(String(errors[0])).toContain('queue full (2)') + expect(String(errors[0])).toContain('trace c') + + await exporter.shutdown() + expect(stub!.calls).toHaveLength(1) + const payload = JSON.parse(String(stub!.calls[0].init.body)) + expect(payload.resourceSpans[0].scopeSpans[0].spans).toHaveLength(2) + }) + + test('eagerly flushes once maxExportBatchSize is reached', async () => { + let resolveFetch!: (r: Response) => void + stub = stubFetch( + () => new Promise((r) => { resolveFetch = r }) + ) + const exporter = new OtlpTraceExporter({ + endpoint: 'http://test/v1/traces', + maxExportBatchSize: 2, + onError: () => {}, + }) + + exporter.export(makeTrace('a')) + expect(stub!.calls).toHaveLength(0) + exporter.export(makeTrace('b')) + // Threshold reached — flush kicked off synchronously. + await Promise.resolve() + expect(stub!.calls).toHaveLength(1) + + resolveFetch(new Response('', { status: 200 })) + await exporter.shutdown() + }) +}) + +describe('OtlpTraceExporter — failure handling', () => { + test('re-buffers a failed batch for the next flush', async () => { + let attempt = 0 + stub = stubFetch(() => { + attempt++ + return attempt === 1 + ? new Response('boom', { status: 500, statusText: 'Server Error' }) + : new Response('', { status: 200 }) + }) + const errors: unknown[] = [] + const exporter = new OtlpTraceExporter({ + endpoint: 'http://test/v1/traces', + maxExportBatchSize: 99, + onError: (e) => errors.push(e), + }) + + exporter.export(makeTrace('a')) + await exporter.flush() // 500 — should re-buffer + + expect(errors).toHaveLength(1) + expect(String(errors[0])).toContain('500') + + await exporter.flush() // retry — should succeed + expect(stub!.calls).toHaveLength(2) + + // Nothing left to ship on shutdown. + await exporter.shutdown() + expect(stub!.calls).toHaveLength(2) + }) + + test('re-buffer respects maxQueueSize and drops the oldest overflow', async () => { + stub = stubFetch(() => new Response('', { status: 500 })) + const errors: unknown[] = [] + const exporter = new OtlpTraceExporter({ + endpoint: 'http://test/v1/traces', + maxQueueSize: 3, + maxExportBatchSize: 99, + onError: (e) => errors.push(e), + }) + + exporter.export(makeTrace('a')) + exporter.export(makeTrace('b')) + exporter.export(makeTrace('c')) + await exporter.flush() // 3 traces fail, queue empty, re-buffer all 3 + // queue is now [a, b, c] + + exporter.export(makeTrace('d')) + exporter.export(makeTrace('e')) + exporter.export(makeTrace('f')) // dropped — queue at cap (3) before push + // queue is still [a, b, c]; d, e were dropped on push + + // Errors so far: one HTTP failure + the drop of d/e/f on push. + const overflowDrops = errors.filter((e) => String(e).includes('dropping trace')) + expect(overflowDrops.length).toBeGreaterThanOrEqual(1) + }) +}) + +describe('OtlpTraceExporter — concurrency', () => { + test('serializes concurrent flushes into a single in-flight request', async () => { + let resolveFetch!: (r: Response) => void + stub = stubFetch( + () => new Promise((r) => { resolveFetch = r }) + ) + const exporter = new OtlpTraceExporter({ + endpoint: 'http://test/v1/traces', + maxExportBatchSize: 99, + onError: () => {}, + }) + + exporter.export(makeTrace('a')) + const p1 = exporter.flush() + const p2 = exporter.flush() + const p3 = exporter.flush() + + // Only one fetch in flight — the second/third flush() returned the same promise. + expect(stub!.calls).toHaveLength(1) + + resolveFetch(new Response('', { status: 200 })) + await Promise.all([p1, p2, p3]) + expect(stub!.calls).toHaveLength(1) + }) +}) + +describe('OtlpTraceExporter — header merge', () => { + test('user headers override defaults case-insensitively', async () => { + stub = stubFetch(() => new Response('', { status: 200 })) + const exporter = new OtlpTraceExporter({ + endpoint: 'http://test/v1/traces', + headers: { 'Content-Type': 'application/x-protobuf', Authorization: 'Bearer t' }, + onError: () => {}, + }) + + exporter.export(makeTrace('a')) + await exporter.flush() + + const headers = stub!.calls[0].init.headers as Record + // No duplicate `Content-Type` keys — the user override wins. + expect(headers['content-type']).toBe('application/x-protobuf') + expect(headers['Content-Type']).toBeUndefined() + expect(headers['authorization']).toBe('Bearer t') + }) +}) diff --git a/packages/core/src/exporters/otlp/exporter.ts b/packages/core/src/exporters/otlp/exporter.ts index beda9ea..c7fcbeb 100644 --- a/packages/core/src/exporters/otlp/exporter.ts +++ b/packages/core/src/exporters/otlp/exporter.ts @@ -13,13 +13,25 @@ export interface OtlpExporterOptions { serviceName?: string /** Additional resource attributes (e.g. `deployment.environment`). */ resourceAttributes?: Record - /** Flush when this many traces are buffered. Default 100. */ + /** + * Hard cap on buffered traces. Once reached, new traces are dropped and + * reported via `onError`. Default 2048 (matches the OTel SDK convention). + */ maxQueueSize?: number + /** + * Flush eagerly once this many traces are buffered, rather than waiting for + * `flushIntervalMs`. Default 512. + */ + maxExportBatchSize?: number /** Background flush interval in ms. Default 5000. */ flushIntervalMs?: number /** Per-request timeout in ms. Default 10000. */ timeoutMs?: number - /** Notified on transport/HTTP failures. Defaults to a no-op (silent). */ + /** + * Notified on transport/HTTP failures and queue overflow. Defaults to + * `console.warn` so problems aren't silently dropped during local dev — pass + * a no-op to silence. + */ onError?: (error: unknown) => void } @@ -27,27 +39,34 @@ export interface OtlpExporterOptions { * Buffers completed traces and ships them to any OTLP/HTTP-compatible backend * (BetterStack, Honeycomb, Grafana, an OTel Collector, …) as OTLP/JSON. The * destination is purely a matter of `endpoint` + `headers`. + * + * Failed batches are re-buffered for the next flush so a transient backend + * outage doesn't silently lose traces; `maxQueueSize` bounds memory growth if + * the backend stays down. */ export class OtlpTraceExporter implements TraceExporter { private readonly endpoint: string private readonly headers: Record private readonly resource: OtlpKeyValue[] private readonly maxQueueSize: number + private readonly maxExportBatchSize: number private readonly timeoutMs: number private readonly onError: (error: unknown) => void private queue: Trace[] = [] private timer?: ReturnType + private flushing?: Promise constructor(options: OtlpExporterOptions) { this.endpoint = options.endpoint - this.headers = { 'content-type': 'application/json', ...options.headers } + this.headers = mergeHeaders(options.headers) this.resource = toAttributes({ 'service.name': options.serviceName ?? 'unknown_service', ...options.resourceAttributes, }) - this.maxQueueSize = options.maxQueueSize ?? 100 + this.maxQueueSize = options.maxQueueSize ?? 2048 + this.maxExportBatchSize = options.maxExportBatchSize ?? 512 this.timeoutMs = options.timeoutMs ?? 10_000 - this.onError = options.onError ?? (() => {}) + this.onError = options.onError ?? defaultOnError this.timer = setInterval(() => void this.flush(), options.flushIntervalMs ?? 5_000) // Don't keep the process alive just for the flush timer. @@ -55,17 +74,35 @@ export class OtlpTraceExporter implements TraceExporter { } export(trace: Trace): void { + if (this.queue.length >= this.maxQueueSize) { + this.onError( + new Error( + `OTLP exporter queue full (${this.maxQueueSize}); dropping trace ${trace.id}` + ) + ) + return + } this.queue.push(trace) - if (this.queue.length >= this.maxQueueSize) void this.flush() + if (this.queue.length >= this.maxExportBatchSize) void this.flush() } async flush(): Promise { + // Serialize concurrent flushes — both the interval and the threshold + // trigger can race, and stacking N in-flight POSTs against the same + // endpoint is wasteful (and amplifies the blast radius on a slow backend). + if (this.flushing) return this.flushing if (this.queue.length === 0) return const batch = this.queue this.queue = [] - const payload = tracesToOtlpPayload(batch, this.resource) + this.flushing = this.send(batch).finally(() => { + this.flushing = undefined + }) + return this.flushing + } + private async send(batch: Trace[]): Promise { + const payload = tracesToOtlpPayload(batch, this.resource) try { const response = await fetch(this.endpoint, { method: 'POST', @@ -77,17 +114,67 @@ export class OtlpTraceExporter implements TraceExporter { this.onError( new Error(`OTLP export failed: ${response.status} ${response.statusText}`) ) + this.rebuffer(batch) } } catch (error) { this.onError(error) + this.rebuffer(batch) } } + /** + * Return a failed batch to the front of the queue so the next flush retries + * it. Respects `maxQueueSize` so a persistently-down backend can't grow + * memory without bound; on overflow we keep the newest entries (older traces + * are less actionable once they're already stale). + */ + private rebuffer(batch: Trace[]): void { + const room = this.maxQueueSize - this.queue.length + if (room <= 0) { + this.onError( + new Error( + `OTLP exporter queue full (${this.maxQueueSize}); dropping ${batch.length} retried traces` + ) + ) + return + } + if (batch.length > room) { + const dropped = batch.length - room + this.onError( + new Error( + `OTLP exporter queue full (${this.maxQueueSize}); dropping ${dropped} of ${batch.length} retried traces` + ) + ) + this.queue.unshift(...batch.slice(dropped)) + return + } + this.queue.unshift(...batch) + } + async shutdown(): Promise { if (this.timer) { clearInterval(this.timer) this.timer = undefined } + // Single best-effort attempt — if the backend rejects the batch on the way + // out, those traces are lost. Looping would risk spinning forever against a + // persistently-failing endpoint at the worst possible time. await this.flush() } } + +function mergeHeaders(extra?: Record): Record { + // Lowercase keys so a user-supplied `Content-Type` actually overrides our + // default — without normalization the spread would produce both `content-type` + // and `Content-Type` in the same object. + const headers: Record = { 'content-type': 'application/json' } + if (!extra) return headers + for (const [key, value] of Object.entries(extra)) { + headers[key.toLowerCase()] = value + } + return headers +} + +function defaultOnError(error: unknown): void { + console.warn('[OtlpTraceExporter]', error) +} diff --git a/packages/core/src/exporters/otlp/index.ts b/packages/core/src/exporters/otlp/index.ts index f2401de..7b33cf7 100644 --- a/packages/core/src/exporters/otlp/index.ts +++ b/packages/core/src/exporters/otlp/index.ts @@ -1,4 +1,2 @@ export { OtlpTraceExporter } from './exporter' export type { OtlpExporterOptions } from './exporter' -export { traceToOtlpSpans, tracesToOtlpPayload } from './transform' -export type * from './otlp-types' diff --git a/packages/core/src/exporters/otlp/transform.ts b/packages/core/src/exporters/otlp/transform.ts index ee388b9..fff93d7 100644 --- a/packages/core/src/exporters/otlp/transform.ts +++ b/packages/core/src/exporters/otlp/transform.ts @@ -18,7 +18,12 @@ function httpStatus(statusCode?: number): OtlpStatus | undefined { return undefined } -/** Child-span status derived from the `error` attribute set by `createSpanHelper`. */ +/** + * Child-span status derived from the `error` attribute set by `createSpanHelper`. + * The strict `=== true` check is intentional: `error` is an internal contract + * with `createSpanHelper` (which always writes the boolean `true`), so we don't + * widen to `Boolean(...)` and risk treating arbitrary user-set values as errors. + */ function spanStatus(span: Span): OtlpStatus | undefined { if (span.attributes?.error === true) { const message = span.attributes['error.message'] @@ -80,9 +85,12 @@ function childSpan( spanIds: Map ): OtlpSpan { const parentSpanId = span.parentId ? spanIds.get(span.parentId) ?? rootSpanId : rootSpanId + // `spanIds` is pre-populated for every span in the trace by `traceToOtlpSpans`, + // so this lookup is guaranteed to hit. + const spanId = spanIds.get(span.id)! return { traceId, - spanId: spanIds.get(span.id) ?? newSpanId(), + spanId, parentSpanId, name: span.name, kind: SpanKind.INTERNAL, diff --git a/packages/server/README.md b/packages/server/README.md index fd68d7b..2456290 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -268,7 +268,7 @@ createVision({ }) ``` -The exporter speaks OTLP/JSON over HTTP — the destination is purely a matter of `endpoint` + `headers`, so the same code targets any OTLP backend (switching from BetterStack to Honeycomb is a URL + header change, not a code change). Traces are buffered and flushed in batches; a failing exporter is isolated and never affects request handling. +The exporter speaks OTLP/JSON over HTTP — the destination is purely a matter of `endpoint` + `headers`, so the same code targets any OTLP backend (switching from BetterStack to Honeycomb is a URL + header change, not a code change). Traces are buffered and flushed in batches; failed batches are re-buffered for the next flush so a transient backend outage doesn't silently lose traces, and a failing exporter is isolated so it never affects request handling. Vision models each HTTP request as a synthetic root span (`SERVER`) with your `c.span(...)` calls as nested `INTERNAL` spans, and attaches the trace's logs as span events. @@ -277,13 +277,14 @@ Vision models each HTTP request as a synthetic root span (`SERVER`) with your `c | Option | Default | Description | | --- | --- | --- | | `endpoint` | _(required)_ | OTLP/HTTP traces endpoint, e.g. `https:///v1/traces` | -| `headers` | `{}` | Extra headers, typically auth (e.g. `{ Authorization: 'Bearer ' }`) | +| `headers` | `{}` | Extra headers, typically auth (e.g. `{ Authorization: 'Bearer ' }`). Keys are matched case-insensitively when merging with built-in defaults. | | `serviceName` | `'unknown_service'` | `service.name` resource attribute | | `resourceAttributes` | `{}` | Additional resource attributes (e.g. `{ 'deployment.environment': 'prod' }`) | -| `maxQueueSize` | `100` | Flush once this many traces are buffered | +| `maxQueueSize` | `2048` | Hard cap on buffered traces. Excess traces (and re-buffered batches that won't fit) are dropped and surfaced via `onError`. | +| `maxExportBatchSize` | `512` | Flush eagerly once this many traces are buffered, rather than waiting for `flushIntervalMs`. | | `flushIntervalMs` | `5000` | Background flush interval (ms) | | `timeoutMs` | `10000` | Per-request timeout (ms) | -| `onError` | no-op | Called on transport/HTTP failures (defaults to silent) | +| `onError` | `console.warn` | Called on transport/HTTP failures and queue overflow. Pass a no-op to silence. | > Need a custom sink (custom format, webhook, second dashboard)? Implement the `TraceExporter` interface (`export(trace)` + optional `shutdown()`) and add it to `vision.exporters` — `OtlpTraceExporter` is just the built-in one.