From b7ff011191330d7a9718f61097dd51a5e8e8fd3e Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 8 Feb 2026 16:28:20 +0000 Subject: [PATCH 1/5] Add evlog enrich hook and enrichers --- README.md | 38 ++++ packages/evlog/README.md | 38 ++++ packages/evlog/build.config.ts | 1 + packages/evlog/package.json | 7 + packages/evlog/src/enrichers/index.ts | 206 ++++++++++++++++++ packages/evlog/src/index.ts | 1 + packages/evlog/src/nitro/plugin.ts | 79 ++++++- .../server/routes/_evlog/ingest.post.ts | 31 ++- packages/evlog/src/types.ts | 35 +++ packages/evlog/test/enrichers.test.ts | 79 +++++++ 10 files changed, 505 insertions(+), 10 deletions(-) create mode 100644 packages/evlog/src/enrichers/index.ts create mode 100644 packages/evlog/test/enrichers.test.ts diff --git a/README.md b/README.md index 236b2ed..c1ebcb2 100644 --- a/README.md +++ b/README.md @@ -390,6 +390,44 @@ Notes: - `request.cf` is included (colo, country, asn) unless disabled - Use `headerAllowlist` to avoid logging sensitive headers +## Enrichment Hook + +Use the `evlog:enrich` hook to add derived context after emit, before drain. + +```typescript +// server/plugins/evlog-enrich.ts +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('evlog:enrich', (ctx) => { + ctx.event.deploymentId = process.env.DEPLOYMENT_ID + }) +}) +``` + +### Built-in Enrichers + +```typescript +// server/plugins/evlog-enrich.ts +import { + createGeoEnricher, + createRequestSizeEnricher, + createTraceContextEnricher, + createUserAgentEnricher, +} from 'evlog/enrichers' + +export default defineNitroPlugin((nitroApp) => { + const enrich = [ + createUserAgentEnricher(), + createGeoEnricher(), + createRequestSizeEnricher(), + createTraceContextEnricher(), + ] + + nitroApp.hooks.hook('evlog:enrich', (ctx) => { + for (const enricher of enrich) enricher(ctx) + }) +}) +``` + ## Adapters Send your logs to external observability platforms with built-in adapters. diff --git a/packages/evlog/README.md b/packages/evlog/README.md index 236b2ed..c1ebcb2 100644 --- a/packages/evlog/README.md +++ b/packages/evlog/README.md @@ -390,6 +390,44 @@ Notes: - `request.cf` is included (colo, country, asn) unless disabled - Use `headerAllowlist` to avoid logging sensitive headers +## Enrichment Hook + +Use the `evlog:enrich` hook to add derived context after emit, before drain. + +```typescript +// server/plugins/evlog-enrich.ts +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('evlog:enrich', (ctx) => { + ctx.event.deploymentId = process.env.DEPLOYMENT_ID + }) +}) +``` + +### Built-in Enrichers + +```typescript +// server/plugins/evlog-enrich.ts +import { + createGeoEnricher, + createRequestSizeEnricher, + createTraceContextEnricher, + createUserAgentEnricher, +} from 'evlog/enrichers' + +export default defineNitroPlugin((nitroApp) => { + const enrich = [ + createUserAgentEnricher(), + createGeoEnricher(), + createRequestSizeEnricher(), + createTraceContextEnricher(), + ] + + nitroApp.hooks.hook('evlog:enrich', (ctx) => { + for (const enricher of enrich) enricher(ctx) + }) +}) +``` + ## Adapters Send your logs to external observability platforms with built-in adapters. diff --git a/packages/evlog/build.config.ts b/packages/evlog/build.config.ts index 4ab16b9..974f1f5 100644 --- a/packages/evlog/build.config.ts +++ b/packages/evlog/build.config.ts @@ -20,6 +20,7 @@ export default defineBuildConfig({ { input: 'src/adapters/otlp', name: 'adapters/otlp' }, { input: 'src/adapters/posthog', name: 'adapters/posthog' }, { input: 'src/adapters/sentry', name: 'adapters/sentry' }, + { input: 'src/enrichers/index', name: 'enrichers' }, ], declaration: true, clean: true, diff --git a/packages/evlog/package.json b/packages/evlog/package.json index 4c8caf6..d37b8ec 100644 --- a/packages/evlog/package.json +++ b/packages/evlog/package.json @@ -54,6 +54,10 @@ "./sentry": { "types": "./dist/adapters/sentry.d.mts", "import": "./dist/adapters/sentry.mjs" + }, + "./enrichers": { + "types": "./dist/enrichers.d.mts", + "import": "./dist/enrichers.mjs" } }, "main": "./dist/index.mjs", @@ -83,6 +87,9 @@ ], "sentry": [ "./dist/adapters/sentry.d.mts" + ], + "enrichers": [ + "./dist/enrichers.d.mts" ] } }, diff --git a/packages/evlog/src/enrichers/index.ts b/packages/evlog/src/enrichers/index.ts new file mode 100644 index 0000000..58f70b1 --- /dev/null +++ b/packages/evlog/src/enrichers/index.ts @@ -0,0 +1,206 @@ +import type { EnrichContext } from '../types' + +export interface EnricherOptions { + /** + * When true, overwrite any existing fields in the event. + * Defaults to false to preserve user-provided data. + */ + overwrite?: boolean +} + +interface UserAgentInfo { + raw: string + browser?: { name: string; version?: string } + os?: { name: string; version?: string } + device?: { type: 'mobile' | 'tablet' | 'desktop' | 'bot' | 'unknown' } +} + +interface GeoInfo { + country?: string + region?: string + regionCode?: string + city?: string + latitude?: number + longitude?: number +} + +interface RequestSizeInfo { + requestBytes?: number + responseBytes?: number +} + +interface TraceContextInfo { + traceparent?: string + tracestate?: string + traceId?: string + spanId?: string +} + +function getHeader(headers: Record | undefined, name: string): string | undefined { + if (!headers) return undefined + if (headers[name]) return headers[name] + const lowerName = name.toLowerCase() + if (headers[lowerName]) return headers[lowerName] + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === lowerName) return value + } + return undefined +} + +function parseUserAgent(ua: string): UserAgentInfo { + const lower = ua.toLowerCase() + + let deviceType: UserAgentInfo['device'] = { type: 'unknown' } + if (/bot|crawl|spider|slurp|bingpreview/.test(lower)) { + deviceType = { type: 'bot' } + } else if (/ipad|tablet/.test(lower)) { + deviceType = { type: 'tablet' } + } else if (/mobi|iphone|android/.test(lower)) { + deviceType = { type: 'mobile' } + } else if (ua.length > 0) { + deviceType = { type: 'desktop' } + } + + const browserMatchers: Array<{ name: string, regex: RegExp }> = [ + { name: 'Edge', regex: /edg\/([\d.]+)/i }, + { name: 'Chrome', regex: /chrome\/([\d.]+)/i }, + { name: 'Firefox', regex: /firefox\/([\d.]+)/i }, + { name: 'Safari', regex: /version\/([\d.]+).*safari/i }, + ] + + let browser: UserAgentInfo['browser'] + for (const matcher of browserMatchers) { + const match = ua.match(matcher.regex) + if (match) { + browser = { name: matcher.name, version: match[1] } + break + } + } + + let os: UserAgentInfo['os'] + if (/windows nt/i.test(ua)) { + const match = ua.match(/windows nt ([\d.]+)/i) + os = { name: 'Windows', version: match?.[1] } + } else if (/mac os x/i.test(ua) && !/iphone|ipad|ipod/i.test(ua)) { + const match = ua.match(/mac os x ([\d_]+)/i) + os = { name: 'macOS', version: match?.[1]?.replace(/_/g, '.') } + } else if (/iphone|ipad|ipod/i.test(ua)) { + const match = ua.match(/os ([\d_]+)/i) + os = { name: 'iOS', version: match?.[1]?.replace(/_/g, '.') } + } else if (/android/i.test(ua)) { + const match = ua.match(/android ([\d.]+)/i) + os = { name: 'Android', version: match?.[1] } + } else if (/linux/i.test(ua)) { + os = { name: 'Linux' } + } + + return { + raw: ua, + browser, + os, + device: deviceType, + } +} + +function parseTraceparent(traceparent: string): Pick | undefined { + const match = traceparent.match(/^[\da-f]{2}-([\da-f]{32})-([\da-f]{16})-[\da-f]{2}$/i) + if (!match) return undefined + return { traceId: match[1], spanId: match[2] } +} + +function mergeEventField>( + existing: unknown, + computed: T, + overwrite?: boolean, +): T { + if (overwrite || existing === undefined || existing === null || typeof existing !== 'object') { + return computed + } + return { ...computed, ...(existing as T) } +} + +function normalizeNumber(value: string | undefined): number | undefined { + if (!value) return undefined + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : undefined +} + +/** + * Enrich events with parsed user agent data. + */ +export function createUserAgentEnricher(options: EnricherOptions = {}): (ctx: EnrichContext) => void { + return (ctx) => { + const ua = getHeader(ctx.headers, 'user-agent') + if (!ua) return + const info = parseUserAgent(ua) + ctx.event.userAgent = mergeEventField(ctx.event.userAgent, info, options.overwrite) + } +} + +/** + * Enrich events with geo data from Cloudflare/Vercel headers. + */ +export function createGeoEnricher(options: EnricherOptions = {}): (ctx: EnrichContext) => void { + return (ctx) => { + const headers = ctx.headers + if (!headers) return + + const geo: GeoInfo = { + country: getHeader(headers, 'cf-ipcountry') ?? getHeader(headers, 'x-vercel-ip-country'), + region: getHeader(headers, 'cf-region') ?? getHeader(headers, 'x-vercel-ip-country-region'), + regionCode: getHeader(headers, 'cf-region-code') ?? getHeader(headers, 'x-vercel-ip-country-region-code'), + city: getHeader(headers, 'cf-city') ?? getHeader(headers, 'x-vercel-ip-city'), + latitude: normalizeNumber(getHeader(headers, 'cf-latitude') ?? getHeader(headers, 'x-vercel-ip-latitude')), + longitude: normalizeNumber(getHeader(headers, 'cf-longitude') ?? getHeader(headers, 'x-vercel-ip-longitude')), + } + + if (Object.values(geo).every(value => value === undefined)) return + ctx.event.geo = mergeEventField(ctx.event.geo, geo, options.overwrite) + } +} + +/** + * Enrich events with request/response payload sizes. + */ +export function createRequestSizeEnricher(options: EnricherOptions = {}): (ctx: EnrichContext) => void { + return (ctx) => { + const requestBytes = normalizeNumber(getHeader(ctx.headers, 'content-length')) + const responseBytes = normalizeNumber(getHeader(ctx.response?.headers, 'content-length')) + + const sizes: RequestSizeInfo = { + requestBytes, + responseBytes, + } + + if (requestBytes === undefined && responseBytes === undefined) return + ctx.event.requestSize = mergeEventField(ctx.event.requestSize, sizes, options.overwrite) + } +} + +/** + * Enrich events with W3C trace context data. + */ +export function createTraceContextEnricher(options: EnricherOptions = {}): (ctx: EnrichContext) => void { + return (ctx) => { + const traceparent = getHeader(ctx.headers, 'traceparent') + const tracestate = getHeader(ctx.headers, 'tracestate') + if (!traceparent && !tracestate) return + + const parsed = traceparent ? parseTraceparent(traceparent) : undefined + const traceContext: TraceContextInfo = { + traceparent, + tracestate, + traceId: parsed?.traceId ?? (ctx.event.traceId as string | undefined), + spanId: parsed?.spanId ?? (ctx.event.spanId as string | undefined), + } + + if (traceContext.traceId && (options.overwrite || ctx.event.traceId === undefined)) { + ctx.event.traceId = traceContext.traceId + } + if (traceContext.spanId && (options.overwrite || ctx.event.spanId === undefined)) { + ctx.event.spanId = traceContext.spanId + } + + ctx.event.traceContext = mergeEventField(ctx.event.traceContext, traceContext, options.overwrite) + } +} diff --git a/packages/evlog/src/index.ts b/packages/evlog/src/index.ts index d20be58..1d2197e 100644 --- a/packages/evlog/src/index.ts +++ b/packages/evlog/src/index.ts @@ -6,6 +6,7 @@ export { parseError } from './runtime/utils/parseError' export type { BaseWideEvent, DrainContext, + EnrichContext, EnvironmentContext, ErrorOptions, H3EventContext, diff --git a/packages/evlog/src/nitro/plugin.ts b/packages/evlog/src/nitro/plugin.ts index 7c6521a..043b4b3 100644 --- a/packages/evlog/src/nitro/plugin.ts +++ b/packages/evlog/src/nitro/plugin.ts @@ -2,7 +2,7 @@ import type { NitroApp } from 'nitropack/types' import { defineNitroPlugin, useRuntimeConfig } from 'nitropack/runtime' import { getHeaders } from 'h3' import { createRequestLogger, initLogger } from '../logger' -import type { RequestLogger, RouteConfig, SamplingConfig, ServerEvent, TailSamplingContext, WideEvent } from '../types' +import type { EnrichContext, RequestLogger, RouteConfig, SamplingConfig, ServerEvent, TailSamplingContext, WideEvent } from '../types' import { matchesPattern } from '../utils' interface EvlogConfig { @@ -74,11 +74,10 @@ const SENSITIVE_HEADERS = [ 'proxy-authorization', ] -function getSafeHeaders(event: ServerEvent): Record { - const allHeaders = getHeaders(event as Parameters[0]) +function filterSafeHeaders(headers: Record): Record { const safeHeaders: Record = {} - for (const [key, value] of Object.entries(allHeaders)) { + for (const [key, value] of Object.entries(headers)) { if (!SENSITIVE_HEADERS.includes(key.toLowerCase())) { safeHeaders[key] = value } @@ -87,6 +86,32 @@ function getSafeHeaders(event: ServerEvent): Record { return safeHeaders } +function getSafeHeaders(event: ServerEvent): Record { + const allHeaders = getHeaders(event as Parameters[0]) + return filterSafeHeaders(allHeaders) +} + +function getSafeResponseHeaders(event: ServerEvent): Record | undefined { + const headers: Record = {} + const nodeRes = event.node?.res as { getHeaders?: () => Record } | undefined + + if (nodeRes?.getHeaders) { + for (const [key, value] of Object.entries(nodeRes.getHeaders())) { + if (value === undefined) continue + headers[key] = Array.isArray(value) ? value.join(', ') : String(value) + } + } + + if (event.response?.headers) { + event.response.headers.forEach((value, key) => { + headers[key] = value + }) + } + + if (Object.keys(headers).length === 0) return undefined + return filterSafeHeaders(headers) +} + function getResponseStatus(event: ServerEvent): number { // Node.js style if (event.node?.res?.statusCode) { @@ -106,13 +131,31 @@ function getResponseStatus(event: ServerEvent): number { return 200 } -function callDrainHook(nitroApp: NitroApp, emittedEvent: WideEvent | null, event: ServerEvent): void { +function buildHookContext(event: ServerEvent): Omit { + const responseHeaders = getSafeResponseHeaders(event) + return { + request: { method: event.method, path: event.path, requestId: event.context.requestId as string | undefined }, + headers: getSafeHeaders(event), + response: { + status: getResponseStatus(event), + headers: responseHeaders, + }, + } +} + +function callDrainHook( + nitroApp: NitroApp, + emittedEvent: WideEvent | null, + event: ServerEvent, + request: EnrichContext['request'], + headers: EnrichContext['headers'], +): void { if (!emittedEvent) return const drainPromise = nitroApp.hooks.callHook('evlog:drain', { event: emittedEvent, - request: { method: event.method, path: event.path, requestId: event.context.requestId as string | undefined }, - headers: getSafeHeaders(event), + request, + headers, }).catch((err) => { console.error('[evlog] drain failed:', err) }) @@ -125,6 +168,24 @@ function callDrainHook(nitroApp: NitroApp, emittedEvent: WideEvent | null, event } } +async function callEnrichAndDrain( + nitroApp: NitroApp, + emittedEvent: WideEvent | null, + event: ServerEvent, +): Promise { + if (!emittedEvent) return + + const hookContext = buildHookContext(event) + + try { + await nitroApp.hooks.callHook('evlog:enrich', { event: emittedEvent, ...hookContext }) + } catch (err) { + console.error('[evlog] enrich failed:', err) + } + + callDrainHook(nitroApp, emittedEvent, event, hookContext.request, hookContext.headers) +} + export default defineNitroPlugin((nitroApp) => { const config = useRuntimeConfig() const evlogConfig = config.evlog as EvlogConfig | undefined @@ -198,7 +259,7 @@ export default defineNitroPlugin((nitroApp) => { e.context._evlogEmitted = true const emittedEvent = log.emit({ _forceKeep: tailCtx.shouldKeep }) - callDrainHook(nitroApp, emittedEvent, e) + await callEnrichAndDrain(nitroApp, emittedEvent, e) } }) @@ -227,7 +288,7 @@ export default defineNitroPlugin((nitroApp) => { await nitroApp.hooks.callHook('evlog:emit:keep', tailCtx) const emittedEvent = log.emit({ _forceKeep: tailCtx.shouldKeep }) - callDrainHook(nitroApp, emittedEvent, e) + await callEnrichAndDrain(nitroApp, emittedEvent, e) } }) }) diff --git a/packages/evlog/src/runtime/server/routes/_evlog/ingest.post.ts b/packages/evlog/src/runtime/server/routes/_evlog/ingest.post.ts index 7dc6594..bda8304 100644 --- a/packages/evlog/src/runtime/server/routes/_evlog/ingest.post.ts +++ b/packages/evlog/src/runtime/server/routes/_evlog/ingest.post.ts @@ -1,4 +1,4 @@ -import { createError, defineEventHandler, getHeader, getRequestHost, readBody, setResponseStatus } from 'h3' +import { createError, defineEventHandler, getHeader, getHeaders, getRequestHost, readBody, setResponseStatus } from 'h3' import { useNitroApp } from 'nitropack/runtime' import type { IngestPayload, WideEvent } from '../../../../types' import { getEnvironment } from '../../../../logger' @@ -76,6 +76,28 @@ function validatePayload(body: unknown): IngestPayload { } } +const SENSITIVE_HEADERS = [ + 'authorization', + 'cookie', + 'set-cookie', + 'x-api-key', + 'x-auth-token', + 'proxy-authorization', +] + +function getSafeHeaders(event: Parameters[0] extends (e: infer E) => unknown ? E : never): Record { + const allHeaders = getHeaders(event as Parameters[0]) + const safeHeaders: Record = {} + + for (const [key, value] of Object.entries(allHeaders)) { + if (!SENSITIVE_HEADERS.includes(key.toLowerCase())) { + safeHeaders[key] = value + } + } + + return safeHeaders +} + export default defineEventHandler(async (event) => { validateOrigin(event) @@ -93,6 +115,13 @@ export default defineEventHandler(async (event) => { } try { + await nitroApp.hooks.callHook('evlog:enrich', { + event: wideEvent, + request: { method: 'POST', path: event.path }, + headers: getSafeHeaders(event), + response: { status: 204 }, + }) + await nitroApp.hooks.callHook('evlog:drain', { event: wideEvent, request: { method: 'POST', path: event.path }, diff --git a/packages/evlog/src/types.ts b/packages/evlog/src/types.ts index 1036dc9..d599951 100644 --- a/packages/evlog/src/types.ts +++ b/packages/evlog/src/types.ts @@ -17,6 +17,19 @@ declare module 'nitropack/types' { */ 'evlog:emit:keep': (ctx: TailSamplingContext) => void | Promise + /** + * Enrichment hook - called after emit, before drain. + * Use this to enrich the event with derived context (e.g. geo, user agent). + * + * @example + * ```ts + * nitroApp.hooks.hook('evlog:enrich', (ctx) => { + * ctx.event.deploymentId = process.env.DEPLOYMENT_ID + * }) + * ``` + */ + 'evlog:enrich': (ctx: EnrichContext) => void | Promise + /** * Drain hook - called after emitting a log (fire-and-forget). * Use this to send logs to external services like Axiom, Loki, or custom endpoints. @@ -112,6 +125,28 @@ export interface TailSamplingContext { shouldKeep?: boolean } +/** + * Context passed to the evlog:enrich hook. + * Called after emit, before drain. + */ +export interface EnrichContext { + /** The emitted wide event (mutable). */ + event: WideEvent + /** Request metadata (if available) */ + request?: { + method?: string + path?: string + requestId?: string + } + /** Safe HTTP request headers (sensitive headers filtered out) */ + headers?: Record + /** Optional response metadata */ + response?: { + status?: number + headers?: Record + } +} + /** * Context passed to the evlog:drain hook. * Contains the complete wide event and request metadata for external transport. diff --git a/packages/evlog/test/enrichers.test.ts b/packages/evlog/test/enrichers.test.ts new file mode 100644 index 0000000..40063ad --- /dev/null +++ b/packages/evlog/test/enrichers.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import type { EnrichContext, WideEvent } from '../src/types' +import { createGeoEnricher, createRequestSizeEnricher, createTraceContextEnricher, createUserAgentEnricher } from '../src/enrichers' + +function createContext(headers: Record, responseHeaders?: Record): EnrichContext { + const event: WideEvent = { + timestamp: new Date().toISOString(), + level: 'info', + service: 'test', + environment: 'test', + } + + return { + event, + headers, + response: responseHeaders ? { headers: responseHeaders, status: 200 } : undefined, + } +} + +describe('enrichers', () => { + it('adds user agent info', () => { + const ctx = createContext({ + 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 13_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15', + }) + + createUserAgentEnricher()(ctx) + + expect(ctx.event.userAgent).toBeDefined() + expect((ctx.event.userAgent as { browser?: { name: string } }).browser?.name).toBe('Safari') + }) + + it('adds geo info from cloud headers', () => { + const ctx = createContext({ + 'cf-ipcountry': 'FR', + 'cf-region': 'Île-de-France', + 'cf-region-code': 'IDF', + 'cf-city': 'Paris', + }) + + createGeoEnricher()(ctx) + + expect(ctx.event.geo).toMatchObject({ + country: 'FR', + region: 'Île-de-France', + regionCode: 'IDF', + city: 'Paris', + }) + }) + + it('adds request/response size info', () => { + const ctx = createContext( + { 'content-length': '512' }, + { 'content-length': '1024' }, + ) + + createRequestSizeEnricher()(ctx) + + expect(ctx.event.requestSize).toMatchObject({ + requestBytes: 512, + responseBytes: 1024, + }) + }) + + it('adds trace context data', () => { + const ctx = createContext({ + traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', + tracestate: 'congo=t61rcWkgMzE', + }) + + createTraceContextEnricher()(ctx) + + expect(ctx.event.traceId).toBe('0af7651916cd43dd8448eb211c80319c') + expect(ctx.event.spanId).toBe('b7ad6b7169203331') + expect(ctx.event.traceContext).toMatchObject({ + traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', + tracestate: 'congo=t61rcWkgMzE', + }) + }) +}) From a9cab86998ac005adbb01ae2a96cea2dd9fcc700 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 8 Feb 2026 16:28:47 +0000 Subject: [PATCH 2/5] chore: apply automated lint fixes --- packages/evlog/src/enrichers/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/evlog/src/enrichers/index.ts b/packages/evlog/src/enrichers/index.ts index 58f70b1..686dbfb 100644 --- a/packages/evlog/src/enrichers/index.ts +++ b/packages/evlog/src/enrichers/index.ts @@ -142,7 +142,7 @@ export function createUserAgentEnricher(options: EnricherOptions = {}): (ctx: En */ export function createGeoEnricher(options: EnricherOptions = {}): (ctx: EnrichContext) => void { return (ctx) => { - const headers = ctx.headers + const { headers } = ctx if (!headers) return const geo: GeoInfo = { From 7cc74d05967089c5a7a4e23f126f6406d0fe5be4 Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Sun, 8 Feb 2026 17:11:11 +0000 Subject: [PATCH 3/5] up --- README.md | 52 +++ .../playground/server/plugins/evlog-enrich.ts | 17 + packages/evlog/src/enrichers/index.ts | 39 +- packages/evlog/src/nitro/plugin.ts | 26 +- .../server/routes/_evlog/ingest.post.ts | 49 ++- packages/evlog/src/utils.ts | 22 ++ packages/evlog/test/enrichers.test.ts | 351 +++++++++++++++++- packages/evlog/test/nitro-plugin.test.ts | 262 +++++++++++-- 8 files changed, 729 insertions(+), 89 deletions(-) create mode 100644 apps/playground/server/plugins/evlog-enrich.ts diff --git a/README.md b/README.md index c1ebcb2..8dcf114 100644 --- a/README.md +++ b/README.md @@ -428,6 +428,58 @@ export default defineNitroPlugin((nitroApp) => { }) ``` +Each enricher adds a specific field to the event: + +| Enricher | Event Field | Shape | +|----------|-------------|-------| +| `createUserAgentEnricher()` | `event.userAgent` | `{ raw, browser?: { name, version? }, os?: { name, version? }, device?: { type } }` | +| `createGeoEnricher()` | `event.geo` | `{ country?, region?, regionCode?, city?, latitude?, longitude? }` | +| `createRequestSizeEnricher()` | `event.requestSize` | `{ requestBytes?, responseBytes? }` | +| `createTraceContextEnricher()` | `event.traceContext` + `event.traceId` + `event.spanId` | `{ traceparent?, tracestate?, traceId?, spanId? }` | + +All enrichers accept an optional `{ overwrite?: boolean }` option. By default (`overwrite: false`), user-provided data on the event takes precedence over enricher-computed values. Set `overwrite: true` to always replace existing fields. + +> **Cloudflare geo note:** Only `cf-ipcountry` is a real Cloudflare HTTP header. The `cf-region`, `cf-city`, `cf-latitude`, `cf-longitude` headers are NOT standard — they are properties of `request.cf`. For full geo data on Cloudflare, write a custom enricher that reads `request.cf`, or use a Workers middleware to forward `cf` properties as custom headers. + +### Custom Enrichers + +The `evlog:enrich` hook receives an `EnrichContext` with these fields: + +```typescript +interface EnrichContext { + event: WideEvent // The emitted wide event (mutable — modify it directly) + request?: { // Request metadata + method?: string + path?: string + requestId?: string + } + headers?: Record // Safe HTTP headers (sensitive headers filtered) + response?: { // Response metadata + status?: number + headers?: Record + } +} +``` + +Example custom enricher: + +```typescript +// server/plugins/evlog-enrich.ts +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('evlog:enrich', (ctx) => { + // Add deployment metadata + ctx.event.deploymentId = process.env.DEPLOYMENT_ID + ctx.event.region = process.env.FLY_REGION + + // Extract data from headers + const tenantId = ctx.headers?.['x-tenant-id'] + if (tenantId) { + ctx.event.tenantId = tenantId + } + }) +}) +``` + ## Adapters Send your logs to external observability platforms with built-in adapters. diff --git a/apps/playground/server/plugins/evlog-enrich.ts b/apps/playground/server/plugins/evlog-enrich.ts new file mode 100644 index 0000000..72a065f --- /dev/null +++ b/apps/playground/server/plugins/evlog-enrich.ts @@ -0,0 +1,17 @@ +import { createRequestSizeEnricher, createUserAgentEnricher } from 'evlog/enrichers' + +export default defineNitroPlugin((nitroApp) => { + const enrichers = [ + createUserAgentEnricher(), + createRequestSizeEnricher(), + ] + + nitroApp.hooks.hook('evlog:enrich', (ctx) => { + for (const enricher of enrichers) enricher(ctx) + + ctx.event.playground = { + name: 'nuxt-playground', + enrichedAt: new Date().toISOString(), + } + }) +}) diff --git a/packages/evlog/src/enrichers/index.ts b/packages/evlog/src/enrichers/index.ts index 686dbfb..e6181ac 100644 --- a/packages/evlog/src/enrichers/index.ts +++ b/packages/evlog/src/enrichers/index.ts @@ -8,14 +8,14 @@ export interface EnricherOptions { overwrite?: boolean } -interface UserAgentInfo { +export interface UserAgentInfo { raw: string browser?: { name: string; version?: string } os?: { name: string; version?: string } device?: { type: 'mobile' | 'tablet' | 'desktop' | 'bot' | 'unknown' } } -interface GeoInfo { +export interface GeoInfo { country?: string region?: string regionCode?: string @@ -24,12 +24,12 @@ interface GeoInfo { longitude?: number } -interface RequestSizeInfo { +export interface RequestSizeInfo { requestBytes?: number responseBytes?: number } -interface TraceContextInfo { +export interface TraceContextInfo { traceparent?: string tracestate?: string traceId?: string @@ -38,9 +38,9 @@ interface TraceContextInfo { function getHeader(headers: Record | undefined, name: string): string | undefined { if (!headers) return undefined - if (headers[name]) return headers[name] + if (headers[name] !== undefined) return headers[name] const lowerName = name.toLowerCase() - if (headers[lowerName]) return headers[lowerName] + if (headers[lowerName] !== undefined) return headers[lowerName] for (const [key, value] of Object.entries(headers)) { if (key.toLowerCase() === lowerName) return value } @@ -127,6 +127,7 @@ function normalizeNumber(value: string | undefined): number | undefined { /** * Enrich events with parsed user agent data. + * Sets `event.userAgent` with `UserAgentInfo` shape: `{ raw, browser?, os?, device? }`. */ export function createUserAgentEnricher(options: EnricherOptions = {}): (ctx: EnrichContext) => void { return (ctx) => { @@ -138,7 +139,16 @@ export function createUserAgentEnricher(options: EnricherOptions = {}): (ctx: En } /** - * Enrich events with geo data from Cloudflare/Vercel headers. + * Enrich events with geo data from platform headers. + * Sets `event.geo` with `GeoInfo` shape: `{ country?, region?, regionCode?, city?, latitude?, longitude? }`. + * + * Supports Vercel (`x-vercel-ip-*`) headers out of the box. + * + * **Cloudflare note:** Only `cf-ipcountry` is an actual HTTP header added by Cloudflare. + * The `cf-region`, `cf-city`, `cf-latitude`, `cf-longitude` headers are NOT standard + * Cloudflare headers — they are properties of `request.cf` which is not exposed as HTTP + * headers. For full geo data on Cloudflare, write a custom enricher that reads `request.cf` + * or use a Workers middleware to copy `cf` properties into custom headers. */ export function createGeoEnricher(options: EnricherOptions = {}): (ctx: EnrichContext) => void { return (ctx) => { @@ -146,12 +156,12 @@ export function createGeoEnricher(options: EnricherOptions = {}): (ctx: EnrichCo if (!headers) return const geo: GeoInfo = { - country: getHeader(headers, 'cf-ipcountry') ?? getHeader(headers, 'x-vercel-ip-country'), - region: getHeader(headers, 'cf-region') ?? getHeader(headers, 'x-vercel-ip-country-region'), - regionCode: getHeader(headers, 'cf-region-code') ?? getHeader(headers, 'x-vercel-ip-country-region-code'), - city: getHeader(headers, 'cf-city') ?? getHeader(headers, 'x-vercel-ip-city'), - latitude: normalizeNumber(getHeader(headers, 'cf-latitude') ?? getHeader(headers, 'x-vercel-ip-latitude')), - longitude: normalizeNumber(getHeader(headers, 'cf-longitude') ?? getHeader(headers, 'x-vercel-ip-longitude')), + country: getHeader(headers, 'x-vercel-ip-country') ?? getHeader(headers, 'cf-ipcountry'), + region: getHeader(headers, 'x-vercel-ip-country-region') ?? getHeader(headers, 'cf-region'), + regionCode: getHeader(headers, 'x-vercel-ip-country-region-code') ?? getHeader(headers, 'cf-region-code'), + city: getHeader(headers, 'x-vercel-ip-city') ?? getHeader(headers, 'cf-city'), + latitude: normalizeNumber(getHeader(headers, 'x-vercel-ip-latitude') ?? getHeader(headers, 'cf-latitude')), + longitude: normalizeNumber(getHeader(headers, 'x-vercel-ip-longitude') ?? getHeader(headers, 'cf-longitude')), } if (Object.values(geo).every(value => value === undefined)) return @@ -161,6 +171,7 @@ export function createGeoEnricher(options: EnricherOptions = {}): (ctx: EnrichCo /** * Enrich events with request/response payload sizes. + * Sets `event.requestSize` with `RequestSizeInfo` shape: `{ requestBytes?, responseBytes? }`. */ export function createRequestSizeEnricher(options: EnricherOptions = {}): (ctx: EnrichContext) => void { return (ctx) => { @@ -179,6 +190,8 @@ export function createRequestSizeEnricher(options: EnricherOptions = {}): (ctx: /** * Enrich events with W3C trace context data. + * Sets `event.traceContext` with `TraceContextInfo` shape: `{ traceparent?, tracestate?, traceId?, spanId? }`. + * Also sets `event.traceId` and `event.spanId` at the top level. */ export function createTraceContextEnricher(options: EnricherOptions = {}): (ctx: EnrichContext) => void { return (ctx) => { diff --git a/packages/evlog/src/nitro/plugin.ts b/packages/evlog/src/nitro/plugin.ts index 043b4b3..6a345b5 100644 --- a/packages/evlog/src/nitro/plugin.ts +++ b/packages/evlog/src/nitro/plugin.ts @@ -3,7 +3,7 @@ import { defineNitroPlugin, useRuntimeConfig } from 'nitropack/runtime' import { getHeaders } from 'h3' import { createRequestLogger, initLogger } from '../logger' import type { EnrichContext, RequestLogger, RouteConfig, SamplingConfig, ServerEvent, TailSamplingContext, WideEvent } from '../types' -import { matchesPattern } from '../utils' +import { filterSafeHeaders, matchesPattern } from '../utils' interface EvlogConfig { env?: Record @@ -64,28 +64,6 @@ function getServiceForPath(path: string, routes?: Record): return undefined } -/** Headers that should never be passed to the drain hook for security */ -const SENSITIVE_HEADERS = [ - 'authorization', - 'cookie', - 'set-cookie', - 'x-api-key', - 'x-auth-token', - 'proxy-authorization', -] - -function filterSafeHeaders(headers: Record): Record { - const safeHeaders: Record = {} - - for (const [key, value] of Object.entries(headers)) { - if (!SENSITIVE_HEADERS.includes(key.toLowerCase())) { - safeHeaders[key] = value - } - } - - return safeHeaders -} - function getSafeHeaders(event: ServerEvent): Record { const allHeaders = getHeaders(event as Parameters[0]) return filterSafeHeaders(allHeaders) @@ -209,7 +187,7 @@ export default defineNitroPlugin((nitroApp) => { let requestIdOverride: string | undefined = undefined if (globalThis.navigator?.userAgent === 'Cloudflare-Workers') { - const cfRay = getSafeHeaders(event)?.['cf-ray'] + const cfRay = getSafeHeaders(e)?.['cf-ray'] if (cfRay) requestIdOverride = cfRay } diff --git a/packages/evlog/src/runtime/server/routes/_evlog/ingest.post.ts b/packages/evlog/src/runtime/server/routes/_evlog/ingest.post.ts index bda8304..bf8b5c0 100644 --- a/packages/evlog/src/runtime/server/routes/_evlog/ingest.post.ts +++ b/packages/evlog/src/runtime/server/routes/_evlog/ingest.post.ts @@ -2,6 +2,7 @@ import { createError, defineEventHandler, getHeader, getHeaders, getRequestHost, import { useNitroApp } from 'nitropack/runtime' import type { IngestPayload, WideEvent } from '../../../../types' import { getEnvironment } from '../../../../logger' +import { filterSafeHeaders } from '../../../../utils' const VALID_LEVELS = ['info', 'error', 'warn', 'debug'] as const @@ -76,26 +77,9 @@ function validatePayload(body: unknown): IngestPayload { } } -const SENSITIVE_HEADERS = [ - 'authorization', - 'cookie', - 'set-cookie', - 'x-api-key', - 'x-auth-token', - 'proxy-authorization', -] - function getSafeHeaders(event: Parameters[0] extends (e: infer E) => unknown ? E : never): Record { const allHeaders = getHeaders(event as Parameters[0]) - const safeHeaders: Record = {} - - for (const [key, value] of Object.entries(allHeaders)) { - if (!SENSITIVE_HEADERS.includes(key.toLowerCase())) { - safeHeaders[key] = value - } - } - - return safeHeaders + return filterSafeHeaders(allHeaders) } export default defineEventHandler(async (event) => { @@ -114,20 +98,33 @@ export default defineEventHandler(async (event) => { source: 'client', } + const headers = getSafeHeaders(event) + const request = { method: 'POST' as const, path: event.path } + try { await nitroApp.hooks.callHook('evlog:enrich', { event: wideEvent, - request: { method: 'POST', path: event.path }, - headers: getSafeHeaders(event), + request, + headers, response: { status: 204 }, }) + } catch (err) { + console.error('[evlog] enrich failed:', err) + } - await nitroApp.hooks.callHook('evlog:drain', { - event: wideEvent, - request: { method: 'POST', path: event.path }, - }) - } catch { - // Silently fail - don't break the client + const drainPromise = nitroApp.hooks.callHook('evlog:drain', { + event: wideEvent, + request, + headers, + }).catch((err) => { + console.error('[evlog] drain failed:', err) + }) + + // Use waitUntil if available (Cloudflare Workers, Vercel Edge) + const waitUntilCtx = (event as unknown as { context: Record }).context + const cfCtx = (waitUntilCtx as { cloudflare?: { context?: { waitUntil?: (p: Promise) => void } } }).cloudflare?.context ?? waitUntilCtx + if (typeof (cfCtx as { waitUntil?: unknown }).waitUntil === 'function') { + (cfCtx as { waitUntil: (p: Promise) => void }).waitUntil(drainPromise) } setResponseStatus(event, 204) diff --git a/packages/evlog/src/utils.ts b/packages/evlog/src/utils.ts index 312cb1d..84b9dd6 100644 --- a/packages/evlog/src/utils.ts +++ b/packages/evlog/src/utils.ts @@ -77,6 +77,28 @@ export function getLevelColor(level: string): string { } } +/** Headers that should never be passed to hooks for security */ +export const SENSITIVE_HEADERS = [ + 'authorization', + 'cookie', + 'set-cookie', + 'x-api-key', + 'x-auth-token', + 'proxy-authorization', +] + +export function filterSafeHeaders(headers: Record): Record { + const safeHeaders: Record = {} + + for (const [key, value] of Object.entries(headers)) { + if (!SENSITIVE_HEADERS.includes(key.toLowerCase())) { + safeHeaders[key] = value + } + } + + return safeHeaders +} + /** * Match a path against a glob pattern. * Supports * (any chars except /) and ** (any chars including /). diff --git a/packages/evlog/test/enrichers.test.ts b/packages/evlog/test/enrichers.test.ts index 40063ad..63aef79 100644 --- a/packages/evlog/test/enrichers.test.ts +++ b/packages/evlog/test/enrichers.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import type { EnrichContext, WideEvent } from '../src/types' +import type { GeoInfo, UserAgentInfo } from '../src/enrichers' import { createGeoEnricher, createRequestSizeEnricher, createTraceContextEnricher, createUserAgentEnricher } from '../src/enrichers' function createContext(headers: Record, responseHeaders?: Record): EnrichContext { @@ -26,7 +27,7 @@ describe('enrichers', () => { createUserAgentEnricher()(ctx) expect(ctx.event.userAgent).toBeDefined() - expect((ctx.event.userAgent as { browser?: { name: string } }).browser?.name).toBe('Safari') + expect((ctx.event.userAgent as UserAgentInfo).browser?.name).toBe('Safari') }) it('adds geo info from cloud headers', () => { @@ -77,3 +78,351 @@ describe('enrichers', () => { }) }) }) + +describe('enrichers - Vercel geo headers (T1)', () => { + it('adds geo info from Vercel headers', () => { + const ctx = createContext({ + 'x-vercel-ip-country': 'US', + 'x-vercel-ip-country-region': 'California', + 'x-vercel-ip-country-region-code': 'CA', + 'x-vercel-ip-city': 'San Francisco', + 'x-vercel-ip-latitude': '37.7749', + 'x-vercel-ip-longitude': '-122.4194', + }) + + createGeoEnricher()(ctx) + + expect(ctx.event.geo).toMatchObject({ + country: 'US', + region: 'California', + regionCode: 'CA', + city: 'San Francisco', + latitude: 37.7749, + longitude: -122.4194, + }) + }) + + it('prefers Vercel headers over Cloudflare when both present', () => { + const ctx = createContext({ + 'cf-ipcountry': 'FR', + 'x-vercel-ip-country': 'US', + }) + + createGeoEnricher()(ctx) + + expect((ctx.event.geo as GeoInfo).country).toBe('US') + }) +}) + +describe('enrichers - overwrite option (T2)', () => { + it('preserves existing data when overwrite is false (default)', () => { + const ctx = createContext({ + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36', + }) + + ctx.event.userAgent = { raw: 'custom', browser: { name: 'CustomBrowser', version: '1.0' } } + + createUserAgentEnricher()(ctx) + + expect((ctx.event.userAgent as UserAgentInfo).browser?.name).toBe('CustomBrowser') + }) + + it('overwrites existing data when overwrite is true', () => { + const ctx = createContext({ + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36', + }) + + ctx.event.userAgent = { raw: 'custom', browser: { name: 'CustomBrowser', version: '1.0' } } + + createUserAgentEnricher({ overwrite: true })(ctx) + + expect((ctx.event.userAgent as UserAgentInfo).browser?.name).toBe('Chrome') + }) + + it('preserves existing geo data by default', () => { + const ctx = createContext({ + 'cf-ipcountry': 'FR', + }) + + ctx.event.geo = { country: 'DE' } + + createGeoEnricher()(ctx) + + expect((ctx.event.geo as GeoInfo).country).toBe('DE') + }) + + it('overwrites existing geo data when overwrite is true', () => { + const ctx = createContext({ + 'cf-ipcountry': 'FR', + }) + + ctx.event.geo = { country: 'DE' } + + createGeoEnricher({ overwrite: true })(ctx) + + expect((ctx.event.geo as GeoInfo).country).toBe('FR') + }) +}) + +describe('enrichers - mergeEventField behavior (T3)', () => { + it('computed values are used when no existing data', () => { + const ctx = createContext({ + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36', + }) + + createUserAgentEnricher()(ctx) + + expect((ctx.event.userAgent as UserAgentInfo).browser?.name).toBe('Chrome') + expect((ctx.event.userAgent as UserAgentInfo).os?.name).toBe('Windows') + }) + + it('user data takes precedence over computed for overlapping keys', () => { + const ctx = createContext({ + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36', + }) + + ctx.event.userAgent = { raw: 'custom-raw', browser: { name: 'MyBrowser' } } + + createUserAgentEnricher()(ctx) + + const ua = ctx.event.userAgent as UserAgentInfo + expect(ua.browser?.name).toBe('MyBrowser') + expect(ua.raw).toBe('custom-raw') + }) + + it('replaces non-object existing values', () => { + const ctx = createContext({ + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36', + }) + + ctx.event.userAgent = 'not-an-object' + + createUserAgentEnricher()(ctx) + + expect(typeof ctx.event.userAgent).toBe('object') + expect((ctx.event.userAgent as UserAgentInfo).browser?.name).toBe('Chrome') + }) +}) + +describe('enrichers - browser detection (T4)', () => { + it('detects Chrome', () => { + const ctx = createContext({ + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + }) + + createUserAgentEnricher()(ctx) + + const ua = ctx.event.userAgent as UserAgentInfo + expect(ua.browser?.name).toBe('Chrome') + expect(ua.browser?.version).toBe('120.0.0.0') + }) + + it('detects Firefox', () => { + const ctx = createContext({ + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0', + }) + + createUserAgentEnricher()(ctx) + + const ua = ctx.event.userAgent as UserAgentInfo + expect(ua.browser?.name).toBe('Firefox') + expect(ua.browser?.version).toBe('121.0') + }) + + it('detects Edge', () => { + const ctx = createContext({ + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0', + }) + + createUserAgentEnricher()(ctx) + + const ua = ctx.event.userAgent as UserAgentInfo + expect(ua.browser?.name).toBe('Edge') + expect(ua.browser?.version).toBe('120.0.0.0') + }) + + it('detects Safari', () => { + const ctx = createContext({ + 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 13_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15', + }) + + createUserAgentEnricher()(ctx) + + const ua = ctx.event.userAgent as UserAgentInfo + expect(ua.browser?.name).toBe('Safari') + expect(ua.browser?.version).toBe('17.0') + }) + + it('returns undefined browser for unknown user agents', () => { + const ctx = createContext({ + 'user-agent': 'custom-http-client/1.0', + }) + + createUserAgentEnricher()(ctx) + + const ua = ctx.event.userAgent as UserAgentInfo + expect(ua.browser).toBeUndefined() + }) +}) + +describe('enrichers - device detection (T5)', () => { + it('detects mobile device', () => { + const ctx = createContext({ + 'user-agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1', + }) + + createUserAgentEnricher()(ctx) + + expect((ctx.event.userAgent as UserAgentInfo).device?.type).toBe('mobile') + }) + + it('detects tablet device', () => { + const ctx = createContext({ + 'user-agent': 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1', + }) + + createUserAgentEnricher()(ctx) + + expect((ctx.event.userAgent as UserAgentInfo).device?.type).toBe('tablet') + }) + + it('detects bot', () => { + const ctx = createContext({ + 'user-agent': 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', + }) + + createUserAgentEnricher()(ctx) + + expect((ctx.event.userAgent as UserAgentInfo).device?.type).toBe('bot') + }) + + it('detects desktop device', () => { + const ctx = createContext({ + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + }) + + createUserAgentEnricher()(ctx) + + expect((ctx.event.userAgent as UserAgentInfo).device?.type).toBe('desktop') + }) + + it('detects Android mobile', () => { + const ctx = createContext({ + 'user-agent': 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36', + }) + + createUserAgentEnricher()(ctx) + + expect((ctx.event.userAgent as UserAgentInfo).device?.type).toBe('mobile') + }) +}) + +describe('enrichers - OS detection (T6)', () => { + it('detects Windows', () => { + const ctx = createContext({ + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + }) + + createUserAgentEnricher()(ctx) + + const ua = ctx.event.userAgent as UserAgentInfo + expect(ua.os?.name).toBe('Windows') + expect(ua.os?.version).toBe('10.0') + }) + + it('detects macOS', () => { + const ctx = createContext({ + 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 13_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15', + }) + + createUserAgentEnricher()(ctx) + + const ua = ctx.event.userAgent as UserAgentInfo + expect(ua.os?.name).toBe('macOS') + expect(ua.os?.version).toBe('13.4') + }) + + it('detects iOS', () => { + const ctx = createContext({ + 'user-agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1', + }) + + createUserAgentEnricher()(ctx) + + const ua = ctx.event.userAgent as UserAgentInfo + expect(ua.os?.name).toBe('iOS') + expect(ua.os?.version).toBe('17.0') + }) + + it('detects Android', () => { + const ctx = createContext({ + 'user-agent': 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36', + }) + + createUserAgentEnricher()(ctx) + + const ua = ctx.event.userAgent as UserAgentInfo + expect(ua.os?.name).toBe('Android') + expect(ua.os?.version).toBe('14') + }) + + it('detects Linux', () => { + const ctx = createContext({ + 'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + }) + + createUserAgentEnricher()(ctx) + + const ua = ctx.event.userAgent as UserAgentInfo + expect(ua.os?.name).toBe('Linux') + expect(ua.os?.version).toBeUndefined() + }) +}) + +describe('enrichers - empty/missing headers (T8)', () => { + it('no-ops when headers is undefined', () => { + const event: WideEvent = { + timestamp: new Date().toISOString(), + level: 'info', + service: 'test', + environment: 'test', + } + + const ctx: EnrichContext = { event, headers: undefined } + + createUserAgentEnricher()(ctx) + createGeoEnricher()(ctx) + createRequestSizeEnricher()(ctx) + createTraceContextEnricher()(ctx) + + expect(ctx.event.userAgent).toBeUndefined() + expect(ctx.event.geo).toBeUndefined() + expect(ctx.event.requestSize).toBeUndefined() + expect(ctx.event.traceContext).toBeUndefined() + }) + + it('no-ops when headers is empty object', () => { + const ctx = createContext({}) + + createUserAgentEnricher()(ctx) + createGeoEnricher()(ctx) + createRequestSizeEnricher()(ctx) + createTraceContextEnricher()(ctx) + + expect(ctx.event.userAgent).toBeUndefined() + expect(ctx.event.geo).toBeUndefined() + expect(ctx.event.requestSize).toBeUndefined() + expect(ctx.event.traceContext).toBeUndefined() + }) + + it('handles missing response headers for requestSize enricher', () => { + const ctx = createContext({ 'content-length': '512' }) + + createRequestSizeEnricher()(ctx) + + expect(ctx.event.requestSize).toMatchObject({ + requestBytes: 512, + responseBytes: undefined, + }) + }) +}) diff --git a/packages/evlog/test/nitro-plugin.test.ts b/packages/evlog/test/nitro-plugin.test.ts index 5a3332f..a2633fe 100644 --- a/packages/evlog/test/nitro-plugin.test.ts +++ b/packages/evlog/test/nitro-plugin.test.ts @@ -1,34 +1,14 @@ import { describe, expect, it, vi } from 'vitest' import { getHeaders } from 'h3' -import type { DrainContext, RouteConfig, ServerEvent, WideEvent } from '../src/types' -import { matchesPattern } from '../src/utils' +import type { DrainContext, EnrichContext, RouteConfig, ServerEvent, WideEvent } from '../src/types' +import { filterSafeHeaders, matchesPattern } from '../src/utils' -// Mock h3's getHeaders vi.mock('h3', () => ({ getHeaders: vi.fn(), })) -/** Headers that should never be passed to the drain hook for security */ -const SENSITIVE_HEADERS = [ - 'authorization', - 'cookie', - 'set-cookie', - 'x-api-key', - 'x-auth-token', - 'proxy-authorization', -] - -/** Simulate the getSafeHeaders function from the plugin */ function getSafeHeaders(allHeaders: Record): Record { - const safeHeaders: Record = {} - - for (const [key, value] of Object.entries(allHeaders)) { - if (!SENSITIVE_HEADERS.includes(key.toLowerCase())) { - safeHeaders[key] = value - } - } - - return safeHeaders + return filterSafeHeaders(allHeaders) } describe('nitro plugin - drain hook headers', () => { @@ -300,7 +280,6 @@ describe('nitro plugin - drain hook headers', () => { }) describe('nitro plugin - waitUntil support', () => { - /** Simulate the callDrainHook function from the plugin */ function callDrainHook( nitroApp: { hooks: { callHook: (name: string, ctx: DrainContext) => Promise } }, emittedEvent: WideEvent | null, @@ -517,7 +496,6 @@ describe('nitro plugin - waitUntil support', () => { }) describe('nitro plugin - route-based service configuration', () => { - /** Simulate the getServiceForPath function from the plugin */ function getServiceForPath(path: string, routes?: Record): string | undefined { if (!routes) return undefined @@ -825,3 +803,237 @@ describe('nitro plugin - service resolution priority', () => { expect(mockLog.getContext().service).toBe('default-service') }) }) + +describe('nitro plugin - enrichment pipeline (T7)', () => { + async function callEnrichAndDrain( + nitroApp: { + hooks: { + callHook: (name: string, ctx: EnrichContext | DrainContext) => Promise + } + }, + emittedEvent: WideEvent | null, + event: ServerEvent, + ): Promise { + if (!emittedEvent) return + + const allHeaders = getHeaders(event as Parameters[0]) + const hookContext = { + request: { method: event.method, path: event.path, requestId: event.context.requestId as string | undefined }, + headers: getSafeHeaders(allHeaders), + response: { status: 200 }, + } + + try { + await nitroApp.hooks.callHook('evlog:enrich', { event: emittedEvent, ...hookContext }) + } catch (err) { + console.error('[evlog] enrich failed:', err) + } + + nitroApp.hooks.callHook('evlog:drain', { + event: emittedEvent, + request: hookContext.request, + headers: hookContext.headers, + }).catch((err) => { + console.error('[evlog] drain failed:', err) + }) + } + + it('calls enrich then drain in sequence', async () => { + const callOrder: string[] = [] + const mockHeaders = { 'content-type': 'application/json' } + vi.mocked(getHeaders).mockReturnValue(mockHeaders) + + const mockHooks = { + callHook: vi.fn().mockImplementation((hookName: string) => { + callOrder.push(hookName) + return Promise.resolve() + }), + } + + const mockEvent: ServerEvent = { + method: 'POST', + path: '/api/test', + context: { requestId: 'req-123' }, + } + + const emittedEvent: WideEvent = { + timestamp: new Date().toISOString(), + level: 'info', + service: 'test', + environment: 'test', + } + + await callEnrichAndDrain({ hooks: mockHooks }, emittedEvent, mockEvent) + + expect(callOrder).toEqual(['evlog:enrich', 'evlog:drain']) + }) + + it('skips pipeline when emittedEvent is null', async () => { + const mockHooks = { + callHook: vi.fn().mockResolvedValue(undefined), + } + + const mockEvent: ServerEvent = { + method: 'GET', + path: '/api/test', + context: {}, + } + + await callEnrichAndDrain({ hooks: mockHooks }, null, mockEvent) + + expect(mockHooks.callHook).not.toHaveBeenCalled() + }) + + it('enrich errors do not prevent drain from running', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const mockHeaders = { 'content-type': 'application/json' } + vi.mocked(getHeaders).mockReturnValue(mockHeaders) + + let drainCalled = false + const mockHooks = { + callHook: vi.fn().mockImplementation((hookName: string) => { + if (hookName === 'evlog:enrich') { + return Promise.reject(new Error('enrich boom')) + } + if (hookName === 'evlog:drain') { + drainCalled = true + } + return Promise.resolve() + }), + } + + const mockEvent: ServerEvent = { + method: 'POST', + path: '/api/test', + context: {}, + } + + const emittedEvent: WideEvent = { + timestamp: new Date().toISOString(), + level: 'info', + service: 'test', + environment: 'test', + } + + await callEnrichAndDrain({ hooks: mockHooks }, emittedEvent, mockEvent) + + expect(drainCalled).toBe(true) + expect(consoleSpy).toHaveBeenCalledWith('[evlog] enrich failed:', expect.any(Error)) + consoleSpy.mockRestore() + }) + + it('drain errors are logged but do not throw', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const mockHeaders = {} + vi.mocked(getHeaders).mockReturnValue(mockHeaders) + + const mockHooks = { + callHook: vi.fn().mockImplementation((hookName: string) => { + if (hookName === 'evlog:drain') { + return Promise.reject(new Error('drain boom')) + } + return Promise.resolve() + }), + } + + const mockEvent: ServerEvent = { + method: 'GET', + path: '/api/test', + context: {}, + } + + const emittedEvent: WideEvent = { + timestamp: new Date().toISOString(), + level: 'info', + service: 'test', + environment: 'test', + } + + // Should not throw + await callEnrichAndDrain({ hooks: mockHooks }, emittedEvent, mockEvent) + + // Wait for drain promise to settle + await new Promise(resolve => setTimeout(resolve, 10)) + + expect(consoleSpy).toHaveBeenCalledWith('[evlog] drain failed:', expect.any(Error)) + consoleSpy.mockRestore() + }) + + it('enricher can mutate the event before drain receives it', async () => { + const mockHeaders = { 'user-agent': 'TestBot/1.0' } + vi.mocked(getHeaders).mockReturnValue(mockHeaders) + + let drainEvent: WideEvent | null = null + const mockHooks = { + callHook: vi.fn().mockImplementation((hookName: string, ctx: EnrichContext | DrainContext) => { + if (hookName === 'evlog:enrich') { + (ctx as EnrichContext).event.enriched = true + ;(ctx as EnrichContext).event.customField = 'added-by-enricher' + } + if (hookName === 'evlog:drain') { + drainEvent = (ctx as DrainContext).event + } + return Promise.resolve() + }), + } + + const mockEvent: ServerEvent = { + method: 'POST', + path: '/api/test', + context: {}, + } + + const emittedEvent: WideEvent = { + timestamp: new Date().toISOString(), + level: 'info', + service: 'test', + environment: 'test', + } + + await callEnrichAndDrain({ hooks: mockHooks }, emittedEvent, mockEvent) + + expect(drainEvent).not.toBeNull() + expect(drainEvent!.enriched).toBe(true) + expect(drainEvent!.customField).toBe('added-by-enricher') + }) + + it('passes headers to both enrich and drain hooks', async () => { + const mockHeaders = { + 'content-type': 'application/json', + 'x-request-id': 'req-456', + } + vi.mocked(getHeaders).mockReturnValue(mockHeaders) + + let enrichHeaders: Record | undefined + let drainHeaders: Record | undefined + const mockHooks = { + callHook: vi.fn().mockImplementation((hookName: string, ctx: EnrichContext | DrainContext) => { + if (hookName === 'evlog:enrich') { + enrichHeaders = (ctx as EnrichContext).headers + } + if (hookName === 'evlog:drain') { + drainHeaders = (ctx as DrainContext).headers + } + return Promise.resolve() + }), + } + + const mockEvent: ServerEvent = { + method: 'POST', + path: '/api/test', + context: {}, + } + + const emittedEvent: WideEvent = { + timestamp: new Date().toISOString(), + level: 'info', + service: 'test', + environment: 'test', + } + + await callEnrichAndDrain({ hooks: mockHooks }, emittedEvent, mockEvent) + + expect(enrichHeaders).toEqual(mockHeaders) + expect(drainHeaders).toEqual(mockHeaders) + }) +}) From a982c0c25c82c1f2f1e5f1d9a6c79621cd9e1830 Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Sun, 8 Feb 2026 17:45:34 +0000 Subject: [PATCH 4/5] fix from cr --- packages/evlog/src/enrichers/index.ts | 21 ++++++++++++------- packages/evlog/src/nitro/plugin.ts | 4 ++-- .../server/routes/_evlog/ingest.post.ts | 3 +++ 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/evlog/src/enrichers/index.ts b/packages/evlog/src/enrichers/index.ts index e6181ac..00d3534 100644 --- a/packages/evlog/src/enrichers/index.ts +++ b/packages/evlog/src/enrichers/index.ts @@ -108,7 +108,7 @@ function parseTraceparent(traceparent: string): Pick>( +function mergeEventField( existing: unknown, computed: T, overwrite?: boolean, @@ -200,20 +200,25 @@ export function createTraceContextEnricher(options: EnricherOptions = {}): (ctx: if (!traceparent && !tracestate) return const parsed = traceparent ? parseTraceparent(traceparent) : undefined - const traceContext: TraceContextInfo = { + const incomingTraceContext: TraceContextInfo = { traceparent, tracestate, traceId: parsed?.traceId ?? (ctx.event.traceId as string | undefined), spanId: parsed?.spanId ?? (ctx.event.spanId as string | undefined), } - if (traceContext.traceId && (options.overwrite || ctx.event.traceId === undefined)) { - ctx.event.traceId = traceContext.traceId + const mergedTraceContext = mergeEventField( + ctx.event.traceContext, + incomingTraceContext, + options.overwrite, + ) + ctx.event.traceContext = mergedTraceContext + + if (mergedTraceContext.traceId && (options.overwrite || ctx.event.traceId === undefined)) { + ctx.event.traceId = mergedTraceContext.traceId } - if (traceContext.spanId && (options.overwrite || ctx.event.spanId === undefined)) { - ctx.event.spanId = traceContext.spanId + if (mergedTraceContext.spanId && (options.overwrite || ctx.event.spanId === undefined)) { + ctx.event.spanId = mergedTraceContext.spanId } - - ctx.event.traceContext = mergeEventField(ctx.event.traceContext, traceContext, options.overwrite) } } diff --git a/packages/evlog/src/nitro/plugin.ts b/packages/evlog/src/nitro/plugin.ts index 6a345b5..8669d85 100644 --- a/packages/evlog/src/nitro/plugin.ts +++ b/packages/evlog/src/nitro/plugin.ts @@ -112,7 +112,7 @@ function getResponseStatus(event: ServerEvent): number { function buildHookContext(event: ServerEvent): Omit { const responseHeaders = getSafeResponseHeaders(event) return { - request: { method: event.method, path: event.path, requestId: event.context.requestId as string | undefined }, + request: { method: event.method, path: event.path }, headers: getSafeHeaders(event), response: { status: getResponseStatus(event), @@ -184,7 +184,7 @@ export default defineNitroPlugin((nitroApp) => { // Store start time for duration calculation in tail sampling e.context._evlogStartTime = Date.now() - + let requestIdOverride: string | undefined = undefined if (globalThis.navigator?.userAgent === 'Cloudflare-Workers') { const cfRay = getSafeHeaders(e)?.['cf-ray'] diff --git a/packages/evlog/src/runtime/server/routes/_evlog/ingest.post.ts b/packages/evlog/src/runtime/server/routes/_evlog/ingest.post.ts index bf8b5c0..3c03c07 100644 --- a/packages/evlog/src/runtime/server/routes/_evlog/ingest.post.ts +++ b/packages/evlog/src/runtime/server/routes/_evlog/ingest.post.ts @@ -121,10 +121,13 @@ export default defineEventHandler(async (event) => { }) // Use waitUntil if available (Cloudflare Workers, Vercel Edge) + // Otherwise, await the drain to prevent lost logs in serverless environments const waitUntilCtx = (event as unknown as { context: Record }).context const cfCtx = (waitUntilCtx as { cloudflare?: { context?: { waitUntil?: (p: Promise) => void } } }).cloudflare?.context ?? waitUntilCtx if (typeof (cfCtx as { waitUntil?: unknown }).waitUntil === 'function') { (cfCtx as { waitUntil: (p: Promise) => void }).waitUntil(drainPromise) + } else { + await drainPromise } setResponseStatus(event, 204) From ab369f166f9f633a7fb3ad3d9d9dabebf5c93cc1 Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Sun, 8 Feb 2026 17:54:54 +0000 Subject: [PATCH 5/5] add doc --- .agents/skills/create-enricher/SKILL.md | 147 +++++++++++++ .../references/enricher-template.md | 79 +++++++ AGENTS.md | 62 ++++++ .../1.getting-started/2.installation.md | 45 ++++ apps/docs/content/4.enrichers/.navigation.yml | 1 + apps/docs/content/4.enrichers/1.overview.md | 122 +++++++++++ apps/docs/content/4.enrichers/2.built-in.md | 207 ++++++++++++++++++ apps/docs/content/4.enrichers/3.custom.md | 151 +++++++++++++ packages/evlog/package.json | 2 +- 9 files changed, 815 insertions(+), 1 deletion(-) create mode 100644 .agents/skills/create-enricher/SKILL.md create mode 100644 .agents/skills/create-enricher/references/enricher-template.md create mode 100644 apps/docs/content/4.enrichers/.navigation.yml create mode 100644 apps/docs/content/4.enrichers/1.overview.md create mode 100644 apps/docs/content/4.enrichers/2.built-in.md create mode 100644 apps/docs/content/4.enrichers/3.custom.md diff --git a/.agents/skills/create-enricher/SKILL.md b/.agents/skills/create-enricher/SKILL.md new file mode 100644 index 0000000..474c48b --- /dev/null +++ b/.agents/skills/create-enricher/SKILL.md @@ -0,0 +1,147 @@ +--- +name: create-evlog-enricher +description: Create a new built-in evlog enricher to add derived context to wide events. Use when adding a new enricher (e.g., for deployment metadata, tenant context, feature flags, etc.) to the evlog package. Covers source code, tests, and all documentation. +--- + +# Create evlog Enricher + +Add a new built-in enricher to evlog. Every enricher follows the same architecture. This skill walks through all 6 touchpoints. **Every single touchpoint is mandatory** -- do not skip any. + +## PR Title + +Recommended format for the pull request title: + +``` +feat: add {name} enricher +``` + +The exact wording may vary depending on the enricher (e.g., `feat: add user agent enricher`, `feat: add geo enricher`), but it should always follow the `feat:` conventional commit prefix. + +## Touchpoints Checklist + +| # | File | Action | +|---|------|--------| +| 1 | `packages/evlog/src/enrichers/index.ts` | Add enricher source | +| 2 | `packages/evlog/test/enrichers.test.ts` | Add tests | +| 3 | `apps/docs/content/4.enrichers/2.built-in.md` | Add enricher to built-in docs | +| 4 | `apps/docs/content/4.enrichers/1.overview.md` | Add enricher to overview cards | +| 5 | `AGENTS.md` | Add enricher to the "Built-in Enrichers" table | +| 6 | `README.md` + `packages/evlog/README.md` | Add enricher to README enrichers section | + +**Important**: Do NOT consider the task complete until all 6 touchpoints have been addressed. + +## Naming Conventions + +Use these placeholders consistently: + +| Placeholder | Example (UserAgent) | Usage | +|-------------|---------------------|-------| +| `{name}` | `userAgent` | camelCase for event field key | +| `{Name}` | `UserAgent` | PascalCase in function/interface names | +| `{DISPLAY}` | `User Agent` | Human-readable display name | + +## Step 1: Enricher Source + +Add the enricher to `packages/evlog/src/enrichers/index.ts`. + +Read [references/enricher-template.md](references/enricher-template.md) for the full annotated template. + +Key architecture rules: + +1. **Info interface** -- define the shape of the enricher output (e.g., `UserAgentInfo`, `GeoInfo`) +2. **Factory function** -- `create{Name}Enricher(options?: EnricherOptions)` returns `(ctx: EnrichContext) => void` +3. **Uses `EnricherOptions`** -- accepts `{ overwrite?: boolean }` to control merge behavior +4. **Uses `mergeEventField()`** -- merge computed data with existing event fields, respecting `overwrite` +5. **Uses `getHeader()`** -- case-insensitive header lookup helper +6. **Sets a single event field** -- `ctx.event.{name} = mergedValue` +7. **Early return** -- skip enrichment if required headers are missing +8. **No side effects** -- enrichers only mutate `ctx.event`, never throw or log + +## Step 2: Tests + +Add tests to `packages/evlog/test/enrichers.test.ts`. + +Required test categories: + +1. **Sets field from headers** -- verify the enricher populates the event field correctly +2. **Skips when header missing** -- verify no field is set when the required header is absent +3. **Preserves existing data** -- verify `overwrite: false` (default) doesn't replace user-provided fields +4. **Overwrites when requested** -- verify `overwrite: true` replaces existing fields +5. **Handles edge cases** -- empty strings, malformed values, case-insensitive header names + +Follow the existing test structure in `enrichers.test.ts` -- each enricher has its own `describe` block. + +## Step 3: Update Built-in Docs + +Edit `apps/docs/content/4.enrichers/2.built-in.md` to add a new section for the enricher. + +Each enricher section follows this structure: + +```markdown +## {DISPLAY} + +[One-sentence description of what the enricher does.] + +**Sets:** `event.{name}` + +\`\`\`typescript +const enrich = create{Name}Enricher() +\`\`\` + +**Output shape:** + +\`\`\`typescript +interface {Name}Info { + // fields +} +\`\`\` + +**Example output:** + +\`\`\`json +{ + "{name}": { + // example values + } +} +\`\`\` +``` + +Add any relevant callouts for platform-specific notes or limitations. + +## Step 4: Update Overview Page + +Edit `apps/docs/content/4.enrichers/1.overview.md` to add a card for the new enricher in the `::card-group` section (before the Custom card): + +```markdown + :::card + --- + icon: i-lucide-{icon} + title: {DISPLAY} + to: /enrichers/built-in#{anchor} + --- + [Short description.] + ::: +``` + +## Step 5: Update AGENTS.md + +Add the new enricher to the **"Built-in Enrichers"** table in the root `AGENTS.md` file, in the "Event Enrichment" section: + +```markdown +| {DISPLAY} | `evlog/enrichers` | `{name}` | [Description] | +``` + +## Step 6: Update READMEs + +Add the enricher to the enrichers section in both `README.md` and `packages/evlog/README.md`. Both files should list all built-in enrichers with their event fields and output shapes. + +## Verification + +After completing all steps, run: + +```bash +cd packages/evlog +bun run build # Verify build succeeds +bun run test # Verify tests pass +``` diff --git a/.agents/skills/create-enricher/references/enricher-template.md b/.agents/skills/create-enricher/references/enricher-template.md new file mode 100644 index 0000000..c219481 --- /dev/null +++ b/.agents/skills/create-enricher/references/enricher-template.md @@ -0,0 +1,79 @@ +# Enricher Source Template + +Template for adding a new enricher to `packages/evlog/src/enrichers/index.ts`. + +Replace `{Name}`, `{name}`, and `{DISPLAY}` with the actual enricher name. + +## Info Interface + +Define the output shape of the enricher: + +```typescript +export interface {Name}Info { + /** Description of field */ + field1?: string + /** Description of field */ + field2?: number +} +``` + +## Factory Function + +```typescript +/** + * Enrich events with {DISPLAY} data. + * Sets `event.{name}` with `{Name}Info` shape: `{ field1?, field2? }`. + */ +export function create{Name}Enricher(options: EnricherOptions = {}): (ctx: EnrichContext) => void { + return (ctx) => { + // 1. Extract data from headers (case-insensitive) + const value = getHeader(ctx.headers, 'x-my-header') + if (!value) return // Early return if no data available + + // 2. Compute the enriched data + const info: {Name}Info = { + field1: value, + field2: Number(value), + } + + // 3. Merge with existing event field (respects overwrite option) + ctx.event.{name} = mergeEventField<{Name}Info>(ctx.event.{name}, info, options.overwrite) + } +} +``` + +## Architecture Rules + +1. **Use existing helpers** -- `getHeader()` for case-insensitive header lookup, `mergeEventField()` for safe merging, `normalizeNumber()` for parsing numeric strings +2. **Single event field** -- each enricher sets one top-level field on `ctx.event` +3. **Factory pattern** -- always return a function, never execute directly +4. **EnricherOptions** -- accept `{ overwrite?: boolean }` for merge control +5. **Early return** -- skip if required data is missing +6. **No side effects** -- never throw, never log, only mutate `ctx.event` +7. **Clean undefined values** -- skip the enricher entirely if all computed values are `undefined` + +## Available Helpers + +These helpers are already defined in the enrichers file: + +```typescript +// Case-insensitive header lookup +function getHeader(headers: Record | undefined, name: string): string | undefined + +// Merge computed data with existing event fields, respecting overwrite +function mergeEventField(existing: unknown, computed: T, overwrite?: boolean): T + +// Parse string to number, returning undefined for non-finite values +function normalizeNumber(value: string | undefined): number | undefined +``` + +## Data Sources + +Enrichers typically read from: + +- **`ctx.headers`** -- HTTP request headers (sensitive headers already filtered) +- **`ctx.response?.headers`** -- HTTP response headers +- **`ctx.response?.status`** -- HTTP response status code +- **`ctx.request`** -- Request metadata (method, path, requestId) +- **`process.env`** -- Environment variables (for deployment metadata) +- **`ctx.event`** -- The event itself (for computed/derived fields) diff --git a/AGENTS.md b/AGENTS.md index ab29dba..479e736 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,8 @@ evlog/ │ ├── src/ │ │ ├── nuxt/ # Nuxt module │ │ ├── nitro/ # Nitro plugin +│ │ ├── adapters/ # Log drain adapters (Axiom, OTLP, PostHog, Sentry) +│ │ ├── enrichers/ # Built-in enrichers (UserAgent, Geo, RequestSize, TraceContext) │ │ └── runtime/ # Runtime code (client/, server/, utils/) │ └── test/ # Tests └── .github/ # CI/CD workflows @@ -328,6 +330,66 @@ export default defineNuxtConfig({ }) ``` +#### Event Enrichment + +Enrichers add derived context to wide events after emit, before drain. Use the `evlog:enrich` hook to register enrichers. + +> **Creating a new enricher?** Follow the skill at `.agents/skills/create-enricher/SKILL.md`. It covers all touchpoints: source code, tests, and documentation updates. + +**Built-in Enrichers:** + +| Enricher | Import | Event Field | Description | +|----------|--------|-------------|-------------| +| User Agent | `evlog/enrichers` | `userAgent` | Parse browser, OS, device type from User-Agent header | +| Geo | `evlog/enrichers` | `geo` | Extract country, region, city from platform headers (Vercel, Cloudflare) | +| Request Size | `evlog/enrichers` | `requestSize` | Capture request/response payload sizes from Content-Length | +| Trace Context | `evlog/enrichers` | `traceContext` | Extract W3C trace context (traceId, spanId) from traceparent header | + +**Using Built-in Enrichers:** + +```typescript +// server/plugins/evlog-enrich.ts +import { + createUserAgentEnricher, + createGeoEnricher, + createRequestSizeEnricher, + createTraceContextEnricher, +} from 'evlog/enrichers' + +export default defineNitroPlugin((nitroApp) => { + const enrichers = [ + createUserAgentEnricher(), + createGeoEnricher(), + createRequestSizeEnricher(), + createTraceContextEnricher(), + ] + + nitroApp.hooks.hook('evlog:enrich', (ctx) => { + for (const enricher of enrichers) enricher(ctx) + }) +}) +``` + +**Custom Enricher:** + +```typescript +// server/plugins/evlog-enrich.ts +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('evlog:enrich', (ctx) => { + ctx.event.deploymentId = process.env.DEPLOYMENT_ID + ctx.event.region = process.env.FLY_REGION + }) +}) +``` + +The `EnrichContext` contains: +- `event`: The emitted `WideEvent` (mutable — add or modify fields directly) +- `request`: Optional request metadata (`method`, `path`, `requestId`) +- `headers`: Safe HTTP headers (sensitive headers are filtered) +- `response`: Optional response metadata (`status`, `headers`) + +All enrichers accept `{ overwrite?: boolean }` — defaults to `false` to preserve user-provided data. + ### Nitro ```typescript diff --git a/apps/docs/content/1.getting-started/2.installation.md b/apps/docs/content/1.getting-started/2.installation.md index 12a4326..be7111e 100644 --- a/apps/docs/content/1.getting-started/2.installation.md +++ b/apps/docs/content/1.getting-started/2.installation.md @@ -243,6 +243,51 @@ export default defineNitroPlugin((nitroApp) => { }) ``` +### Event Enrichment + +Enrich your wide events with derived context like user agent, geo data, request size, and trace context. Enrichers run after emit, before drain. + +```typescript [server/plugins/evlog-enrich.ts] +import { + createUserAgentEnricher, + createGeoEnricher, + createRequestSizeEnricher, + createTraceContextEnricher, +} from 'evlog/enrichers' + +export default defineNitroPlugin((nitroApp) => { + const enrichers = [ + createUserAgentEnricher(), + createGeoEnricher(), + createRequestSizeEnricher(), + createTraceContextEnricher(), + ] + + nitroApp.hooks.hook('evlog:enrich', (ctx) => { + for (const enricher of enrichers) enricher(ctx) + }) +}) +``` + +| Enricher | Event Field | Description | +|----------|-------------|-------------| +| `createUserAgentEnricher()` | `userAgent` | Browser, OS, device type from User-Agent header | +| `createGeoEnricher()` | `geo` | Country, region, city from platform headers (Vercel, Cloudflare) | +| `createRequestSizeEnricher()` | `requestSize` | Request/response payload sizes from Content-Length | +| `createTraceContextEnricher()` | `traceContext` | W3C trace context (traceId, spanId) from traceparent header | + +You can also write custom enrichers to add any derived context: + +```typescript [server/plugins/evlog-enrich.ts] +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('evlog:enrich', (ctx) => { + ctx.event.deploymentId = process.env.DEPLOYMENT_ID + }) +}) +``` + +See the [Enrichers guide](/enrichers/overview) for full documentation. + ### Client Transport Send browser logs to your server for centralized logging. When enabled, client-side `log.info()`, `log.error()`, etc. calls are automatically sent to the server via the `/api/_evlog/ingest` endpoint. diff --git a/apps/docs/content/4.enrichers/.navigation.yml b/apps/docs/content/4.enrichers/.navigation.yml new file mode 100644 index 0000000..beda0ab --- /dev/null +++ b/apps/docs/content/4.enrichers/.navigation.yml @@ -0,0 +1 @@ +title: Enrichers diff --git a/apps/docs/content/4.enrichers/1.overview.md b/apps/docs/content/4.enrichers/1.overview.md new file mode 100644 index 0000000..8bea89c --- /dev/null +++ b/apps/docs/content/4.enrichers/1.overview.md @@ -0,0 +1,122 @@ +--- +title: Enrichers Overview +description: Enrich your wide events with derived context like user agent, geo data, request size, and trace context. Built-in enrichers and custom enricher support. +navigation: + title: Overview + icon: i-lucide-sparkles +links: + - label: Built-in Enrichers + icon: i-lucide-puzzle + to: /enrichers/built-in + color: neutral + variant: subtle + - label: Custom Enrichers + icon: i-lucide-code + to: /enrichers/custom + color: neutral + variant: subtle +--- + +Enrichers add derived context to your wide events after they are emitted, before they reach your drain adapters. Use them to automatically extract useful information from request headers without cluttering your application code. + +## How Enrichers Work + +Enrichers hook into the `evlog:enrich` event, which fires after a wide event is emitted and before the `evlog:drain` hook. The enricher receives the event and request metadata, and can mutate the event to add derived fields. + +``` +Request → emit() → evlog:enrich → evlog:drain + ↑ enrichers ↑ adapters + add context send to services +``` + +```typescript [server/plugins/evlog-enrich.ts] +import { createUserAgentEnricher, createGeoEnricher } from 'evlog/enrichers' + +export default defineNitroPlugin((nitroApp) => { + const enrichers = [ + createUserAgentEnricher(), + createGeoEnricher(), + ] + + nitroApp.hooks.hook('evlog:enrich', (ctx) => { + for (const enricher of enrichers) enricher(ctx) + }) +}) +``` + +## Enrich Context + +Every enricher receives an `EnrichContext` with: + +| Field | Type | Description | +|-------|------|-------------| +| `event` | `WideEvent` | The emitted wide event (mutable) | +| `request` | `object` | Request metadata (`method`, `path`, `requestId`) | +| `headers` | `object` | Safe HTTP request headers (sensitive headers are filtered) | +| `response` | `object` | Response metadata (`status`, `headers`) | + +::callout{icon="i-lucide-shield-check" color="success"} +**Security:** Sensitive headers (`authorization`, `cookie`, `x-api-key`, etc.) are automatically filtered and never passed to enrichers. +:: + +## Available Enrichers + +::card-group + :::card + --- + icon: i-lucide-monitor-smartphone + title: User Agent + to: /enrichers/built-in#user-agent + --- + Parse browser, OS, and device type from the User-Agent header. + ::: + + :::card + --- + icon: i-lucide-map-pin + title: Geo + to: /enrichers/built-in#geo + --- + Extract country, region, city, and coordinates from platform headers. + ::: + + :::card + --- + icon: i-lucide-hard-drive + title: Request Size + to: /enrichers/built-in#request-size + --- + Capture request and response payload sizes from Content-Length headers. + ::: + + :::card + --- + icon: i-lucide-route + title: Trace Context + to: /enrichers/built-in#trace-context + --- + Extract W3C trace context (traceId, spanId) from the traceparent header. + ::: + + :::card + --- + icon: i-lucide-code + title: Custom + to: /enrichers/custom + --- + Write your own enricher for any derived context. + ::: +:: + +## Overwrite Behavior + +By default, enrichers preserve existing fields. If your application code already sets `event.userAgent`, the enricher won't overwrite it. Pass `{ overwrite: true }` to change this: + +```typescript +createUserAgentEnricher({ overwrite: true }) +``` + +## Next Steps + +- [Built-in Enrichers](/enrichers/built-in) - Detailed reference for all built-in enrichers +- [Custom Enrichers](/enrichers/custom) - Write your own enrichers diff --git a/apps/docs/content/4.enrichers/2.built-in.md b/apps/docs/content/4.enrichers/2.built-in.md new file mode 100644 index 0000000..49413ee --- /dev/null +++ b/apps/docs/content/4.enrichers/2.built-in.md @@ -0,0 +1,207 @@ +--- +title: Built-in Enrichers +description: Reference for all built-in evlog enrichers. Parse user agents, extract geo data, measure request sizes, and capture trace context automatically. +navigation: + title: Built-in + icon: i-lucide-puzzle +links: + - label: Custom Enrichers + icon: i-lucide-code + to: /enrichers/custom + color: neutral + variant: subtle + - label: Enrichers Overview + icon: i-lucide-sparkles + to: /enrichers/overview + color: neutral + variant: subtle +--- + +All built-in enrichers are exported from `evlog/enrichers`. Each enricher is a factory function that returns an `(ctx: EnrichContext) => void` callback. + +```typescript +import { + createUserAgentEnricher, + createGeoEnricher, + createRequestSizeEnricher, + createTraceContextEnricher, +} from 'evlog/enrichers' +``` + +## User Agent + +Parse browser, OS, and device type from the `User-Agent` header. + +**Sets:** `event.userAgent` + +```typescript +const enrich = createUserAgentEnricher() +``` + +**Output shape:** + +```typescript +interface UserAgentInfo { + raw: string // Original User-Agent string + browser?: { name: string; version?: string } // Chrome, Firefox, Safari, Edge + os?: { name: string; version?: string } // Windows, macOS, iOS, Android, Linux + device?: { type: 'mobile' | 'tablet' | 'desktop' | 'bot' | 'unknown' } +} +``` + +**Example output:** + +```json +{ + "userAgent": { + "raw": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120.0.0.0", + "browser": { "name": "Chrome", "version": "120.0.0.0" }, + "os": { "name": "macOS", "version": "10.15.7" }, + "device": { "type": "desktop" } + } +} +``` + +**Detected browsers:** Edge, Chrome, Firefox, Safari (checked in order, Edge before Chrome to avoid false matches). + +**Detected devices:** Bot (crawlers, spiders), Tablet (iPad), Mobile (iPhone, Android phones), Desktop (fallback). + +## Geo + +Extract geographic data from platform-injected headers. + +**Sets:** `event.geo` + +```typescript +const enrich = createGeoEnricher() +``` + +**Output shape:** + +```typescript +interface GeoInfo { + country?: string // ISO country code (e.g., "US", "FR") + region?: string // Region/state name + regionCode?: string // Region code + city?: string // City name + latitude?: number // Decimal latitude + longitude?: number // Decimal longitude +} +``` + +**Supported platforms:** + +| Platform | Headers | Coverage | +|----------|---------|----------| +| Vercel | `x-vercel-ip-country`, `x-vercel-ip-country-region`, `x-vercel-ip-city`, `x-vercel-ip-latitude`, `x-vercel-ip-longitude` | Full | +| Cloudflare | `cf-ipcountry` | Country only | + +::callout{icon="i-lucide-info" color="info"} +**Cloudflare note:** Only `cf-ipcountry` is a standard Cloudflare HTTP header. Other geo fields (`city`, `region`, `latitude`, etc.) are properties of `request.cf`, which is not exposed as headers. For full Cloudflare geo data, write a [custom enricher](/enrichers/custom) that reads `request.cf`, or use a Workers middleware to copy `cf` properties into custom headers. +:: + +## Request Size + +Capture request and response payload sizes from `Content-Length` headers. + +**Sets:** `event.requestSize` + +```typescript +const enrich = createRequestSizeEnricher() +``` + +**Output shape:** + +```typescript +interface RequestSizeInfo { + requestBytes?: number // Request Content-Length + responseBytes?: number // Response Content-Length +} +``` + +**Example output:** + +```json +{ + "requestSize": { + "requestBytes": 1234, + "responseBytes": 5678 + } +} +``` + +::callout{icon="i-lucide-info" color="info"} +This enricher reads the `Content-Length` header from both the request and response. If the header is missing (e.g., for chunked transfer encoding), the corresponding field will be `undefined`. +:: + +## Trace Context + +Extract W3C trace context from the `traceparent` and `tracestate` headers. + +**Sets:** `event.traceContext`, `event.traceId`, `event.spanId` + +```typescript +const enrich = createTraceContextEnricher() +``` + +**Output shape:** + +```typescript +interface TraceContextInfo { + traceparent?: string // Full traceparent header value + tracestate?: string // Full tracestate header value + traceId?: string // 32-char hex trace ID (parsed from traceparent) + spanId?: string // 16-char hex span ID (parsed from traceparent) +} +``` + +**Example output:** + +```json +{ + "traceContext": { + "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", + "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", + "spanId": "00f067aa0ba902b7" + }, + "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", + "spanId": "00f067aa0ba902b7" +} +``` + +`traceId` and `spanId` are also set at the top level of the event for easy querying and correlation. + +::callout{icon="i-lucide-info" color="info"} +The traceparent format follows the [W3C Trace Context](https://www.w3.org/TR/trace-context/) specification: `{version}-{traceId}-{spanId}-{flags}`. +:: + +## Full Setup Example + +Use all built-in enrichers together: + +```typescript [server/plugins/evlog-enrich.ts] +import { + createUserAgentEnricher, + createGeoEnricher, + createRequestSizeEnricher, + createTraceContextEnricher, +} from 'evlog/enrichers' + +export default defineNitroPlugin((nitroApp) => { + const enrichers = [ + createUserAgentEnricher(), + createGeoEnricher(), + createRequestSizeEnricher(), + createTraceContextEnricher(), + ] + + nitroApp.hooks.hook('evlog:enrich', (ctx) => { + for (const enricher of enrichers) enricher(ctx) + }) +}) +``` + +## Next Steps + +- [Custom Enrichers](/enrichers/custom) - Write your own enricher +- [Adapters](/adapters/overview) - Send enriched events to external services diff --git a/apps/docs/content/4.enrichers/3.custom.md b/apps/docs/content/4.enrichers/3.custom.md new file mode 100644 index 0000000..3d971c1 --- /dev/null +++ b/apps/docs/content/4.enrichers/3.custom.md @@ -0,0 +1,151 @@ +--- +title: Custom Enrichers +description: Write custom enrichers to add derived context to your wide events. Add deployment metadata, tenant IDs, feature flags, or any computed data. +navigation: + title: Custom + icon: i-lucide-code +links: + - label: Built-in Enrichers + icon: i-lucide-puzzle + to: /enrichers/built-in + color: neutral + variant: subtle + - label: Enrichers Overview + icon: i-lucide-sparkles + to: /enrichers/overview + color: neutral + variant: subtle +--- + +Write custom enrichers to add any derived context to your wide events. An enricher is a function that receives an `EnrichContext` and mutates the event. + +## Basic Example + +Add deployment metadata to every event: + +```typescript [server/plugins/evlog-enrich.ts] +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('evlog:enrich', (ctx) => { + ctx.event.deploymentId = process.env.DEPLOYMENT_ID + ctx.event.deployedBy = process.env.DEPLOYED_BY + }) +}) +``` + +## EnrichContext + +The `evlog:enrich` hook receives an `EnrichContext`: + +```typescript +interface EnrichContext { + /** The emitted wide event (mutable) */ + event: WideEvent + /** Request metadata */ + request?: { + method?: string + path?: string + requestId?: string + } + /** Safe HTTP request headers (sensitive headers filtered out) */ + headers?: Record + /** Response metadata */ + response?: { + status?: number + headers?: Record + } +} +``` + +## Factory Pattern + +For reusable enrichers with options, use the factory pattern (same as built-in enrichers): + +```typescript [server/utils/enrichers.ts] +import type { EnrichContext } from 'evlog' + +interface TenantEnricherOptions { + headerName?: string + overwrite?: boolean +} + +export function createTenantEnricher(options: TenantEnricherOptions = {}) { + const headerName = options.headerName ?? 'x-tenant-id' + + return (ctx: EnrichContext) => { + if (!options.overwrite && ctx.event.tenantId !== undefined) return + + const tenantId = ctx.headers?.[headerName] + if (tenantId) { + ctx.event.tenantId = tenantId + } + } +} +``` + +```typescript [server/plugins/evlog-enrich.ts] +import { createTenantEnricher } from '~/server/utils/enrichers' + +export default defineNitroPlugin((nitroApp) => { + const enrichTenant = createTenantEnricher({ headerName: 'x-org-id' }) + + nitroApp.hooks.hook('evlog:enrich', (ctx) => { + enrichTenant(ctx) + }) +}) +``` + +## Combining with Built-in Enrichers + +Mix custom enrichers with built-in ones: + +```typescript [server/plugins/evlog-enrich.ts] +import { createUserAgentEnricher, createGeoEnricher } from 'evlog/enrichers' + +export default defineNitroPlugin((nitroApp) => { + const builtIn = [ + createUserAgentEnricher(), + createGeoEnricher(), + ] + + nitroApp.hooks.hook('evlog:enrich', (ctx) => { + // Run built-in enrichers + for (const enricher of builtIn) enricher(ctx) + + // Add custom context + ctx.event.region = process.env.FLY_REGION ?? process.env.AWS_REGION + ctx.event.instance = process.env.FLY_ALLOC_ID ?? process.env.HOSTNAME + }) +}) +``` + +## More Examples + +### Feature Flags + +```typescript +nitroApp.hooks.hook('evlog:enrich', (ctx) => { + ctx.event.featureFlags = { + newCheckout: isEnabled('new-checkout'), + betaApi: isEnabled('beta-api'), + } +}) +``` + +### Response Time Classification + +```typescript +nitroApp.hooks.hook('evlog:enrich', (ctx) => { + const duration = ctx.event.duration as number | undefined + if (duration === undefined) return + + if (duration < 100) ctx.event.performanceTier = 'fast' + else if (duration < 500) ctx.event.performanceTier = 'normal' + else if (duration < 2000) ctx.event.performanceTier = 'slow' + else ctx.event.performanceTier = 'critical' +}) +``` + +## Next Steps + +- [Built-in Enrichers](/enrichers/built-in) - See all available built-in enrichers +- [Adapters](/adapters/overview) - Send enriched events to external services diff --git a/packages/evlog/package.json b/packages/evlog/package.json index d37b8ec..fd4b9f4 100644 --- a/packages/evlog/package.json +++ b/packages/evlog/package.json @@ -1,6 +1,6 @@ { "name": "evlog", - "version": "1.6.0", + "version": "1.7.0", "description": "Wide event logging library with structured error handling. Inspired by LoggingSucks.", "author": "HugoRCD ", "homepage": "https://evlog.dev",