feat: precise --changed via stack-trace source file tracking; OTel sink docs

This commit is contained in:
John Dvorak
2026-05-22 15:00:04 -07:00
parent dab14ef77d
commit 6331933388
5 changed files with 111 additions and 18 deletions
+66 -5
View File
@@ -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
+1 -1
View File
@@ -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
+15 -9
View File
@@ -185,23 +185,29 @@ export async function getGitChangedFiles(cwd: string): Promise<string[]> {
}
/**
* 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<RouteContract[]> {
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))
)
})
}
+27 -3
View File
@@ -13,6 +13,7 @@ interface CapturedRoute {
url: string
schema?: Record<string, unknown>
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<object, CapturedRoute[]>()
/**
* 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: [],
+2
View File
@@ -41,6 +41,8 @@ export interface RouteContract {
schema?: Record<string, unknown>
/** 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']. */