Skip to content

Commit 5cc5418

Browse files
feat: evlog:enrich hook and built-in enrichers (#55)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent e922ff2 commit 5cc5418

21 files changed

Lines changed: 2012 additions & 55 deletions

File tree

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
---
2+
name: create-evlog-enricher
3+
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.
4+
---
5+
6+
# Create evlog Enricher
7+
8+
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.
9+
10+
## PR Title
11+
12+
Recommended format for the pull request title:
13+
14+
```
15+
feat: add {name} enricher
16+
```
17+
18+
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.
19+
20+
## Touchpoints Checklist
21+
22+
| # | File | Action |
23+
|---|------|--------|
24+
| 1 | `packages/evlog/src/enrichers/index.ts` | Add enricher source |
25+
| 2 | `packages/evlog/test/enrichers.test.ts` | Add tests |
26+
| 3 | `apps/docs/content/4.enrichers/2.built-in.md` | Add enricher to built-in docs |
27+
| 4 | `apps/docs/content/4.enrichers/1.overview.md` | Add enricher to overview cards |
28+
| 5 | `AGENTS.md` | Add enricher to the "Built-in Enrichers" table |
29+
| 6 | `README.md` + `packages/evlog/README.md` | Add enricher to README enrichers section |
30+
31+
**Important**: Do NOT consider the task complete until all 6 touchpoints have been addressed.
32+
33+
## Naming Conventions
34+
35+
Use these placeholders consistently:
36+
37+
| Placeholder | Example (UserAgent) | Usage |
38+
|-------------|---------------------|-------|
39+
| `{name}` | `userAgent` | camelCase for event field key |
40+
| `{Name}` | `UserAgent` | PascalCase in function/interface names |
41+
| `{DISPLAY}` | `User Agent` | Human-readable display name |
42+
43+
## Step 1: Enricher Source
44+
45+
Add the enricher to `packages/evlog/src/enrichers/index.ts`.
46+
47+
Read [references/enricher-template.md](references/enricher-template.md) for the full annotated template.
48+
49+
Key architecture rules:
50+
51+
1. **Info interface** -- define the shape of the enricher output (e.g., `UserAgentInfo`, `GeoInfo`)
52+
2. **Factory function** -- `create{Name}Enricher(options?: EnricherOptions)` returns `(ctx: EnrichContext) => void`
53+
3. **Uses `EnricherOptions`** -- accepts `{ overwrite?: boolean }` to control merge behavior
54+
4. **Uses `mergeEventField()`** -- merge computed data with existing event fields, respecting `overwrite`
55+
5. **Uses `getHeader()`** -- case-insensitive header lookup helper
56+
6. **Sets a single event field** -- `ctx.event.{name} = mergedValue`
57+
7. **Early return** -- skip enrichment if required headers are missing
58+
8. **No side effects** -- enrichers only mutate `ctx.event`, never throw or log
59+
60+
## Step 2: Tests
61+
62+
Add tests to `packages/evlog/test/enrichers.test.ts`.
63+
64+
Required test categories:
65+
66+
1. **Sets field from headers** -- verify the enricher populates the event field correctly
67+
2. **Skips when header missing** -- verify no field is set when the required header is absent
68+
3. **Preserves existing data** -- verify `overwrite: false` (default) doesn't replace user-provided fields
69+
4. **Overwrites when requested** -- verify `overwrite: true` replaces existing fields
70+
5. **Handles edge cases** -- empty strings, malformed values, case-insensitive header names
71+
72+
Follow the existing test structure in `enrichers.test.ts` -- each enricher has its own `describe` block.
73+
74+
## Step 3: Update Built-in Docs
75+
76+
Edit `apps/docs/content/4.enrichers/2.built-in.md` to add a new section for the enricher.
77+
78+
Each enricher section follows this structure:
79+
80+
```markdown
81+
## {DISPLAY}
82+
83+
[One-sentence description of what the enricher does.]
84+
85+
**Sets:** `event.{name}`
86+
87+
\`\`\`typescript
88+
const enrich = create{Name}Enricher()
89+
\`\`\`
90+
91+
**Output shape:**
92+
93+
\`\`\`typescript
94+
interface {Name}Info {
95+
// fields
96+
}
97+
\`\`\`
98+
99+
**Example output:**
100+
101+
\`\`\`json
102+
{
103+
"{name}": {
104+
// example values
105+
}
106+
}
107+
\`\`\`
108+
```
109+
110+
Add any relevant callouts for platform-specific notes or limitations.
111+
112+
## Step 4: Update Overview Page
113+
114+
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):
115+
116+
```markdown
117+
:::card
118+
---
119+
icon: i-lucide-{icon}
120+
title: {DISPLAY}
121+
to: /enrichers/built-in#{anchor}
122+
---
123+
[Short description.]
124+
:::
125+
```
126+
127+
## Step 5: Update AGENTS.md
128+
129+
Add the new enricher to the **"Built-in Enrichers"** table in the root `AGENTS.md` file, in the "Event Enrichment" section:
130+
131+
```markdown
132+
| {DISPLAY} | `evlog/enrichers` | `{name}` | [Description] |
133+
```
134+
135+
## Step 6: Update READMEs
136+
137+
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.
138+
139+
## Verification
140+
141+
After completing all steps, run:
142+
143+
```bash
144+
cd packages/evlog
145+
bun run build # Verify build succeeds
146+
bun run test # Verify tests pass
147+
```
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Enricher Source Template
2+
3+
Template for adding a new enricher to `packages/evlog/src/enrichers/index.ts`.
4+
5+
Replace `{Name}`, `{name}`, and `{DISPLAY}` with the actual enricher name.
6+
7+
## Info Interface
8+
9+
Define the output shape of the enricher:
10+
11+
```typescript
12+
export interface {Name}Info {
13+
/** Description of field */
14+
field1?: string
15+
/** Description of field */
16+
field2?: number
17+
}
18+
```
19+
20+
## Factory Function
21+
22+
```typescript
23+
/**
24+
* Enrich events with {DISPLAY} data.
25+
* Sets `event.{name}` with `{Name}Info` shape: `{ field1?, field2? }`.
26+
*/
27+
export function create{Name}Enricher(options: EnricherOptions = {}): (ctx: EnrichContext) => void {
28+
return (ctx) => {
29+
// 1. Extract data from headers (case-insensitive)
30+
const value = getHeader(ctx.headers, 'x-my-header')
31+
if (!value) return // Early return if no data available
32+
33+
// 2. Compute the enriched data
34+
const info: {Name}Info = {
35+
field1: value,
36+
field2: Number(value),
37+
}
38+
39+
// 3. Merge with existing event field (respects overwrite option)
40+
ctx.event.{name} = mergeEventField<{Name}Info>(ctx.event.{name}, info, options.overwrite)
41+
}
42+
}
43+
```
44+
45+
## Architecture Rules
46+
47+
1. **Use existing helpers** -- `getHeader()` for case-insensitive header lookup, `mergeEventField()` for safe merging, `normalizeNumber()` for parsing numeric strings
48+
2. **Single event field** -- each enricher sets one top-level field on `ctx.event`
49+
3. **Factory pattern** -- always return a function, never execute directly
50+
4. **EnricherOptions** -- accept `{ overwrite?: boolean }` for merge control
51+
5. **Early return** -- skip if required data is missing
52+
6. **No side effects** -- never throw, never log, only mutate `ctx.event`
53+
7. **Clean undefined values** -- skip the enricher entirely if all computed values are `undefined`
54+
55+
## Available Helpers
56+
57+
These helpers are already defined in the enrichers file:
58+
59+
```typescript
60+
// Case-insensitive header lookup
61+
function getHeader(headers: Record<string, string> | undefined, name: string): string | undefined
62+
63+
// Merge computed data with existing event fields, respecting overwrite
64+
function mergeEventField<T extends object>(existing: unknown, computed: T, overwrite?: boolean): T
65+
66+
// Parse string to number, returning undefined for non-finite values
67+
function normalizeNumber(value: string | undefined): number | undefined
68+
```
69+
70+
## Data Sources
71+
72+
Enrichers typically read from:
73+
74+
- **`ctx.headers`** -- HTTP request headers (sensitive headers already filtered)
75+
- **`ctx.response?.headers`** -- HTTP response headers
76+
- **`ctx.response?.status`** -- HTTP response status code
77+
- **`ctx.request`** -- Request metadata (method, path, requestId)
78+
- **`process.env`** -- Environment variables (for deployment metadata)
79+
- **`ctx.event`** -- The event itself (for computed/derived fields)

