From a15a12f733824cfad9242c58e27b85acd45d942c Mon Sep 17 00:00:00 2001 From: Manvendra Date: Sat, 27 Jun 2026 17:57:12 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=202=20=E2=80=94=20request=20traci?= =?UTF-8?q?ng=20&=20persistence=20(OpenTelemetry=20+=20SQLite)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements ROADMAP Phase 2 (PRP C7): every request is an OpenTelemetry span — provider, model, status, latency, token usage — persisted to a queryable store and exposed via an admin-gated read API. - OTel SDK: a span per request; a custom SpanExporter persists spans to the trace store; optional OTLP export when OTEL_EXPORTER_OTLP_ENDPOINT is set. Tracer resolved lazily so it binds to the registered provider. - Trace store: TraceStore interface with SQLite (better-sqlite3) and in-memory backends, behind a createTraceStore factory. - GET /traces + /traces/:id, gated by SENTINEL_ADMIN_KEY (distinct from client keys). Filters: model/provider/status/stream/since/until/limit. - Privacy: metadata only — no prompt/response bodies; keys SHA-256 hashed. - 69 tests, 93% branch coverage; pnpm verify + CI green. Live-verified in-process against a real SQLite store + OTel bootstrap. --- .env.example | 9 + README.md | 16 +- SECURITY_REVIEW_LOG.md | 5 +- package.json | 3 +- packages/gateway/package.json | 11 + packages/gateway/src/auth.ts | 16 + packages/gateway/src/config.test.ts | 18 + packages/gateway/src/config.ts | 9 + packages/gateway/src/index.ts | 2 + packages/gateway/src/main.ts | 22 +- packages/gateway/src/routes.traces.ts | 45 ++ packages/gateway/src/server.test.ts | 164 ++++++- packages/gateway/src/server.ts | 153 ++++-- .../gateway/src/telemetry/exporter.test.ts | 105 +++++ packages/gateway/src/telemetry/exporter.ts | 36 ++ packages/gateway/src/telemetry/otel.ts | 39 ++ .../gateway/src/telemetry/store.memory.ts | 37 ++ .../gateway/src/telemetry/store.sqlite.ts | 146 ++++++ packages/gateway/src/telemetry/store.test.ts | 111 +++++ packages/gateway/src/telemetry/store.ts | 16 + packages/gateway/src/telemetry/trace.ts | 75 +++ pnpm-lock.yaml | 445 ++++++++++++++++++ tsconfig.base.json | 1 + vitest.config.ts | 8 +- 24 files changed, 1442 insertions(+), 50 deletions(-) create mode 100644 packages/gateway/src/routes.traces.ts create mode 100644 packages/gateway/src/telemetry/exporter.test.ts create mode 100644 packages/gateway/src/telemetry/exporter.ts create mode 100644 packages/gateway/src/telemetry/otel.ts create mode 100644 packages/gateway/src/telemetry/store.memory.ts create mode 100644 packages/gateway/src/telemetry/store.sqlite.ts create mode 100644 packages/gateway/src/telemetry/store.test.ts create mode 100644 packages/gateway/src/telemetry/store.ts create mode 100644 packages/gateway/src/telemetry/trace.ts diff --git a/.env.example b/.env.example index f28657f..df343a3 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,15 @@ SENTINEL_API_KEYS=dev-local-key PORT=8080 # Path to the provider config file (defaults to ./sentinel.config.json) SENTINEL_CONFIG=./sentinel.config.json +# Admin key that gates the GET /traces observability API (leave blank to disable it). +SENTINEL_ADMIN_KEY= + +# ── Tracing & persistence ──────────────────────────────────────────── +# Where request traces are stored: "sqlite" (default) or "memory". +TRACE_DB=sqlite +TRACE_DB_PATH=./traces.db +# Optional: also export OpenTelemetry spans to an OTLP/HTTP collector (e.g. Jaeger). +# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces # ── Local models via Ollama (keyless — no quota, no rate limit) ─────── # OpenAI-compatible base URL of YOUR Ollama (used for local completions, diff --git a/README.md b/README.md index 62d5cf7..b942c80 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A self-hostable **verifying LLM gateway** — a drop-in, OpenAI-compatible proxy that **routes** (cheapest capable model + fallback), **semantically caches**, and **verifies** (deterministic guardrails inline + a local Ollama judge) every LLM call, with full OpenTelemetry tracing. Unlike after-the-fact observability tools, it can flag or block a bad response _before it returns_. -> 🚧 **Early development.** Product spec in [`PRP_SPEC.md`](./PRP_SPEC.md), phased build in [`ROADMAP.md`](./ROADMAP.md), contributor/agent guidance in [`CLAUDE.md`](./CLAUDE.md). Currently at **Phase 1 — the pass-through proxy** (routing, caching, and verification land in later phases). +> 🚧 **Early development.** Product spec in [`PRP_SPEC.md`](./PRP_SPEC.md), phased build in [`ROADMAP.md`](./ROADMAP.md), contributor/agent guidance in [`CLAUDE.md`](./CLAUDE.md). Currently at **Phase 2 — tracing & persistence** (routing, caching, and verification land in later phases). ## What works today (Phase 1) @@ -83,6 +83,20 @@ Because almost every provider speaks the OpenAI API, **bringing your own key is Sentinel never hardcodes a machine or a model. Set `OLLAMA_BASE_URL` in `.env` to point at _your_ Ollama (default `http://localhost:11434/v1`), list the models you've pulled in the `models` map, and you're set. The local **judge** that verifies outputs in a later phase works the same way — it reads `JUDGE_MODEL` from your environment, so every clone uses its own local model, with no API key and no quota. +## Observability + +Every request is traced with OpenTelemetry — provider, model, status, latency, and token usage — and persisted to a queryable store (SQLite by default; set `TRACE_DB=memory` for an ephemeral one). Spans also export to any OTLP collector (Jaeger, etc.) when `OTEL_EXPORTER_OTLP_ENDPOINT` is set. + +Read recent traces through the admin-gated API (set `SENTINEL_ADMIN_KEY` to enable it): + +```bash +curl http://localhost:8080/traces \ + -H "authorization: Bearer $SENTINEL_ADMIN_KEY" +# filters: ?model=gpt-4o-mini&status=200&stream=true&since=&limit=50 +``` + +Traces are **metadata only** — no prompt or response bodies are stored, and API keys are recorded as a SHA-256 hash, never in the clear. + ## Development ```bash diff --git a/SECURITY_REVIEW_LOG.md b/SECURITY_REVIEW_LOG.md index 92487ec..62450cb 100644 --- a/SECURITY_REVIEW_LOG.md +++ b/SECURITY_REVIEW_LOG.md @@ -29,7 +29,7 @@ Each item: assume an attacker is actively trying it. Tick only when a test prove - [x] Every request authenticated by a Sentinel API key; invalid/missing ⇒ 401. - [ ] Cache entries and traces namespaced per key; cross-tenant read is impossible (tested). -- [ ] Admin/config endpoints separated from proxy traffic and authorized distinctly. +- [x] Admin/config endpoints separated from proxy traffic and authorized distinctly. ### 4. SSRF / outbound calls @@ -43,7 +43,7 @@ Each item: assume an attacker is actively trying it. Tick only when a test prove ### 6. Log / trace data hygiene -- [ ] PII/prompt redaction policy applied before persistence (configurable; on by default for sensitive fields). +- [x] Traces are metadata-only — prompt/response bodies are never persisted; API keys stored as a SHA-256 hash, never raw. - [ ] Stored trace content is escaped on render in the dashboard (no stored XSS). ### 7. Availability / DoS @@ -68,5 +68,6 @@ Each item: assume an attacker is actively trying it. Tick only when a test prove | ------ | ---------- | ---------- | ------------------------------ | ---------------------------------------------------------------------------- | -------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | — | 2026-06-27 | — | Initial context docs (no code) | n/a | n/a | n/a | Baseline. | | SR-001 | 2026-06-27 | 1, 3, 4 | Phase 1 pass-through proxy | Unauthenticated access; key/secret leakage; SSRF via request-controlled URLs | high | mitigated | Bearer Sentinel-key auth required — 401 on missing/invalid (tested). Provider keys read from env via `apiKeyEnv`, never hard-coded, never returned to clients. Provider base URLs come only from the config file, never request input. `authorization`/`x-api-key` redacted in request logs via pino `redact` (configured; an explicit log-assertion test is still TODO — box 1.2 left unticked). | +| SR-002 | 2026-06-27 | 3, 6 | Phase 2 tracing & persistence | Unauthorized trace access; secrets/PII in stored traces | high | mitigated | `GET /traces` gated by a separate `SENTINEL_ADMIN_KEY` — 401 without it (tested), distinct from client keys. Traces are metadata-only (model, provider, tokens, latency, status) — no prompt/response bodies persisted. API keys stored as a SHA-256 hash, never raw. Per-key trace scoping deferred (admin-only for now). | > Add a row per high-risk change. Status ∈ {open, mitigated, accepted}. Severity ∈ {low, med, high, critical}. diff --git a/package.json b/package.json index dc3077e..ded0887 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,8 @@ }, "pnpm": { "onlyBuiltDependencies": [ - "esbuild" + "esbuild", + "better-sqlite3" ] } } diff --git a/packages/gateway/package.json b/packages/gateway/package.json index 26700b9..1c2759c 100644 --- a/packages/gateway/package.json +++ b/packages/gateway/package.json @@ -5,8 +5,19 @@ "type": "module", "description": "Sentinel gateway — the verifying LLM proxy core. (Scaffold; built out from ROADMAP Phase 1.)", "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/core": "^2.8.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.219.0", + "@opentelemetry/resources": "^2.8.0", + "@opentelemetry/sdk-trace-base": "^2.8.0", + "@opentelemetry/sdk-trace-node": "^2.8.0", + "@opentelemetry/semantic-conventions": "^1.41.1", + "better-sqlite3": "^11.10.0", "dotenv": "^17.4.2", "fastify": "^5.8.5", "zod": "^3.25.76" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13" } } diff --git a/packages/gateway/src/auth.ts b/packages/gateway/src/auth.ts index 04aa792..a0ca81e 100644 --- a/packages/gateway/src/auth.ts +++ b/packages/gateway/src/auth.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { AuthError } from './errors.js'; /** Extracts the bearer token from an Authorization header, or null if absent/malformed. */ @@ -22,3 +23,18 @@ export function createAuthHook(apiKeys: ReadonlySet) { } }; } + +/** Builds a preHandler that requires the configured admin key (for trace/admin endpoints). */ +export function createAdminAuthHook(adminKey: string | undefined) { + return async function adminAuthHook(request: RequestWithAuth): Promise { + const token = extractBearerToken(request.headers.authorization); + if (adminKey === undefined || adminKey.length === 0 || token !== adminKey) { + throw new AuthError('Admin access required'); + } + }; +} + +/** SHA-256 hex digest of an API key, so raw keys are never persisted in traces. */ +export function hashApiKey(key: string): string { + return createHash('sha256').update(key).digest('hex'); +} diff --git a/packages/gateway/src/config.test.ts b/packages/gateway/src/config.test.ts index a814382..0687137 100644 --- a/packages/gateway/src/config.test.ts +++ b/packages/gateway/src/config.test.ts @@ -28,6 +28,24 @@ describe('loadServerEnv', () => { it('throws when PORT is not a positive integer', () => { expect(() => loadServerEnv({ SENTINEL_API_KEYS: 'k', PORT: 'abc' })).toThrow(ConfigError); }); + + it('reads admin key and trace DB settings', () => { + const env = loadServerEnv({ + SENTINEL_API_KEYS: 'k', + SENTINEL_ADMIN_KEY: 'admin', + TRACE_DB: 'memory', + TRACE_DB_PATH: '/tmp/t.db', + }); + expect(env.adminKey).toBe('admin'); + expect(env.traceDb).toBe('memory'); + expect(env.traceDbPath).toBe('/tmp/t.db'); + }); + + it('defaults trace DB to sqlite and admin key to undefined', () => { + const env = loadServerEnv({ SENTINEL_API_KEYS: 'k' }); + expect(env.traceDb).toBe('sqlite'); + expect(env.adminKey).toBeUndefined(); + }); }); const validConfig = JSON.stringify({ diff --git a/packages/gateway/src/config.ts b/packages/gateway/src/config.ts index 9144e71..0d2da3b 100644 --- a/packages/gateway/src/config.ts +++ b/packages/gateway/src/config.ts @@ -8,12 +8,18 @@ export interface ServerEnv { port: number; apiKeys: ReadonlySet; configPath: string; + adminKey: string | undefined; + traceDb: 'sqlite' | 'memory'; + traceDbPath: string; } const serverEnvSchema = z.object({ PORT: z.coerce.number().int().positive().default(8080), SENTINEL_API_KEYS: z.string().default(''), SENTINEL_CONFIG: z.string().default('./sentinel.config.json'), + SENTINEL_ADMIN_KEY: z.string().optional(), + TRACE_DB: z.enum(['sqlite', 'memory']).default('sqlite'), + TRACE_DB_PATH: z.string().default('./traces.db'), }); /** Reads and validates the process environment Sentinel needs to run. */ @@ -32,6 +38,9 @@ export function loadServerEnv(env: NodeJS.ProcessEnv): ServerEnv { port: parsed.data.PORT, apiKeys: new Set(apiKeys), configPath: parsed.data.SENTINEL_CONFIG, + adminKey: parsed.data.SENTINEL_ADMIN_KEY, + traceDb: parsed.data.TRACE_DB, + traceDbPath: parsed.data.TRACE_DB_PATH, }; } diff --git a/packages/gateway/src/index.ts b/packages/gateway/src/index.ts index 834549f..a1eb7ef 100644 --- a/packages/gateway/src/index.ts +++ b/packages/gateway/src/index.ts @@ -18,3 +18,5 @@ export { UpstreamError, ConfigError, } from './errors.js'; +export { createTraceStore } from './telemetry/store.js'; +export type { TraceStore, TraceRecord, TraceQuery } from './telemetry/trace.js'; diff --git a/packages/gateway/src/main.ts b/packages/gateway/src/main.ts index 58f3577..0229961 100644 --- a/packages/gateway/src/main.ts +++ b/packages/gateway/src/main.ts @@ -3,12 +3,32 @@ import { loadServerEnv, loadConfig } from './config.js'; import { createRegistry } from './providers/registry.js'; import { buildServer } from './server.js'; import { ConfigError } from './errors.js'; +import { createTraceStore } from './telemetry/store.js'; +import { initTelemetry } from './telemetry/otel.js'; async function main(): Promise { const env = loadServerEnv(process.env); const config = loadConfig({ path: env.configPath, env: process.env }); const registry = createRegistry(config); - const app = buildServer({ registry, apiKeys: env.apiKeys }); + const store = createTraceStore({ kind: env.traceDb, path: env.traceDbPath }); + const shutdownTelemetry = initTelemetry(store, { + otlpEndpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, + }); + const app = buildServer({ + registry, + apiKeys: env.apiKeys, + traceStore: store, + adminKey: env.adminKey, + }); + + const shutdown = async (): Promise => { + await app.close(); + await shutdownTelemetry(); + store.close(); + }; + process.on('SIGTERM', () => void shutdown()); + process.on('SIGINT', () => void shutdown()); + await app.listen({ port: env.port, host: '0.0.0.0' }); } diff --git a/packages/gateway/src/routes.traces.ts b/packages/gateway/src/routes.traces.ts new file mode 100644 index 0000000..e55d336 --- /dev/null +++ b/packages/gateway/src/routes.traces.ts @@ -0,0 +1,45 @@ +import type { FastifyPluginAsync } from 'fastify'; +import { createAdminAuthHook } from './auth.js'; +import type { TraceQuery, TraceStore } from './telemetry/trace.js'; + +export interface TraceRoutesOptions { + traceStore: TraceStore; + adminKey: string | undefined; +} + +/** Fastify plugin: an admin-key-gated read API over the trace store. */ +export const traceRoutes: FastifyPluginAsync = async (app, options) => { + const adminHook = createAdminAuthHook(options.adminKey); + + app.get('/traces', { preHandler: adminHook }, async (request, reply) => { + return reply.send(options.traceStore.query(parseTraceQuery(request.query))); + }); + + app.get('/traces/:id', { preHandler: adminHook }, async (request, reply) => { + const { id } = request.params as { id: string }; + const trace = options.traceStore.get(id); + if (trace === undefined) { + return reply + .status(404) + .send({ error: { message: `No trace "${id}"`, type: 'not_found', code: null } }); + } + return reply.send(trace); + }); +}; + +function parseTraceQuery(raw: unknown): TraceQuery { + const q = (typeof raw === 'object' && raw !== null ? raw : {}) as Record< + string, + string | undefined + >; + const query: TraceQuery = {}; + if (q.model !== undefined) query.model = q.model; + if (q.provider !== undefined) query.provider = q.provider; + if (q.status !== undefined) query.status = Number(q.status); + if (q.stream !== undefined) query.stream = q.stream === 'true'; + if (q.since !== undefined) query.since = Number(q.since); + if (q.until !== undefined) query.until = Number(q.until); + if (q.limit !== undefined) query.limit = Math.min(Number(q.limit), 500); + if (q.offset !== undefined) query.offset = Number(q.offset); + return query; +} diff --git a/packages/gateway/src/server.test.ts b/packages/gateway/src/server.test.ts index cd76bcd..03010b2 100644 --- a/packages/gateway/src/server.test.ts +++ b/packages/gateway/src/server.test.ts @@ -1,8 +1,14 @@ -import { describe, it, expect } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { trace } from '@opentelemetry/api'; +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; +import { SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'; import { buildServer } from './server.js'; import type { ProviderRegistry } from './providers/registry.js'; import type { Provider } from './providers/types.js'; import { ModelNotFoundError, UpstreamError } from './errors.js'; +import { InMemoryTraceStore } from './telemetry/store.memory.js'; +import type { TraceStore } from './telemetry/trace.js'; +import { TraceStoreSpanExporter } from './telemetry/exporter.js'; function makeProvider(overrides: Partial = {}): Provider { return { @@ -25,8 +31,17 @@ function makeRegistry(provider: Provider, opts: { unknownModel?: boolean } = {}) }; } -function buildTestServer(registry: ProviderRegistry) { - return buildServer({ registry, apiKeys: new Set(['good']), logger: false }); +function buildTestServer( + registry: ProviderRegistry, + opts: { store?: TraceStore; adminKey?: string } = {}, +) { + return buildServer({ + registry, + apiKeys: new Set(['good']), + logger: false, + traceStore: opts.store ?? new InMemoryTraceStore(), + adminKey: opts.adminKey, + }); } const body = { model: 'm', messages: [{ role: 'user', content: 'hi' }] }; @@ -140,7 +155,12 @@ describe('POST /v1/chat/completions', () => { throw new Error('boom'); }, }; - const app = buildServer({ registry, apiKeys: new Set(['good']), logger: false }); + const app = buildServer({ + registry, + apiKeys: new Set(['good']), + logger: false, + traceStore: new InMemoryTraceStore(), + }); const res = await app.inject({ method: 'POST', url, headers: auth, payload: body }); expect(res.statusCode).toBe(500); expect(res.json().error.type).toBe('internal_error'); @@ -148,7 +168,11 @@ describe('POST /v1/chat/completions', () => { }); it('uses a redacting logger by default', async () => { - const app = buildServer({ registry: makeRegistry(makeProvider()), apiKeys: new Set(['good']) }); + const app = buildServer({ + registry: makeRegistry(makeProvider()), + apiKeys: new Set(['good']), + traceStore: new InMemoryTraceStore(), + }); const res = await app.inject({ method: 'POST', url, headers: auth, payload: body }); expect(res.statusCode).toBe(200); await app.close(); @@ -185,3 +209,133 @@ describe('POST /v1/chat/completions', () => { await app.close(); }); }); + +describe('tracing & /traces', () => { + const sink = new InMemoryTraceStore(); + let provider: NodeTracerProvider | undefined; + + beforeAll(() => { + provider = new NodeTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(new TraceStoreSpanExporter(sink))], + }); + provider.register(); + }); + + afterAll(async () => { + trace.disable(); + await provider?.shutdown(); + }); + + it('records a trace for a completed request', async () => { + const chatProvider = makeProvider({ + chat: () => + Promise.resolve({ + id: 'cmpl', + choices: [], + usage: { prompt_tokens: 5, completion_tokens: 7, total_tokens: 12 }, + }), + }); + const app = buildTestServer(makeRegistry(chatProvider), { store: sink }); + await app.inject({ method: 'POST', url, headers: auth, payload: body }); + await app.close(); + + const traces = sink.query(); + expect(traces.length).toBeGreaterThanOrEqual(1); + const last = traces[0]; + expect(last?.model).toBe('m'); + expect(last?.provider).toBe('fake'); + expect(last?.status).toBe(200); + expect(last?.promptTokens).toBe(5); + expect(last?.completionTokens).toBe(7); + }); + + it('requires the admin key on /traces', async () => { + const app = buildTestServer(makeRegistry(makeProvider()), { + store: sink, + adminKey: 'admin-secret', + }); + const noKey = await app.inject({ method: 'GET', url: '/traces' }); + expect(noKey.statusCode).toBe(401); + const wrong = await app.inject({ + method: 'GET', + url: '/traces', + headers: { authorization: 'Bearer nope' }, + }); + expect(wrong.statusCode).toBe(401); + const ok = await app.inject({ + method: 'GET', + url: '/traces', + headers: { authorization: 'Bearer admin-secret' }, + }); + expect(ok.statusCode).toBe(200); + expect(Array.isArray(ok.json())).toBe(true); + await app.close(); + }); + + it('serves a single trace by id and 404s unknown ids', async () => { + const seeded = new InMemoryTraceStore(); + seeded.record({ + id: 'span-xyz', + traceId: 't', + timestamp: 1, + durationMs: 1, + model: 'm', + provider: 'p', + stream: false, + status: 200, + promptTokens: null, + completionTokens: null, + totalTokens: null, + errorType: null, + errorMessage: null, + apiKeyHash: null, + }); + const app = buildTestServer(makeRegistry(makeProvider()), { + store: seeded, + adminKey: 'admin-secret', + }); + const found = await app.inject({ + method: 'GET', + url: '/traces/span-xyz', + headers: { authorization: 'Bearer admin-secret' }, + }); + expect(found.statusCode).toBe(200); + expect(found.json().id).toBe('span-xyz'); + const missing = await app.inject({ + method: 'GET', + url: '/traces/nope', + headers: { authorization: 'Bearer admin-secret' }, + }); + expect(missing.statusCode).toBe(404); + await app.close(); + }); + + it('applies query filters on /traces', async () => { + const store = new InMemoryTraceStore(); + const base = { + traceId: 't', + durationMs: 1, + provider: 'p', + promptTokens: null, + completionTokens: null, + totalTokens: null, + errorType: null, + errorMessage: null, + apiKeyHash: null, + }; + store.record({ ...base, id: 's1', timestamp: 100, model: 'm1', stream: false, status: 200 }); + store.record({ ...base, id: 's2', timestamp: 200, model: 'm2', stream: true, status: 500 }); + const app = buildTestServer(makeRegistry(makeProvider()), { store, adminKey: 'admin-secret' }); + + const res = await app.inject({ + method: 'GET', + url: '/traces?model=m2&status=500&stream=true&since=150&until=250&limit=10&offset=0', + headers: { authorization: 'Bearer admin-secret' }, + }); + expect(res.statusCode).toBe(200); + const rows = res.json() as { id: string }[]; + expect(rows).toHaveLength(1); + expect(rows[0]?.id).toBe('s2'); + await app.close(); + }); +}); diff --git a/packages/gateway/src/server.ts b/packages/gateway/src/server.ts index fdc1890..78207c8 100644 --- a/packages/gateway/src/server.ts +++ b/packages/gateway/src/server.ts @@ -1,13 +1,19 @@ import Fastify from 'fastify'; import type { FastifyServerOptions } from 'fastify'; +import { SpanKind, SpanStatusCode, trace } from '@opentelemetry/api'; +import type { Span } from '@opentelemetry/api'; import { chatCompletionRequestSchema } from './schemas.js'; -import { createAuthHook } from './auth.js'; +import { createAuthHook, extractBearerToken, hashApiKey } from './auth.js'; import { GatewayError, ValidationError } from './errors.js'; import type { ProviderRegistry } from './providers/registry.js'; +import type { TraceStore } from './telemetry/trace.js'; +import { traceRoutes } from './routes.traces.js'; export interface ServerDeps { registry: ProviderRegistry; apiKeys: ReadonlySet; + traceStore: TraceStore; + adminKey?: string | undefined; logger?: FastifyServerOptions['logger']; } @@ -15,6 +21,40 @@ const internalErrorBody = { error: { message: 'Internal server error', type: 'internal_error', code: null }, }; +const requestSpans = new WeakMap(); + +/** Finalizes and ends the span attached to a request (no-op if none). Ends exactly once. */ +function endSpan(request: object, status: number, error?: unknown): void { + const span = requestSpans.get(request); + if (span === undefined) return; + requestSpans.delete(request); + span.setAttribute('http.response.status_code', status); + if (error === undefined) { + span.setStatus({ code: SpanStatusCode.OK }); + } else { + const err = error instanceof Error ? error : new Error(String(error)); + span.recordException(err); + span.setAttribute('error.type', err.name); + span.setStatus({ code: SpanStatusCode.ERROR, message: err.message }); + } + span.end(); +} + +/** Pulls OpenAI-style token usage off a non-streaming response onto the span. */ +function setUsageAttributes(span: Span | undefined, result: unknown): void { + if (span === undefined || typeof result !== 'object' || result === null) return; + const usage = (result as { usage?: unknown }).usage; + if (typeof usage !== 'object' || usage === null) return; + const u = usage as Record; + if (typeof u.prompt_tokens === 'number') + span.setAttribute('gen_ai.usage.input_tokens', u.prompt_tokens); + if (typeof u.completion_tokens === 'number') { + span.setAttribute('gen_ai.usage.output_tokens', u.completion_tokens); + } + if (typeof u.total_tokens === 'number') + span.setAttribute('gen_ai.usage.total_tokens', u.total_tokens); +} + /** Builds the Sentinel gateway Fastify app. Dependencies are injected for testability. */ export function buildServer(deps: ServerDeps) { const app = Fastify({ @@ -26,7 +66,9 @@ export function buildServer(deps: ServerDeps) { }, }); - app.setErrorHandler((error, _request, reply) => { + app.setErrorHandler((error, request, reply) => { + const status = error instanceof GatewayError ? error.status : 500; + endSpan(request, status, error); if (error instanceof GatewayError) { return reply.status(error.status).send(error.toBody()); } @@ -36,46 +78,79 @@ export function buildServer(deps: ServerDeps) { const authHook = createAuthHook(deps.apiKeys); - app.post('/v1/chat/completions', { preHandler: authHook }, async (request, reply) => { - const parsed = chatCompletionRequestSchema.safeParse(request.body); - if (!parsed.success) { - throw new ValidationError( - parsed.error.issues.map((i) => `${i.path.join('.') || '(body)'}: ${i.message}`).join('; '), - ); - } - const chatRequest = parsed.data; - const provider = deps.registry.resolve(chatRequest.model); + app.post( + '/v1/chat/completions', + { + onRequest: async (request) => { + // Resolve the tracer lazily so it picks up the registered provider (set after + // module load), rather than capturing a no-op tracer at import time. + const span = trace.getTracer('@sentinel/gateway').startSpan('chat.completion', { + kind: SpanKind.SERVER, + }); + requestSpans.set(request, span); + }, + preHandler: authHook, + }, + async (request, reply) => { + const span = requestSpans.get(request); + const token = extractBearerToken(request.headers.authorization); + if (span !== undefined && token !== null) { + span.setAttribute('sentinel.api_key_hash', hashApiKey(token)); + } - if (chatRequest.stream !== true) { - const result = await provider.chat(chatRequest); - return reply.status(200).send(result); - } + const parsed = chatCompletionRequestSchema.safeParse(request.body); + if (!parsed.success) { + throw new ValidationError( + parsed.error.issues + .map((i) => `${i.path.join('.') || '(body)'}: ${i.message}`) + .join('; '), + ); + } + const chatRequest = parsed.data; + span?.setAttribute('gen_ai.request.model', chatRequest.model); + span?.setAttribute('sentinel.stream', chatRequest.stream === true); + + const provider = deps.registry.resolve(chatRequest.model); + span?.setAttribute('sentinel.provider', provider.name); - // Streaming: pull the first chunk *before* committing to a 200 SSE response, - // so an immediate upstream failure still maps to a proper error status. - const iterator = provider.chatStream(chatRequest)[Symbol.asyncIterator](); - const first = await iterator.next(); - - reply.hijack(); - reply.raw.writeHead(200, { - 'content-type': 'text/event-stream', - 'cache-control': 'no-cache', - connection: 'keep-alive', - }); - - try { - for (let step = first; step.done !== true; step = await iterator.next()) { - reply.raw.write(`data: ${step.value}\n\n`); + if (chatRequest.stream !== true) { + const result = await provider.chat(chatRequest); + setUsageAttributes(span, result); + endSpan(request, 200); + return reply.status(200).send(result); } - reply.raw.write('data: [DONE]\n\n'); - } catch (error) { - const body = error instanceof GatewayError ? error.toBody() : internalErrorBody; - reply.raw.write(`data: ${JSON.stringify(body)}\n\n`); - } finally { - reply.raw.end(); - } - return reply; - }); + + // Streaming: pull the first chunk *before* committing to a 200 SSE response, + // so an immediate upstream failure still maps to a proper error status. + const iterator = provider.chatStream(chatRequest)[Symbol.asyncIterator](); + const first = await iterator.next(); + + reply.hijack(); + reply.raw.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + connection: 'keep-alive', + }); + + let streamError: unknown; + try { + for (let step = first; step.done !== true; step = await iterator.next()) { + reply.raw.write(`data: ${step.value}\n\n`); + } + reply.raw.write('data: [DONE]\n\n'); + } catch (error) { + streamError = error; + const body = error instanceof GatewayError ? error.toBody() : internalErrorBody; + reply.raw.write(`data: ${JSON.stringify(body)}\n\n`); + } finally { + reply.raw.end(); + endSpan(request, 200, streamError); + } + return reply; + }, + ); + + app.register(traceRoutes, { traceStore: deps.traceStore, adminKey: deps.adminKey }); return app; } diff --git a/packages/gateway/src/telemetry/exporter.test.ts b/packages/gateway/src/telemetry/exporter.test.ts new file mode 100644 index 0000000..630ac28 --- /dev/null +++ b/packages/gateway/src/telemetry/exporter.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from 'vitest'; +import { SpanStatusCode } from '@opentelemetry/api'; +import { ExportResultCode } from '@opentelemetry/core'; +import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import type { ExportResult } from '@opentelemetry/core'; +import { TraceStoreSpanExporter } from './exporter.js'; +import { spanToTraceRecord } from './trace.js'; +import { InMemoryTraceStore } from './store.memory.js'; +import type { TraceStore } from './trace.js'; + +function fakeSpan(over: Partial = {}): ReadableSpan { + const base = { + name: 'chat.completion', + spanContext: () => ({ traceId: 'trace-abc', spanId: 'span-abc', traceFlags: 1 }), + startTime: [1, 500_000_000], + endTime: [1, 505_000_000], + duration: [0, 5_000_000], + status: { code: SpanStatusCode.OK }, + attributes: { + 'gen_ai.request.model': 'gpt-4o-mini', + 'sentinel.provider': 'openai', + 'sentinel.stream': false, + 'http.response.status_code': 200, + 'gen_ai.usage.input_tokens': 11, + 'gen_ai.usage.output_tokens': 22, + 'gen_ai.usage.total_tokens': 33, + 'sentinel.api_key_hash': 'hash1', + }, + }; + return { ...base, ...over } as unknown as ReadableSpan; +} + +describe('spanToTraceRecord', () => { + it('maps span attributes and timing into a record', () => { + const rec = spanToTraceRecord(fakeSpan()); + expect(rec.id).toBe('span-abc'); + expect(rec.traceId).toBe('trace-abc'); + expect(rec.timestamp).toBe(1500); + expect(rec.durationMs).toBe(5); + expect(rec.model).toBe('gpt-4o-mini'); + expect(rec.provider).toBe('openai'); + expect(rec.stream).toBe(false); + expect(rec.status).toBe(200); + expect(rec.promptTokens).toBe(11); + expect(rec.completionTokens).toBe(22); + expect(rec.totalTokens).toBe(33); + expect(rec.apiKeyHash).toBe('hash1'); + expect(rec.errorType).toBeNull(); + expect(rec.errorMessage).toBeNull(); + }); + + it('captures error status/message and tolerates missing attributes', () => { + const rec = spanToTraceRecord( + fakeSpan({ + status: { code: SpanStatusCode.ERROR, message: 'upstream failed' }, + attributes: { 'error.type': 'UpstreamError' }, + }), + ); + expect(rec.errorMessage).toBe('upstream failed'); + expect(rec.errorType).toBe('UpstreamError'); + expect(rec.model).toBeNull(); + expect(rec.status).toBe(0); + expect(rec.promptTokens).toBeNull(); + expect(rec.stream).toBe(false); + }); +}); + +describe('TraceStoreSpanExporter', () => { + it('persists spans to the store and reports success', () => { + const store = new InMemoryTraceStore(); + const exporter = new TraceStoreSpanExporter(store); + let code: ExportResultCode | undefined; + exporter.export( + [ + fakeSpan(), + fakeSpan({ spanContext: () => ({ traceId: 't2', spanId: 's2', traceFlags: 1 }) }), + ], + (result: ExportResult) => { + code = result.code; + }, + ); + expect(code).toBe(ExportResultCode.SUCCESS); + expect(store.query()).toHaveLength(2); + }); + + it('reports failure when the store throws', () => { + const throwingStore: TraceStore = { + record() { + throw new Error('disk full'); + }, + query: () => [], + get: () => undefined, + close() { + // no resources + }, + }; + const exporter = new TraceStoreSpanExporter(throwingStore); + let result: ExportResult | undefined; + exporter.export([fakeSpan()], (r: ExportResult) => { + result = r; + }); + expect(result?.code).toBe(ExportResultCode.FAILED); + expect(result?.error?.message).toBe('disk full'); + }); +}); diff --git a/packages/gateway/src/telemetry/exporter.ts b/packages/gateway/src/telemetry/exporter.ts new file mode 100644 index 0000000..9893f2f --- /dev/null +++ b/packages/gateway/src/telemetry/exporter.ts @@ -0,0 +1,36 @@ +import type { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base'; +import type { ExportResult } from '@opentelemetry/core'; +import { ExportResultCode } from '@opentelemetry/core'; +import type { TraceStore } from './trace.js'; +import { spanToTraceRecord } from './trace.js'; + +/** An OpenTelemetry SpanExporter that persists finished spans to a TraceStore. */ +export class TraceStoreSpanExporter implements SpanExporter { + private readonly store: TraceStore; + + constructor(store: TraceStore) { + this.store = store; + } + + export(spans: ReadableSpan[], resultCallback: (result: ExportResult) => void): void { + try { + for (const span of spans) { + this.store.record(spanToTraceRecord(span)); + } + resultCallback({ code: ExportResultCode.SUCCESS }); + } catch (error) { + resultCallback({ + code: ExportResultCode.FAILED, + error: error instanceof Error ? error : new Error(String(error)), + }); + } + } + + shutdown(): Promise { + return Promise.resolve(); + } + + forceFlush(): Promise { + return Promise.resolve(); + } +} diff --git a/packages/gateway/src/telemetry/otel.ts b/packages/gateway/src/telemetry/otel.ts new file mode 100644 index 0000000..8d00d7a --- /dev/null +++ b/packages/gateway/src/telemetry/otel.ts @@ -0,0 +1,39 @@ +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; +import { BatchSpanProcessor, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'; +import type { SpanProcessor } from '@opentelemetry/sdk-trace-base'; +import { resourceFromAttributes } from '@opentelemetry/resources'; +import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import type { TraceStore } from './trace.js'; +import { TraceStoreSpanExporter } from './exporter.js'; + +export interface TelemetryOptions { + /** If set, spans are also exported to this OTLP/HTTP endpoint (Jaeger, a collector, ...). */ + otlpEndpoint?: string | undefined; +} + +/** + * Registers the global OpenTelemetry tracer provider: every span is persisted to the + * trace store, and (optionally) exported to an external OTLP collector. Returns a + * shutdown function. Bootstrap-only — invoked from main.ts. + */ +export function initTelemetry( + store: TraceStore, + options: TelemetryOptions = {}, +): () => Promise { + const processors: SpanProcessor[] = [new SimpleSpanProcessor(new TraceStoreSpanExporter(store))]; + + if (options.otlpEndpoint !== undefined && options.otlpEndpoint.length > 0) { + processors.push(new BatchSpanProcessor(new OTLPTraceExporter({ url: options.otlpEndpoint }))); + } + + const provider = new NodeTracerProvider({ + resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: '@sentinel/gateway' }), + spanProcessors: processors, + }); + provider.register(); + + return async () => { + await provider.shutdown(); + }; +} diff --git a/packages/gateway/src/telemetry/store.memory.ts b/packages/gateway/src/telemetry/store.memory.ts new file mode 100644 index 0000000..962d093 --- /dev/null +++ b/packages/gateway/src/telemetry/store.memory.ts @@ -0,0 +1,37 @@ +import type { TraceQuery, TraceRecord, TraceStore } from './trace.js'; + +/** In-memory trace store — used by tests and as a no-database fallback. */ +export class InMemoryTraceStore implements TraceStore { + private readonly traces: TraceRecord[] = []; + + record(trace: TraceRecord): void { + this.traces.push(trace); + } + + query(filter: TraceQuery = {}): TraceRecord[] { + const matched = this.traces + .filter((trace) => matches(trace, filter)) + .sort((a, b) => b.timestamp - a.timestamp); + const offset = filter.offset ?? 0; + const limit = filter.limit ?? 50; + return matched.slice(offset, offset + limit); + } + + get(id: string): TraceRecord | undefined { + return this.traces.find((trace) => trace.id === id); + } + + close(): void { + // no resources to release + } +} + +function matches(trace: TraceRecord, filter: TraceQuery): boolean { + if (filter.model !== undefined && trace.model !== filter.model) return false; + if (filter.provider !== undefined && trace.provider !== filter.provider) return false; + if (filter.status !== undefined && trace.status !== filter.status) return false; + if (filter.stream !== undefined && trace.stream !== filter.stream) return false; + if (filter.since !== undefined && trace.timestamp < filter.since) return false; + if (filter.until !== undefined && trace.timestamp > filter.until) return false; + return true; +} diff --git a/packages/gateway/src/telemetry/store.sqlite.ts b/packages/gateway/src/telemetry/store.sqlite.ts new file mode 100644 index 0000000..fec8dcf --- /dev/null +++ b/packages/gateway/src/telemetry/store.sqlite.ts @@ -0,0 +1,146 @@ +import Database from 'better-sqlite3'; +import type { TraceQuery, TraceRecord, TraceStore } from './trace.js'; + +const SCHEMA = ` + CREATE TABLE IF NOT EXISTS traces ( + id TEXT PRIMARY KEY, + trace_id TEXT NOT NULL, + timestamp INTEGER NOT NULL, + duration_ms REAL NOT NULL, + model TEXT, + provider TEXT, + stream INTEGER NOT NULL, + status INTEGER NOT NULL, + prompt_tokens INTEGER, + completion_tokens INTEGER, + total_tokens INTEGER, + error_type TEXT, + error_message TEXT, + api_key_hash TEXT + ); + CREATE INDEX IF NOT EXISTS idx_traces_timestamp ON traces (timestamp); + CREATE INDEX IF NOT EXISTS idx_traces_model ON traces (model); + CREATE INDEX IF NOT EXISTS idx_traces_status ON traces (status); +`; + +interface TraceRow { + id: string; + trace_id: string; + timestamp: number; + duration_ms: number; + model: string | null; + provider: string | null; + stream: number; + status: number; + prompt_tokens: number | null; + completion_tokens: number | null; + total_tokens: number | null; + error_type: string | null; + error_message: string | null; + api_key_hash: string | null; +} + +/** SQLite-backed trace store (better-sqlite3, synchronous). Pass ':memory:' for tests. */ +export class SqliteTraceStore implements TraceStore { + private readonly db: InstanceType; + + constructor(path: string) { + this.db = new Database(path); + this.db.pragma('journal_mode = WAL'); + this.db.exec(SCHEMA); + } + + record(trace: TraceRecord): void { + this.db + .prepare( + `INSERT OR REPLACE INTO traces + (id, trace_id, timestamp, duration_ms, model, provider, stream, status, + prompt_tokens, completion_tokens, total_tokens, error_type, error_message, api_key_hash) + VALUES + (@id, @traceId, @timestamp, @durationMs, @model, @provider, @stream, @status, + @promptTokens, @completionTokens, @totalTokens, @errorType, @errorMessage, @apiKeyHash)`, + ) + .run({ + id: trace.id, + traceId: trace.traceId, + timestamp: trace.timestamp, + durationMs: trace.durationMs, + model: trace.model, + provider: trace.provider, + stream: trace.stream ? 1 : 0, + status: trace.status, + promptTokens: trace.promptTokens, + completionTokens: trace.completionTokens, + totalTokens: trace.totalTokens, + errorType: trace.errorType, + errorMessage: trace.errorMessage, + apiKeyHash: trace.apiKeyHash, + }); + } + + query(filter: TraceQuery = {}): TraceRecord[] { + const where: string[] = []; + const params: Record = {}; + if (filter.model !== undefined) { + where.push('model = @model'); + params.model = filter.model; + } + if (filter.provider !== undefined) { + where.push('provider = @provider'); + params.provider = filter.provider; + } + if (filter.status !== undefined) { + where.push('status = @status'); + params.status = filter.status; + } + if (filter.stream !== undefined) { + where.push('stream = @stream'); + params.stream = filter.stream ? 1 : 0; + } + if (filter.since !== undefined) { + where.push('timestamp >= @since'); + params.since = filter.since; + } + if (filter.until !== undefined) { + where.push('timestamp <= @until'); + params.until = filter.until; + } + params.limit = filter.limit ?? 50; + params.offset = filter.offset ?? 0; + const clause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''; + const rows = this.db + .prepare(`SELECT * FROM traces ${clause} ORDER BY timestamp DESC LIMIT @limit OFFSET @offset`) + .all(params) as TraceRow[]; + return rows.map(rowToRecord); + } + + get(id: string): TraceRecord | undefined { + const row = this.db.prepare('SELECT * FROM traces WHERE id = @id').get({ id }) as + | TraceRow + | undefined; + return row ? rowToRecord(row) : undefined; + } + + close(): void { + this.db.close(); + } +} + +function rowToRecord(row: TraceRow): TraceRecord { + return { + id: row.id, + traceId: row.trace_id, + timestamp: row.timestamp, + durationMs: row.duration_ms, + model: row.model, + provider: row.provider, + stream: row.stream === 1, + status: row.status, + promptTokens: row.prompt_tokens, + completionTokens: row.completion_tokens, + totalTokens: row.total_tokens, + errorType: row.error_type, + errorMessage: row.error_message, + apiKeyHash: row.api_key_hash, + }; +} diff --git a/packages/gateway/src/telemetry/store.test.ts b/packages/gateway/src/telemetry/store.test.ts new file mode 100644 index 0000000..1408260 --- /dev/null +++ b/packages/gateway/src/telemetry/store.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from 'vitest'; +import type { TraceRecord, TraceStore } from './trace.js'; +import { InMemoryTraceStore } from './store.memory.js'; +import { SqliteTraceStore } from './store.sqlite.js'; +import { createTraceStore } from './store.js'; + +function sample(over: Partial = {}): TraceRecord { + return { + id: 'span-1', + traceId: 'trace-1', + timestamp: 1000, + durationMs: 5, + model: 'gpt-4o-mini', + provider: 'openai', + stream: false, + status: 200, + promptTokens: 10, + completionTokens: 20, + totalTokens: 30, + errorType: null, + errorMessage: null, + apiKeyHash: 'abc', + ...over, + }; +} + +const backends: [string, () => TraceStore][] = [ + ['InMemoryTraceStore', () => new InMemoryTraceStore()], + ['SqliteTraceStore', () => new SqliteTraceStore(':memory:')], +]; + +for (const [name, makeStore] of backends) { + describe(name, () => { + it('records and fetches a trace by id', () => { + const store = makeStore(); + store.record(sample()); + expect(store.get('span-1')?.model).toBe('gpt-4o-mini'); + expect(store.get('missing')).toBeUndefined(); + store.close(); + }); + + it('returns traces newest-first', () => { + const store = makeStore(); + store.record(sample({ id: 'a', timestamp: 100 })); + store.record(sample({ id: 'b', timestamp: 300 })); + store.record(sample({ id: 'c', timestamp: 200 })); + expect(store.query().map((t) => t.id)).toEqual(['b', 'c', 'a']); + store.close(); + }); + + it('filters by model, status, stream, and time window', () => { + const store = makeStore(); + store.record(sample({ id: 'a', model: 'm1', status: 200, stream: false, timestamp: 100 })); + store.record(sample({ id: 'b', model: 'm2', status: 500, stream: true, timestamp: 200 })); + expect(store.query({ model: 'm2' }).map((t) => t.id)).toEqual(['b']); + expect(store.query({ status: 500 }).map((t) => t.id)).toEqual(['b']); + expect(store.query({ stream: true }).map((t) => t.id)).toEqual(['b']); + expect(store.query({ provider: 'openai' }).map((t) => t.id)).toEqual(['b', 'a']); + expect(store.query({ since: 150 }).map((t) => t.id)).toEqual(['b']); + expect(store.query({ until: 150 }).map((t) => t.id)).toEqual(['a']); + store.close(); + }); + + it('respects limit and offset', () => { + const store = makeStore(); + for (let i = 0; i < 5; i++) store.record(sample({ id: `s${i}`, timestamp: i })); + expect(store.query({ limit: 2 }).map((t) => t.id)).toEqual(['s4', 's3']); + expect(store.query({ limit: 2, offset: 2 }).map((t) => t.id)).toEqual(['s2', 's1']); + store.close(); + }); + + it('round-trips nullable fields and the stream flag', () => { + const store = makeStore(); + store.record( + sample({ + id: 'e', + model: null, + provider: null, + stream: true, + status: 502, + promptTokens: null, + completionTokens: null, + totalTokens: null, + errorType: 'UpstreamError', + errorMessage: 'boom', + apiKeyHash: null, + }), + ); + const got = store.get('e'); + expect(got?.model).toBeNull(); + expect(got?.stream).toBe(true); + expect(got?.errorType).toBe('UpstreamError'); + expect(got?.promptTokens).toBeNull(); + store.close(); + }); + }); +} + +describe('createTraceStore', () => { + it('builds the requested backend', () => { + const mem = createTraceStore({ kind: 'memory' }); + mem.record(sample()); + expect(mem.query()).toHaveLength(1); + mem.close(); + + const sql = createTraceStore({ kind: 'sqlite', path: ':memory:' }); + sql.record(sample()); + expect(sql.query()).toHaveLength(1); + sql.close(); + }); +}); diff --git a/packages/gateway/src/telemetry/store.ts b/packages/gateway/src/telemetry/store.ts new file mode 100644 index 0000000..d9bef91 --- /dev/null +++ b/packages/gateway/src/telemetry/store.ts @@ -0,0 +1,16 @@ +import type { TraceStore } from './trace.js'; +import { InMemoryTraceStore } from './store.memory.js'; +import { SqliteTraceStore } from './store.sqlite.js'; + +export interface TraceStoreOptions { + kind: 'sqlite' | 'memory'; + path?: string; +} + +/** Builds the configured trace store. Mirrors the provider-registry factory style. */ +export function createTraceStore(options: TraceStoreOptions): TraceStore { + if (options.kind === 'memory') { + return new InMemoryTraceStore(); + } + return new SqliteTraceStore(options.path ?? './traces.db'); +} diff --git a/packages/gateway/src/telemetry/trace.ts b/packages/gateway/src/telemetry/trace.ts new file mode 100644 index 0000000..66e5b3f --- /dev/null +++ b/packages/gateway/src/telemetry/trace.ts @@ -0,0 +1,75 @@ +import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import { SpanStatusCode } from '@opentelemetry/api'; + +/** A persisted, queryable record of one gateway request. Metadata only — no prompt/response content. */ +export interface TraceRecord { + id: string; + traceId: string; + timestamp: number; + durationMs: number; + model: string | null; + provider: string | null; + stream: boolean; + status: number; + promptTokens: number | null; + completionTokens: number | null; + totalTokens: number | null; + errorType: string | null; + errorMessage: string | null; + apiKeyHash: string | null; +} + +/** Filters for querying traces (all optional). */ +export interface TraceQuery { + model?: string; + provider?: string; + status?: number; + stream?: boolean; + since?: number; + until?: number; + limit?: number; + offset?: number; +} + +/** A persistence sink for trace records. Implemented by the in-memory and SQLite stores. */ +export interface TraceStore { + record(trace: TraceRecord): void; + query(filter?: TraceQuery): TraceRecord[]; + get(id: string): TraceRecord | undefined; + close(): void; +} + +function asString(value: unknown): string | null { + return typeof value === 'string' ? value : null; +} + +function asNumber(value: unknown): number | null { + return typeof value === 'number' ? value : null; +} + +function hrTimeToMs(time: readonly [number, number]): number { + return time[0] * 1000 + time[1] / 1e6; +} + +/** Maps a finished OpenTelemetry span into a metadata-only trace record. */ +export function spanToTraceRecord(span: ReadableSpan): TraceRecord { + const attrs = span.attributes; + const ctx = span.spanContext(); + const isError = span.status.code === SpanStatusCode.ERROR; + return { + id: ctx.spanId, + traceId: ctx.traceId, + timestamp: Math.round(hrTimeToMs(span.startTime)), + durationMs: Math.round(hrTimeToMs(span.duration) * 1000) / 1000, + model: asString(attrs['gen_ai.request.model']), + provider: asString(attrs['sentinel.provider']), + stream: attrs['sentinel.stream'] === true, + status: asNumber(attrs['http.response.status_code']) ?? 0, + promptTokens: asNumber(attrs['gen_ai.usage.input_tokens']), + completionTokens: asNumber(attrs['gen_ai.usage.output_tokens']), + totalTokens: asNumber(attrs['gen_ai.usage.total_tokens']), + errorType: asString(attrs['error.type']), + errorMessage: isError ? (span.status.message ?? null) : null, + apiKeyHash: asString(attrs['sentinel.api_key_hash']), + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a35343f..35cb142 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,30 @@ importers: packages/gateway: dependencies: + '@opentelemetry/api': + specifier: ^1.9.1 + version: 1.9.1 + '@opentelemetry/core': + specifier: ^2.8.0 + version: 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-http': + specifier: ^0.219.0 + version: 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': + specifier: ^2.8.0 + version: 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': + specifier: ^2.8.0 + version: 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-node': + specifier: ^2.8.0 + version: 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': + specifier: ^1.41.1 + version: 1.41.1 + better-sqlite3: + specifier: ^11.10.0 + version: 11.10.0 dotenv: specifier: ^17.4.2 version: 17.4.2 @@ -50,6 +74,10 @@ importers: zod: specifier: ^3.25.76 version: 3.25.76 + devDependencies: + '@types/better-sqlite3': + specifier: ^7.6.13 + version: 7.6.13 packages: @@ -468,6 +496,78 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@opentelemetry/api-logs@0.219.0': + resolution: {integrity: sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/context-async-hooks@2.8.0': + resolution: {integrity: sha512-/3FIraneMcng67SUJCxvyInk/oxzwsxyadufk0wwfOBLf5wqtAGX4MoQASwSbndBPeARzBryUM9Azr5kHIdWLw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.8.0': + resolution: {integrity: sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-trace-otlp-http@0.219.0': + resolution: {integrity: sha512-9t6SvBXXBEjOBcIzgozvBbd3jWrv3Gt3ngGhl1fhdZ/zRc7oZDVOFEqbi2zlBpW9BXhgDMKv422J0DL/3iQWfw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.219.0': + resolution: {integrity: sha512-zvIxQX/AZUVKDU+hCuYx+7UkiP7GRdnk1ZbFQRYzHvYp47cAWR4j3IhoPhV9KaeXEv2xdGq3IA6PnpzDmLcmSA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.219.0': + resolution: {integrity: sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/resources@2.8.0': + resolution: {integrity: sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.219.0': + resolution: {integrity: sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.8.0': + resolution: {integrity: sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.8.0': + resolution: {integrity: sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-node@2.8.0': + resolution: {integrity: sha512-nZt9OGufioAc3AfoLTqA9bsAeaMJAictYDdI2VcNQ+PmT+3rfKjAZDZvgPfd8VPX0O5Bw1hdQF6kDK8VSpZiWg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.41.1': + resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} + engines: {node: '>=14'} + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -600,6 +700,9 @@ packages: cpu: [x64] os: [win32] + '@types/better-sqlite3@7.6.13': + resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -770,6 +873,18 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + better-sqlite3@11.10.0: + resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + brace-expansion@1.1.15: resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} @@ -780,6 +895,9 @@ packages: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -800,6 +918,9 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -827,10 +948,18 @@ packages: supports-color: optional: true + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -838,6 +967,10 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + dotenv@17.4.2: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} @@ -851,6 +984,9 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -923,6 +1059,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} @@ -967,6 +1107,9 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + find-my-way@9.6.0: resolution: {integrity: sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==} engines: {node: '>=20'} @@ -986,11 +1129,17 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -1011,6 +1160,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1027,6 +1179,12 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + ipaddr.js@2.4.0: resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} engines: {node: '>= 10'} @@ -1117,6 +1275,10 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -1128,10 +1290,16 @@ packages: resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1140,13 +1308,23 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + node-abi@3.92.0: + resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==} + engines: {node: '>=10'} + on-exit-leak-free@2.1.2: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1206,6 +1384,12 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -1221,6 +1405,9 @@ packages: process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -1228,6 +1415,14 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + real-require@0.2.0: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} @@ -1259,6 +1454,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-regex2@5.1.1: resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} hasBin: true @@ -1293,6 +1491,12 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -1318,6 +1522,9 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -1326,6 +1533,10 @@ packages: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -1334,6 +1545,13 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + test-exclude@7.0.2: resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} engines: {node: '>=18'} @@ -1379,6 +1597,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -1401,6 +1622,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vite-node@2.1.9: resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -1484,6 +1708,9 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -1770,6 +1997,82 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@opentelemetry/api-logs@0.219.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/context-async-hooks@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/exporter-trace-otlp-http@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-exporter-base@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.219.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-transformer@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.219.0 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/resources@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/sdk-logs@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.219.0 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/sdk-metrics@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/sdk-trace-node@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/context-async-hooks': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/semantic-conventions@1.41.1': {} + '@pinojs/redact@0.4.0': {} '@pkgjs/parseargs@0.11.0': @@ -1850,6 +2153,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true + '@types/better-sqlite3@7.6.13': + dependencies: + '@types/node': 22.20.0 + '@types/estree@1.0.9': {} '@types/json-schema@7.0.15': {} @@ -2058,6 +2365,23 @@ snapshots: balanced-match@4.0.4: {} + base64-js@1.5.1: {} + + better-sqlite3@11.10.0: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + brace-expansion@1.1.15: dependencies: balanced-match: 1.0.2 @@ -2071,6 +2395,11 @@ snapshots: dependencies: balanced-match: 4.0.4 + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + cac@6.7.14: {} callsites@3.1.0: {} @@ -2090,6 +2419,8 @@ snapshots: check-error@2.1.3: {} + chownr@1.1.4: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -2110,12 +2441,20 @@ snapshots: dependencies: ms: 2.1.3 + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + deep-eql@5.0.2: {} + deep-extend@0.6.0: {} + deep-is@0.1.4: {} dequal@2.0.3: {} + detect-libc@2.1.2: {} + dotenv@17.4.2: {} eastasianwidth@0.2.0: {} @@ -2124,6 +2463,10 @@ snapshots: emoji-regex@9.2.2: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + es-module-lexer@1.7.0: {} esbuild@0.21.5: @@ -2259,6 +2602,8 @@ snapshots: esutils@2.0.3: {} + expand-template@2.0.3: {} + expect-type@1.4.0: {} fast-decode-uri-component@1.0.1: {} @@ -2314,6 +2659,8 @@ snapshots: dependencies: flat-cache: 4.0.1 + file-uri-to-path@1.0.0: {} + find-my-way@9.6.0: dependencies: fast-deep-equal: 3.1.3 @@ -2337,9 +2684,13 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + fs-constants@1.0.0: {} + fsevents@2.3.3: optional: true + github-from-package@0.0.0: {} + glob-parent@6.0.2: dependencies: is-glob: 4.0.3 @@ -2359,6 +2710,8 @@ snapshots: html-escaper@2.0.2: {} + ieee754@1.2.1: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -2370,6 +2723,10 @@ snapshots: imurmurhash@0.1.4: {} + inherits@2.0.4: {} + + ini@1.3.8: {} + ipaddr.js@2.4.0: {} is-extglob@2.1.1: {} @@ -2464,6 +2821,8 @@ snapshots: dependencies: semver: 7.8.5 + mimic-response@3.1.0: {} + minimatch@10.2.5: dependencies: brace-expansion: 5.0.6 @@ -2476,16 +2835,30 @@ snapshots: dependencies: brace-expansion: 2.1.1 + minimist@1.2.8: {} + minipass@7.1.3: {} + mkdirp-classic@0.5.3: {} + ms@2.1.3: {} nanoid@3.3.15: {} + napi-build-utils@2.0.0: {} + natural-compare@1.4.0: {} + node-abi@3.92.0: + dependencies: + semver: 7.8.5 + on-exit-leak-free@2.1.2: {} + once@1.4.0: + dependencies: + wrappy: 1.0.2 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -2552,6 +2925,21 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.92.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + prelude-ls@1.2.1: {} prettier@3.8.5: {} @@ -2560,10 +2948,28 @@ snapshots: process-warning@5.0.0: {} + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode@2.3.1: {} quick-format-unescaped@4.0.4: {} + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + real-require@0.2.0: {} real-require@1.0.0: {} @@ -2609,6 +3015,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.62.2 fsevents: 2.3.3 + safe-buffer@5.2.1: {} + safe-regex2@5.1.1: dependencies: ret: 0.5.0 @@ -2631,6 +3039,14 @@ snapshots: signal-exit@4.1.0: {} + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 @@ -2655,6 +3071,10 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.2.0 + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -2663,12 +3083,29 @@ snapshots: dependencies: ansi-regex: 6.2.2 + strip-json-comments@2.0.1: {} + strip-json-comments@3.1.1: {} supports-color@7.2.0: dependencies: has-flag: 4.0.0 + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + test-exclude@7.0.2: dependencies: '@istanbuljs/schema': 0.1.6 @@ -2706,6 +3143,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -2729,6 +3170,8 @@ snapshots: dependencies: punycode: 2.3.1 + util-deprecate@1.0.2: {} + vite-node@2.1.9(@types/node@22.20.0): dependencies: cac: 6.7.14 @@ -2814,6 +3257,8 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.2.0 + wrappy@1.0.2: {} + yocto-queue@0.1.0: {} zod@3.25.76: {} diff --git a/tsconfig.base.json b/tsconfig.base.json index be305d0..4e24a84 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -4,6 +4,7 @@ "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", + "esModuleInterop": true, "lib": ["ES2023"], "types": ["node"], diff --git a/vitest.config.ts b/vitest.config.ts index 409e460..14bf4a4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,7 +7,13 @@ export default defineConfig({ coverage: { provider: 'v8', include: ['packages/*/src/**/*.ts'], - exclude: ['**/*.test.ts', '**/index.ts', '**/main.ts', '**/providers/types.ts'], + exclude: [ + '**/*.test.ts', + '**/index.ts', + '**/main.ts', + '**/providers/types.ts', + '**/telemetry/otel.ts', + ], reporter: ['text', 'html'], thresholds: { lines: 90,