Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/docs/content/docs/roadmap.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions apps/docs/content/docs/server/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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://<host>/v1/traces', // OTLP/HTTP traces endpoint
headers: { Authorization: 'Bearer <token>' }, // 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
Expand Down
7 changes: 4 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
23 changes: 22 additions & 1 deletion packages/core/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
DashboardEvent,
LogLevel,
LogEntry,
TraceExporter,
} from './types/index'

/**
Expand All @@ -25,6 +26,7 @@ export class VisionCore {
private consoleInterceptor?: ConsoleInterceptor
private routes: RouteMetadata[] = []
private services: ServiceGroup[] = []
private exporters: TraceExporter[]
private startPromise?: Promise<void>
private appStatus: AppStatus = {
name: 'Unknown',
Expand All @@ -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) {
Expand Down Expand Up @@ -225,6 +228,23 @@ 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 (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)
}
}
}

Expand Down Expand Up @@ -366,9 +386,10 @@ export class VisionCore {
await this.stop()
}

/** Stop the Dashboard port. Idempotent. */
/** Stop the Dashboard port and flush exporters. Idempotent. */
async stop(): Promise<void> {
this.startPromise = undefined
await Promise.allSettled(this.exporters.map((exporter) => exporter.shutdown?.()))
await this.server.stop()
}
}
3 changes: 3 additions & 0 deletions packages/core/src/exporters/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type { TraceExporter } from './types'
export { OtlpTraceExporter } from './otlp/exporter'
export type { OtlpExporterOptions } from './otlp/exporter'
207 changes: 207 additions & 0 deletions packages/core/src/exporters/otlp/__tests__/exporter.test.ts
Original file line number Diff line number Diff line change
@@ -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> | 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<Response>((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<Response>((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<string, string>
// 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')
})
})
Loading
Loading