AGENTS.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ evlog/
4040
│ ├── src/
4141
│ │ ├── nuxt/ # Nuxt module
4242
│ │ ├── nitro/ # Nitro plugin
43+
│ │ ├── adapters/ # Log drain adapters (Axiom, OTLP, PostHog, Sentry)
44+
│ │ ├── enrichers/ # Built-in enrichers (UserAgent, Geo, RequestSize, TraceContext)
4345
│ │ └── runtime/ # Runtime code (client/, server/, utils/)
4446
│ └── test/ # Tests
4547
└── .github/ # CI/CD workflows
@@ -328,6 +330,66 @@ export default defineNuxtConfig({
328330
})
329331
```
330332

333+
#### Event Enrichment
334+
335+
Enrichers add derived context to wide events after emit, before drain. Use the `evlog:enrich` hook to register enrichers.
336+
337+
> **Creating a new enricher?** Follow the skill at `.agents/skills/create-enricher/SKILL.md`. It covers all touchpoints: source code, tests, and documentation updates.
338+
339+
**Built-in Enrichers:**
340+
341+
| Enricher | Import | Event Field | Description |
342+
|----------|--------|-------------|-------------|
343+
| User Agent | `evlog/enrichers` | `userAgent` | Parse browser, OS, device type from User-Agent header |
344+
| Geo | `evlog/enrichers` | `geo` | Extract country, region, city from platform headers (Vercel, Cloudflare) |
345+
| Request Size | `evlog/enrichers` | `requestSize` | Capture request/response payload sizes from Content-Length |
346+
| Trace Context | `evlog/enrichers` | `traceContext` | Extract W3C trace context (traceId, spanId) from traceparent header |
347+
348+
**Using Built-in Enrichers:**
349+
350+
```typescript
351+
// server/plugins/evlog-enrich.ts
352+
import {
353+
createUserAgentEnricher,
354+
createGeoEnricher,
355+
createRequestSizeEnricher,
356+
createTraceContextEnricher,
357+
} from 'evlog/enrichers'
358+
359+
export default defineNitroPlugin((nitroApp) => {
360+
const enrichers = [
361+
createUserAgentEnricher(),
362+
createGeoEnricher(),
363+
createRequestSizeEnricher(),
364+
createTraceContextEnricher(),
365+
]
366+
367+
nitroApp.hooks.hook('evlog:enrich', (ctx) => {
368+
for (const enricher of enrichers) enricher(ctx)
369+
})
370+
})
371+
```
372+
373+
**Custom Enricher:**
374+
375+
```typescript
376+
// server/plugins/evlog-enrich.ts
377+
export default defineNitroPlugin((nitroApp) => {
378+
nitroApp.hooks.hook('evlog:enrich', (ctx) => {
379+
ctx.event.deploymentId = process.env.DEPLOYMENT_ID
380+
ctx.event.region = process.env.FLY_REGION
381+
})
382+
})
383+
```
384+
385+
The `EnrichContext` contains:
386+
- `event`: The emitted `WideEvent` (mutable — add or modify fields directly)
387+
- `request`: Optional request metadata (`method`, `path`, `requestId`)
388+
- `headers`: Safe HTTP headers (sensitive headers are filtered)
389+
- `response`: Optional response metadata (`status`, `headers`)
390+
391+
All enrichers accept `{ overwrite?: boolean }` — defaults to `false` to preserve user-provided data.
392+
331393
### Nitro
332394

333395
```typescript

0 commit comments

Comments
 (0)