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
70 changes: 39 additions & 31 deletions .agents/skills/create-adapter/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: Create a new built-in evlog adapter to send wide events to an exter

# Create evlog Adapter

Add a new built-in adapter to evlog. Every adapter follows the same architecture. This skill walks through all 8 touchpoints. **Every single touchpoint is mandatory** -- do not skip any.
Add a new built-in adapter to evlog. Every adapter follows the same architecture and is built on the public toolkit primitives in `evlog/toolkit` — so a community adapter has the same shape as a built-in one.

## PR Title

Expand All @@ -21,12 +21,12 @@ The exact wording may vary depending on the adapter (e.g., `feat: add OTLP adapt

| # | File | Action |
|---|------|--------|
| 1 | `packages/evlog/src/adapters/{name}.ts` | Create adapter source |
| 1 | `packages/evlog/src/adapters/{name}.ts` | Create adapter source (built on `defineHttpDrain` from `../shared/drain`) |
| 2 | `packages/evlog/tsdown.config.ts` | Add build entry |
| 3 | `packages/evlog/package.json` | Add `exports` + `typesVersions` entries |
| 4 | `packages/evlog/test/adapters/{name}.test.ts` | Create tests |
| 5 | `apps/docs/content/5.adapters/{n}.{name}.md` | Create adapter doc page (before `custom.md`) |
| 6 | `apps/docs/content/5.adapters/1.overview.md` | Add adapter to overview (links, card, env vars) |
| 5 | `apps/docs/content/4.adapters/{n}.{name}.md` | Create adapter doc page (before `custom.md`) |
| 6 | `apps/docs/content/4.adapters/1.overview.md` | Add adapter to overview (links, card, env vars) |
| 7 | `skills/review-logging-patterns/SKILL.md` | Add adapter row in the Drain Adapters table |
| 8 | Renumber `custom.md` | Ensure `custom.md` stays last after the new adapter |

Expand All @@ -42,26 +42,32 @@ Use these placeholders consistently:
| `{Name}` | `Datadog` | PascalCase in function/interface names |
| `{NAME}` | `DATADOG` | SCREAMING_CASE in env var prefixes |

## Step 1: Adapter Source
Standard option naming (use these exact names):

Create `packages/evlog/src/adapters/{name}.ts`.
| Concept | Standard option name |
|---------|---------------------|
| Bearer-style API secret | `apiKey` |
| Base URL of the ingest API | `endpoint` |
| Service identifier | `serviceName` |
| Request timeout (ms) | `timeout` |

Read [references/adapter-template.md](references/adapter-template.md) for the full annotated template.
If a service historically used a different name (`token`, `sourceToken`, …) keep it as a deprecated alias — see Axiom and Better Stack for the pattern.

Key architecture rules:
## Step 1: Adapter Source — built on `defineHttpDrain`

1. **Config interface** -- service-specific fields (API key, endpoint, etc.) plus optional `timeout?: number`
2. **`getRuntimeConfig()`** -- import from `./_utils` (shared helper, do NOT redefine locally)
3. **Config priority** (highest to lowest):
- Overrides passed to `create{Name}Drain()`
- `runtimeConfig.evlog.{name}`
- `runtimeConfig.{name}`
- Environment variables: `NUXT_{NAME}_*` then `{NAME}_*`
4. **Factory function** -- `create{Name}Drain(overrides?: Partial<Config>)` returns `(ctx: DrainContext) => Promise<void>`
5. **Exported send functions** -- `sendTo{Name}(event, config)` and `sendBatchTo{Name}(events, config)` for direct use and testability
6. **Error handling** -- try/catch with `console.error('[evlog/{name}] ...')`, never throw from the drain
7. **Timeout** -- `AbortController` with 5000ms default, configurable via `config.timeout`
8. **Event transformation** -- if the service needs a specific format, export a `to{Name}Event()` converter
Create `packages/evlog/src/adapters/{name}.ts`. Read [references/adapter-template.md](references/adapter-template.md) for the full annotated template.

The contract is now `defineHttpDrain<TConfig>({ resolve, encode })`. You only ship two pieces of logic:

1. **`resolve()`** — produce a fully-resolved config or `null` to skip. Use `resolveAdapterConfig` for the standard precedence (overrides → `runtimeConfig.evlog.{name}` → `runtimeConfig.{name}` → `NUXT_{NAME}_*` → `{NAME}_*`).
2. **`encode(events, config)`** — produce `{ url, headers, body }` for a batch of events (or `null` to skip). HTTP transport, retries, timeout, and error logging are handled by `defineHttpDrain`.

Key rules:

- **Single factory.** Export one `create{Name}Drain(overrides?: Partial<{Name}Config>)`. No dual-API factories: if a service has multiple ingest modes (logs vs events), expose them via a `mode` option (see PostHog).
- **No HTTP code in the adapter.** Don't call `fetch` directly — let `defineHttpDrain` do it. If your service truly needs custom transport (e.g. binary envelopes), use `defineDrain` and call `httpPost` from `evlog/toolkit`.
- **No bespoke config resolution.** Always go through `resolveAdapterConfig`. If you need to support a deprecated alias (`token` → `apiKey`), include both in the `ConfigField[]` and fall through in `resolve()`.
- **Exported converters.** If the service needs a specific event shape, export a `to{Name}Event()` (or `buildPayload()`) helper so it can be tested independently.

## Step 2: Build Config

Expand Down Expand Up @@ -105,35 +111,35 @@ Required test categories:
1. URL construction (default + custom endpoint)
2. Headers (auth, content-type, service-specific)
3. Request body format (JSON structure matches service API)
4. Error handling (non-OK responses throw with status)
5. Batch operations (`sendBatchTo{Name}`)
6. Timeout handling (default 5000ms + custom)
4. Skip behavior when `apiKey` (or required field) is missing
5. Batch operations
6. Deprecated alias still works (when applicable)

## Step 5: Adapter Documentation Page

Create `apps/docs/content/4.adapters/{n}.{name}.md` where `{n}` is the next number before `custom.md` (custom should always be last).

Use the existing Axiom adapter page (`apps/docs/content/5.adapters/2.axiom.md`) as a reference for frontmatter structure, tone, and sections. Key sections: intro, quick setup, configuration (env vars table + priority), advanced usage, querying in the target service, troubleshooting, direct API usage, next steps.
Use the existing Axiom adapter page (`apps/docs/content/4.adapters/2.axiom.md`) as a reference for frontmatter structure, tone, and sections. Key sections: intro, quick setup, configuration (env vars table + priority), advanced usage, querying in the target service, troubleshooting, direct API usage, next steps.

**Important: multi-framework examples.** The Quick Start section must include a `::code-group` with tabs for all supported frameworks (Nuxt/Nitro, Hono, Express, Fastify, Elysia, NestJS, Standalone). Do not only show Nitro examples. See any existing adapter page for the pattern.

## Step 6: Update Adapters Overview Page

Edit `apps/docs/content/4.adapters/1.overview.md` to add the new adapter in **three** places (follow the pattern of existing adapters):

1. **Frontmatter `links` array** -- add a link entry with icon and path
2. **`::card-group` section** -- add a card block before the Custom card
3. **Zero-Config Setup `.env` example** -- add the adapter's env vars
1. **Frontmatter `links` array** add a link entry with icon and path
2. **`::card-group` section** add a card block before the Custom card
3. **Zero-Config Setup `.env` example** add the adapter's env vars

## Step 7: Update `skills/review-logging-patterns/SKILL.md`

In `skills/review-logging-patterns/SKILL.md` (the public skill distributed to users), find the **Drain Adapters** table and add a new row:

```markdown
| {Name} | `evlog/{name}` | `{NAME}_TOKEN`, `{NAME}_DATASET` (or equivalent) |
| {Name} | `evlog/{name}` | `{NAME}_API_KEY`, `{NAME}_DATASET` (or equivalent) |
```

Follow the pattern of the existing rows (Axiom, OTLP, PostHog, Sentry, Better Stack). No additional usage example block is needed — the table entry is sufficient.
Follow the pattern of the existing rows (Axiom, OTLP, PostHog, Sentry, Better Stack).

## Step 8: Renumber `custom.md`

Expand All @@ -145,6 +151,8 @@ After completing all steps, run:

```bash
cd packages/evlog
pnpm run build # Verify build succeeds with new entry
pnpm run test # Verify tests pass
pnpm run lint
pnpm run typecheck
pnpm run test
pnpm run build
```
196 changes: 86 additions & 110 deletions .agents/skills/create-adapter/references/adapter-template.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
# Adapter Source Template

Complete TypeScript template for `packages/evlog/src/adapters/{name}.ts`.
Complete TypeScript template for `packages/evlog/src/adapters/{name}.ts` using the public toolkit primitives `defineHttpDrain` + `resolveAdapterConfig`.

Replace `{Name}`, `{name}`, and `{NAME}` with the actual service name.

```typescript
import type { DrainContext, WideEvent } from '../types'
import { getRuntimeConfig } from './_utils'
import type { WideEvent } from '../types'
import type { ConfigField } from '../shared/config'
import { resolveAdapterConfig } from '../shared/config'
import { defineHttpDrain } from '../shared/drain'

// --- 1. Config Interface -------------------------------------------------
// Service-specific fields. Standard names: apiKey, endpoint, serviceName, timeout.

// --- 1. Config Interface ---
// Define all service-specific configuration fields.
// Always include optional `timeout`.
export interface {Name}Config {
/** {Name} API key / token */
/** {Name} API key */
apiKey: string
/** {Name} API endpoint. Default: https://api.{name}.com */
endpoint?: string
Expand All @@ -21,142 +23,116 @@ export interface {Name}Config {
// Add service-specific fields here (dataset, project, region, etc.)
}

// --- 2. Event Transformation (optional) ---
// Export a converter if the service needs a specific format.
// This makes the transformation testable independently.
// Field manifest — drives both resolveAdapterConfig and runtime-config-aware
// drain initialization.
const FIELDS: ConfigField<{Name}Config>[] = [
{ key: 'apiKey', env: ['NUXT_{NAME}_API_KEY', '{NAME}_API_KEY'] },
{ key: 'endpoint', env: ['NUXT_{NAME}_ENDPOINT', '{NAME}_ENDPOINT'] },
{ key: 'timeout' },
]

// --- 2. Event Transformation (optional) ----------------------------------
// If the service needs a specific shape, expose a converter so it's testable
// independently. Otherwise pass `events` straight through in `encode`.

/** {Name} event structure */
export interface {Name}Event {
// Define the target service's event shape
timestamp: string
level: string
data: Record<string, unknown>
}

/**
* Convert a WideEvent to {Name}'s event format.
*/
/** Convert a WideEvent to {Name}'s event format. */
export function to{Name}Event(event: WideEvent): {Name}Event {
const { timestamp, level, ...rest } = event

return {
timestamp,
level,
data: rest,
}
return { timestamp, level, data: rest }
}

// --- 3. Factory Function ---
// Returns a drain function that resolves config at call time.
// Config priority: overrides > runtimeConfig.evlog.{name} > runtimeConfig.{name} > env vars

/**
* Create a drain function for sending logs to {Name}.
*
* Configuration priority (highest to lowest):
* 1. Overrides passed to create{Name}Drain()
* 2. runtimeConfig.evlog.{name}
* 3. runtimeConfig.{name}
* 4. Environment variables: NUXT_{NAME}_*, {NAME}_*
*
* @example
* ```ts
* // Zero config - set NUXT_{NAME}_API_KEY env var
* nitroApp.hooks.hook('evlog:drain', create{Name}Drain())
*
* // With overrides
* nitroApp.hooks.hook('evlog:drain', create{Name}Drain({
* apiKey: 'my-key',
* }))
* ```
*/
export function create{Name}Drain(overrides?: Partial<{Name}Config>): (ctx: DrainContext) => Promise<void> {
return async (ctx: DrainContext) => {
const runtimeConfig = getRuntimeConfig()
const evlogConfig = runtimeConfig?.evlog?.{name}
const rootConfig = runtimeConfig?.{name}

// Build config with fallbacks
const config: Partial<{Name}Config> = {
apiKey: overrides?.apiKey ?? evlogConfig?.apiKey ?? rootConfig?.apiKey
?? process.env.NUXT_{NAME}_API_KEY ?? process.env.{NAME}_API_KEY,
endpoint: overrides?.endpoint ?? evlogConfig?.endpoint ?? rootConfig?.endpoint
?? process.env.NUXT_{NAME}_ENDPOINT ?? process.env.{NAME}_ENDPOINT,
timeout: overrides?.timeout ?? evlogConfig?.timeout ?? rootConfig?.timeout,
}

// Validate required fields
if (!config.apiKey) {
console.error('[evlog/{name}] Missing apiKey. Set NUXT_{NAME}_API_KEY env var or pass to create{Name}Drain()')
return
}

try {
await sendTo{Name}(ctx.event, config as {Name}Config)
} catch (error) {
console.error('[evlog/{name}] Failed to send event:', error)
}
// --- 3. Encode helper (pure, easy to test) -------------------------------
function build{Name}Payload(events: WideEvent[], config: {Name}Config) {
const endpoint = (config.endpoint ?? 'https://api.{name}.com').replace(/\/$/, '')
return {
url: `${endpoint}/v1/ingest`,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.apiKey}`,
},
body: JSON.stringify(events.map(to{Name}Event)),
}
}

// --- 5. Send Functions ---
// --- 4. Direct send helpers ----------------------------------------------
// Exported for direct use and testability.
// sendTo{Name} wraps sendBatchTo{Name} for single events.

/**
* Send a single event to {Name}.
*/
/** Send a single event to {Name}. */
export async function sendTo{Name}(event: WideEvent, config: {Name}Config): Promise<void> {
await sendBatchTo{Name}([event], config)
}

/**
* Send a batch of events to {Name}.
*/
export async function sendBatchTo{Name}(events: WideEvent[], config: {Name}Config): Promise<void> {
/** Send a batch of events to {Name}. */
export async function sendBatchTo{Name}(
events: WideEvent[],
config: {Name}Config,
): Promise<void> {
if (events.length === 0) return

const endpoint = (config.endpoint ?? 'https://api.{name}.com').replace(/\/$/, '')
const timeout = config.timeout ?? 5000
// Construct the full URL for the service's ingest API
const url = `${endpoint}/v1/ingest`

const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.apiKey}`,
// Add service-specific headers here
}

// Transform events if the service needs a specific format
const payload = events.map(to{Name}Event)
// Or send raw: JSON.stringify(events)

const { url, headers, body } = build{Name}Payload(events, config)
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
const timeoutId = setTimeout(() => controller.abort(), config.timeout ?? 5000)

try {
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(payload),
signal: controller.signal,
})

const response = await fetch(url, { method: 'POST', headers, body, signal: controller.signal })
if (!response.ok) {
const text = await response.text().catch(() => 'Unknown error')
const safeText = text.length > 200 ? `${text.slice(0, 200)}...[truncated]` : text
throw new Error(`{Name} API error: ${response.status} ${response.statusText} - ${safeText}`)
const safe = text.length > 200 ? `${text.slice(0, 200)}...[truncated]` : text
throw new Error(`{Name} API error: ${response.status} ${response.statusText} - ${safe}`)
}
} finally {
clearTimeout(timeoutId)
}
}

// --- 5. Factory built on `defineHttpDrain` ------------------------------
/**
* Create a drain function for sending logs to {Name}.
*
* Configuration priority (highest to lowest):
* 1. Overrides passed to create{Name}Drain()
* 2. runtimeConfig.evlog.{name}
* 3. runtimeConfig.{name}
* 4. Environment variables: NUXT_{NAME}_*, {NAME}_*
*
* @example
* ```ts
* import { create{Name}Drain } from 'evlog/{name}'
*
* // Zero config — set NUXT_{NAME}_API_KEY env var
* defineEvlog({ drain: create{Name}Drain() })
*
* // With overrides
* defineEvlog({ drain: create{Name}Drain({ apiKey: 'my-key' }) })
* ```
*/
export function create{Name}Drain(overrides?: Partial<{Name}Config>) {
return defineHttpDrain<{Name}Config>({
name: '{name}',
timeout: overrides?.timeout,
resolve: async () => {
const config = await resolveAdapterConfig<{Name}Config>('{name}', FIELDS, overrides)
if (!config.apiKey) {
console.error('[evlog/{name}] Missing apiKey. Set NUXT_{NAME}_API_KEY env var or pass to create{Name}Drain()')
return null
}
return config as {Name}Config
},
encode: (events, config) => build{Name}Payload(events, config),
})
}
```

## Customization Notes

- **Auth style**: Some services use `Authorization: Bearer`, others use a custom header like `X-API-Key`. Adjust the headers accordingly.
- **Payload format**: Some services accept raw JSON arrays (Axiom), others need a wrapper object (PostHog `{ api_key, batch }`), others need a protocol-specific structure (OTLP). Adapt `sendBatchTo{Name}` to match.
- **Event transformation**: If the service expects a specific schema, implement `to{Name}Event()`. If the service accepts arbitrary JSON, you can skip it and send `ctx.event` directly.
- **URL construction**: Match the service's API endpoint pattern. Some use path-based routing (`/v1/datasets/{id}/ingest`), others use a flat endpoint (`/batch/`).
- **Extra config fields**: Add service-specific fields to the config interface (e.g., `dataset` for Axiom, `orgId` for org-scoped APIs, `host` for region selection).
- **Auth style**: Some services use `Authorization: Bearer`, others use a custom header like `X-API-Key`. Adjust `headers` in `build{Name}Payload`.
- **Payload format**: Some services accept raw JSON arrays (Axiom), others need a wrapper object (PostHog `{ api_key, batch }`), others need a protocol-specific structure (OTLP). Adapt `build{Name}Payload`.
- **Event transformation**: If the service expects a specific schema, implement `to{Name}Event()`. If it accepts arbitrary JSON, send `events` directly.
- **Custom transport**: If the service truly cannot fit `defineHttpDrain` (e.g. binary envelopes, gRPC), fall back to `defineDrain` from `../shared/drain` and call `httpPost` (from `../shared/http`) explicitly.
- **Deprecated aliases**: When renaming a config field (e.g. `token` → `apiKey`), keep both as `ConfigField` entries and fall through in `resolve()`. See `axiom.ts` and `better-stack.ts` for the pattern.
5 changes: 4 additions & 1 deletion .agents/skills/create-adapter/references/test-template.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,10 @@ describe('{name} adapter', () => {
expect(body).toHaveLength(1)
})

// --- 4. Error Handling ---
// --- 4. Error Handling (only the direct helper throws — the drain
// itself swallows errors via `defineHttpDrain` so the request
// pipeline is never interrupted; that contract is covered by
// `test/toolkit.test.ts`).
it('throws error on non-OK response', async () => {
fetchSpy.mockResolvedValueOnce(
new Response('Bad Request', { status: 400, statusText: 'Bad Request' }),
Expand Down
Loading
Loading