Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions .agents/skills/create-enricher/SKILL.md
Original file line number Diff line number Diff line change
@@ -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
```
79 changes: 79 additions & 0 deletions .agents/skills/create-enricher/references/enricher-template.md
Original file line number Diff line number Diff line change
@@ -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<string, string> | undefined, name: string): string | undefined

// Merge computed data with existing event fields, respecting overwrite
function mergeEventField<T extends object>(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)
62 changes: 62 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading