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
155 changes: 155 additions & 0 deletions .agents/skills/create-adapter/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
---
name: create-evlog-adapter
description: Create a new built-in evlog adapter to send wide events to an external observability platform. Use when adding a new drain adapter (e.g., for Datadog, Sentry, Loki, Elasticsearch, etc.) to the evlog package. Covers source code, build config, package exports, tests, and documentation.
---

# Create evlog Adapter

Add a new built-in adapter to evlog. Every adapter follows the same architecture. This skill walks through all 5 touchpoints.

## Touchpoints Checklist

| # | File | Action |
|---|------|--------|
| 1 | `packages/evlog/src/adapters/{name}.ts` | Create adapter source |
| 2 | `packages/evlog/build.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/3.adapters/{n}.{name}.md` | Create doc page (before `custom.md`) |

After all 5 steps, update `AGENTS.md` to list the new adapter in the adapters table.

## Naming Conventions

Use these placeholders consistently:

| Placeholder | Example (Datadog) | Usage |
|-------------|-------------------|-------|
| `{name}` | `datadog` | File names, import paths, env var suffix |
| `{Name}` | `Datadog` | PascalCase in function/interface names |
| `{NAME}` | `DATADOG` | SCREAMING_CASE in env var prefixes |

## Step 1: Adapter Source

Create `packages/evlog/src/adapters/{name}.ts`.

Read [references/adapter-template.md](references/adapter-template.md) for the full annotated template.

Key architecture rules:

1. **Config interface** -- service-specific fields (API key, endpoint, etc.) plus optional `timeout?: number`
2. **`getRuntimeConfig()` helper** -- dynamic `require('nitropack/runtime')` wrapped in try/catch
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

## Step 2: Build Config

Add a build entry in `packages/evlog/build.config.ts` alongside the existing adapters:

```typescript
{ input: 'src/adapters/{name}', name: 'adapters/{name}' },
```

Place it after the last adapter entry (currently `posthog` at line ~21).

## Step 3: Package Exports

In `packages/evlog/package.json`, add two entries:

**In `exports`** (after the last adapter, currently `./posthog`):

```json
"./{name}": {
"types": "./dist/adapters/{name}.d.mts",
"import": "./dist/adapters/{name}.mjs"
}
```

**In `typesVersions["*"]`** (after the last adapter):

```json
"{name}": [
"./dist/adapters/{name}.d.mts"
]
```

## Step 4: Tests

Create `packages/evlog/test/adapters/{name}.test.ts`.

Read [references/test-template.md](references/test-template.md) for the full annotated template.

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)

## Step 5: Documentation

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

Use this frontmatter structure:

```yaml
---
title: "{Name} Adapter"
description: "Send logs to {Name} for [value prop]. Zero-config setup with environment variables."
navigation:
title: "{Name}"
icon: i-simple-icons-{name} # or i-lucide-* for generic
links:
- label: "{Name} Dashboard"
icon: i-lucide-external-link
to: https://{service-url}
target: _blank
color: neutral
variant: subtle
- label: "OTLP Adapter"
icon: i-simple-icons-opentelemetry
to: /adapters/otlp
color: neutral
variant: subtle
---
```

Sections to include:

1. **Intro paragraph** -- what the service is and what the adapter does
2. **Installation** -- import path `evlog/{name}`
3. **Quick Setup** -- Nitro plugin with `create{Name}Drain()`
4. **Configuration** -- table of env vars and config options
5. **Configuration Priority** -- overrides > runtimeConfig > env vars
6. **Advanced** -- custom options, event transformation details
7. **Querying/Using** -- how to find evlog events in the target service

Renumber `custom.md` if needed so it stays last.

## Final Step: Update AGENTS.md

Add the new adapter to the adapters table in the root `AGENTS.md` file, in the "Log Draining & Adapters" section:

```markdown
| {Name} | `evlog/{name}` | Send logs to {Name} for [description] |
```

## Verification

After completing all steps, run:

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

Complete TypeScript template for `packages/evlog/src/adapters/{name}.ts`.

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

```typescript
import type { DrainContext, WideEvent } from '../types'

// --- 1. Config Interface ---
// Define all service-specific configuration fields.
// Always include optional `timeout`.
export interface {Name}Config {
/** {Name} API key / token */
apiKey: string
/** {Name} API endpoint. Default: https://api.{name}.com */
endpoint?: string
/** Request timeout in milliseconds. Default: 5000 */
timeout?: number
// 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.

/** {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.
*/
export function to{Name}Event(event: WideEvent): {Name}Event {
const { timestamp, level, ...rest } = event

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

// --- 3. Runtime Config Helper ---
// Dynamic require to avoid bundling issues outside Nitro.
// Returns undefined when not in a Nitro context.
function getRuntimeConfig(): {
evlog?: { {name}?: Partial<{Name}Config> }
{name}?: Partial<{Name}Config>
} | undefined {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { useRuntimeConfig } = require('nitropack/runtime')
return useRuntimeConfig()
} catch {
return undefined
}
}

// --- 4. 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)
}
}
}

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

/**
* 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> {
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 controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)

try {
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(payload),
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}`)
}
} finally {
clearTimeout(timeoutId)
}
}
```

## 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).
Loading
Loading