diff --git a/docs/observe.md b/docs/observe.md index 6bf241a..cec2f8f 100644 --- a/docs/observe.md +++ b/docs/observe.md @@ -228,12 +228,73 @@ Key constraints: ## Sink Implementations APOPHIS does not ship with built-in sinks. The `ObserveSink` interface lets you -plug in any backend. Common patterns: +plug in any backend. -- **OpenTelemetry**: emit counters and histograms via `@opentelemetry/api`. -- **pino logger**: emit structured log records via `pino.info()` / `pino.warn()`. -- **Internal metrics service**: POST events to an internal collector endpoint. -- **In-memory ring buffer**: capture recent events for diagnostics endpoints. +### OpenTelemetry + +Export contract evaluations as OTel custom metrics alongside your existing spans and traces: + +```typescript +import type { ObserveSink, ObserveEvent } from '@apophis/fastify' +import { metrics } from '@opentelemetry/api' + +const meter = metrics.getMeter('apophis') + +const contractCounter = meter.createCounter('apophis.contract.evaluations', { + description: 'Number of contract evaluations', +}) +const violationCounter = meter.createCounter('apophis.contract.violations', { + description: 'Number of contract violations', +}) +const durationHistogram = meter.createHistogram('apophis.contract.duration_ms', { + description: 'Contract evaluation duration', +}) + +export const otelSink: ObserveSink = { + emit(event: ObserveEvent) { + const labels = { route: event.route, formula: event.formula } + contractCounter.add(1, labels) + if (event.type === 'contract.violation') { + violationCounter.add(1, labels) + } + durationHistogram.record(event.durationMs, labels) + }, +} +``` + +Register it with your Fastify app: + +```typescript +await app.register(apophisPlugin, { + observe: { enabled: true, sinks: [otelSink] }, +}) +``` + +APOPHIS violations now appear in your OTel metrics backend (Prometheus, Datadog, New Relic, etc.) alongside latency and error data — no custom collector needed. + +### Console (development / debugging) + +```typescript +export const consoleSink: ObserveSink = { + emit(event: ObserveEvent) { + if (event.type === 'contract.violation') { + console.warn('[apophis]', event.route, event.formula, event.observed) + } + }, +} +``` + +### pino logger + +```typescript +export function createPinoSink(logger: pino.Logger): ObserveSink { + return { + emit(event: ObserveEvent) { + logger[event.type === 'contract.violation' ? 'warn' : 'info'](event) + }, + } +} +``` ## Monorepo Validation diff --git a/docs/verify.md b/docs/verify.md index 84a8f6c..b30d9ef 100644 --- a/docs/verify.md +++ b/docs/verify.md @@ -83,7 +83,7 @@ apophis verify --profile ci --changed If no routes changed, exits 2 with a message. -`--changed` is a heuristic: it maps changed file paths to routes by checking route path segments against file names. This is useful as a developer convenience, but for strict CI gating, prefer explicit `--routes` filters or full verification. +`--changed` tracks which source file registered each route by capturing stack traces during route discovery. When a file changes, only routes registered from that file are verified. Falls back to a path-segment heuristic for routes discovered without source file metadata (e.g., `printRoutes()` fallback). ## Failure Output Format diff --git a/src/cli/commands/verify/runner.ts b/src/cli/commands/verify/runner.ts index 9d67e6b..609d7f3 100644 --- a/src/cli/commands/verify/runner.ts +++ b/src/cli/commands/verify/runner.ts @@ -185,23 +185,29 @@ export async function getGitChangedFiles(cwd: string): Promise { } /** - * Filter routes to only those modified in git. + * Filter routes to only those whose source file was modified in git. + * Uses sourceFile captured from stack traces during route registration. + * Falls back to path-segment heuristic when sourceFile is unavailable. */ export async function filterChangedRoutes( routes: RouteContract[], cwd: string, ): Promise { const changedFiles = await getGitChangedFiles(cwd) - - // Map route paths to potential file paths (heuristic) + return routes.filter(route => { + // Prefer precise source file match + if (route.sourceFile) { + return changedFiles.some(file => + route.sourceFile!.endsWith(file) || file.endsWith(route.sourceFile!) + ) + } + // Fallback: heuristic path-segment match const routePath = route.path - // Check if any changed file might contain this route - return changedFiles.some(file => { - // Simple heuristic: check if route path segments appear in file path - const segments = routePath.split('/').filter(Boolean) - return segments.some(segment => file.includes(segment)) - }) + const segments = routePath.split('/').filter(Boolean) + return changedFiles.some(file => + segments.some(segment => file.includes(segment)) + ) }) } diff --git a/src/domain/discovery.ts b/src/domain/discovery.ts index 55de4c8..0a15a0c 100644 --- a/src/domain/discovery.ts +++ b/src/domain/discovery.ts @@ -13,6 +13,7 @@ interface CapturedRoute { url: string schema?: Record prefix?: string + sourceFile?: string } export interface DiscoveryResult { @@ -24,6 +25,26 @@ export interface DiscoveryResult { // WeakMap to store captured routes per Fastify instance (no memory leaks) const capturedRoutes = new WeakMap() + +/** + * Extract the source file that registered a route from a stack trace. + * Finds the first frame outside node_modules and APOPHIS internal directories. + */ +function extractSourceFile(stack: string): string | undefined { + const lines = stack.split('\n') + for (let i = 3; i < lines.length; i++) { + const line = lines[i] + if (!line) continue + const match = line.match(/\((.*?):\d+:\d+\)/) ?? line.match(/at\s+(.*?):\d+:\d+/) + if (!match || !match[1]) continue + const filePath = match[1] + if (!filePath.includes('node_modules') && !filePath.includes('/apophis/') && !filePath.includes('\\apophis\\')) { + return filePath + } + } + return undefined +} + /** * Capture a route for discovery. * Called from the plugin's `onRoute` hook. @@ -32,6 +53,7 @@ export const captureRoute = ( instance: object, route: CapturedRoute ): void => { + route.sourceFile = extractSourceFile(new Error().stack ?? '') const existing = capturedRoutes.get(instance) ?? [] existing.push(route) capturedRoutes.set(instance, existing) @@ -140,9 +162,11 @@ export const discoverRouteDetails = (instance: DiscoverRouteDetailsInput): Disco const captured = capturedRoutes.get(instance) if (captured && captured.length > 0) { return { - routes: captured.map((route) => - extractContract(route.url, route.method, route.schema) - ), + routes: captured.map((route) => { + const contract = extractContract(route.url, route.method, route.schema) + contract.sourceFile = route.sourceFile + return contract + }), source: 'captured', hasSchemaMetadata: true, warnings: [], diff --git a/src/types/core.ts b/src/types/core.ts index 92854e5..b48a8b1 100644 --- a/src/types/core.ts +++ b/src/types/core.ts @@ -41,6 +41,8 @@ export interface RouteContract { schema?: Record /** Per-route timeout in milliseconds, extracted from schema['x-timeout']. Overrides global/plugin timeout. */ timeout?: number + /** Source file that registered this route (captured from stack trace during discovery). */ + sourceFile?: string /** Outbound dependency contracts for this route. Extracted from schema['x-outbound'] and normalized. */ outbound?: readonly OutboundBinding[] /** Route-level variants for negotiated content-type or feature testing. Extracted from schema['x-variants']. */