Skip to content

Commit acf5e2c

Browse files
authored
feat: add /create-adapter agent skill (#49)
1 parent 4f128db commit acf5e2c

File tree

3 files changed

+517
-0
lines changed

3 files changed

+517
-0
lines changed
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
---
2+
name: create-evlog-adapter
3+
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.
4+
---
5+
6+
# Create evlog Adapter
7+
8+
Add a new built-in adapter to evlog. Every adapter follows the same architecture. This skill walks through all 5 touchpoints.
9+
10+
## Touchpoints Checklist
11+
12+
| # | File | Action |
13+
|---|------|--------|
14+
| 1 | `packages/evlog/src/adapters/{name}.ts` | Create adapter source |
15+
| 2 | `packages/evlog/build.config.ts` | Add build entry |
16+
| 3 | `packages/evlog/package.json` | Add `exports` + `typesVersions` entries |
17+
| 4 | `packages/evlog/test/adapters/{name}.test.ts` | Create tests |
18+
| 5 | `apps/docs/content/3.adapters/{n}.{name}.md` | Create doc page (before `custom.md`) |
19+
20+
After all 5 steps, update `AGENTS.md` to list the new adapter in the adapters table.
21+
22+
## Naming Conventions
23+
24+
Use these placeholders consistently:
25+
26+
| Placeholder | Example (Datadog) | Usage |
27+
|-------------|-------------------|-------|
28+
| `{name}` | `datadog` | File names, import paths, env var suffix |
29+
| `{Name}` | `Datadog` | PascalCase in function/interface names |
30+
| `{NAME}` | `DATADOG` | SCREAMING_CASE in env var prefixes |
31+
32+
## Step 1: Adapter Source
33+
34+
Create `packages/evlog/src/adapters/{name}.ts`.
35+
36+
Read [references/adapter-template.md](references/adapter-template.md) for the full annotated template.
37+
38+
Key architecture rules:
39+
40+
1. **Config interface** -- service-specific fields (API key, endpoint, etc.) plus optional `timeout?: number`
41+
2. **`getRuntimeConfig()` helper** -- dynamic `require('nitropack/runtime')` wrapped in try/catch
42+
3. **Config priority** (highest to lowest):
43+
- Overrides passed to `create{Name}Drain()`
44+
- `runtimeConfig.evlog.{name}`
45+
- `runtimeConfig.{name}`
46+
- Environment variables: `NUXT_{NAME}_*` then `{NAME}_*`
47+
4. **Factory function** -- `create{Name}Drain(overrides?: Partial<Config>)` returns `(ctx: DrainContext) => Promise<void>`
48+
5. **Exported send functions** -- `sendTo{Name}(event, config)` and `sendBatchTo{Name}(events, config)` for direct use and testability
49+
6. **Error handling** -- try/catch with `console.error('[evlog/{name}] ...')`, never throw from the drain
50+
7. **Timeout** -- `AbortController` with 5000ms default, configurable via `config.timeout`
51+
8. **Event transformation** -- if the service needs a specific format, export a `to{Name}Event()` converter
52+
53+
## Step 2: Build Config
54+
55+
Add a build entry in `packages/evlog/build.config.ts` alongside the existing adapters:
56+
57+
```typescript
58+
{ input: 'src/adapters/{name}', name: 'adapters/{name}' },
59+
```
60+
61+
Place it after the last adapter entry (currently `posthog` at line ~21).
62+
63+
## Step 3: Package Exports
64+
65+
In `packages/evlog/package.json`, add two entries:
66+
67+
**In `exports`** (after the last adapter, currently `./posthog`):
68+
69+
```json
70+
"./{name}": {
71+
"types": "./dist/adapters/{name}.d.mts",
72+
"import": "./dist/adapters/{name}.mjs"
73+
}
74+
```
75+
76+
**In `typesVersions["*"]`** (after the last adapter):
77+
78+
```json
79+
"{name}": [
80+
"./dist/adapters/{name}.d.mts"
81+
]
82+
```
83+
84+
## Step 4: Tests
85+
86+
Create `packages/evlog/test/adapters/{name}.test.ts`.
87+
88+
Read [references/test-template.md](references/test-template.md) for the full annotated template.
89+
90+
Required test categories:
91+
92+
1. URL construction (default + custom endpoint)
93+
2. Headers (auth, content-type, service-specific)
94+
3. Request body format (JSON structure matches service API)
95+
4. Error handling (non-OK responses throw with status)
96+
5. Batch operations (`sendBatchTo{Name}`)
97+
6. Timeout handling (default 5000ms + custom)
98+
99+
## Step 5: Documentation
100+
101+
Create `apps/docs/content/3.adapters/{n}.{name}.md` where `{n}` is the next number before `custom.md` (custom should always be last).
102+
103+
Use this frontmatter structure:
104+
105+
```yaml
106+
---
107+
title: "{Name} Adapter"
108+
description: "Send logs to {Name} for [value prop]. Zero-config setup with environment variables."
109+
navigation:
110+
title: "{Name}"
111+
icon: i-simple-icons-{name} # or i-lucide-* for generic
112+
links:
113+
- label: "{Name} Dashboard"
114+
icon: i-lucide-external-link
115+
to: https://{service-url}
116+
target: _blank
117+
color: neutral
118+
variant: subtle
119+
- label: "OTLP Adapter"
120+
icon: i-simple-icons-opentelemetry
121+
to: /adapters/otlp
122+
color: neutral
123+
variant: subtle
124+
---
125+
```
126+
127+
Sections to include:
128+
129+
1. **Intro paragraph** -- what the service is and what the adapter does
130+
2. **Installation** -- import path `evlog/{name}`
131+
3. **Quick Setup** -- Nitro plugin with `create{Name}Drain()`
132+
4. **Configuration** -- table of env vars and config options
133+
5. **Configuration Priority** -- overrides > runtimeConfig > env vars
134+
6. **Advanced** -- custom options, event transformation details
135+
7. **Querying/Using** -- how to find evlog events in the target service
136+
137+
Renumber `custom.md` if needed so it stays last.
138+
139+
## Final Step: Update AGENTS.md
140+
141+
Add the new adapter to the adapters table in the root `AGENTS.md` file, in the "Log Draining & Adapters" section:
142+
143+
```markdown
144+
| {Name} | `evlog/{name}` | Send logs to {Name} for [description] |
145+
```
146+
147+
## Verification
148+
149+
After completing all steps, run:
150+
151+
```bash
152+
cd packages/evlog
153+
bun run build # Verify build succeeds with new entry
154+
bun run test # Verify tests pass
155+
```
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
# Adapter Source Template
2+
3+
Complete TypeScript template for `packages/evlog/src/adapters/{name}.ts`.
4+
5+
Replace `{Name}`, `{name}`, and `{NAME}` with the actual service name.
6+
7+
```typescript
8+
import type { DrainContext, WideEvent } from '../types'
9+
10+
// --- 1. Config Interface ---
11+
// Define all service-specific configuration fields.
12+
// Always include optional `timeout`.
13+
export interface {Name}Config {
14+
/** {Name} API key / token */
15+
apiKey: string
16+
/** {Name} API endpoint. Default: https://api.{name}.com */
17+
endpoint?: string
18+
/** Request timeout in milliseconds. Default: 5000 */
19+
timeout?: number
20+
// Add service-specific fields here (dataset, project, region, etc.)
21+
}
22+
23+
// --- 2. Event Transformation (optional) ---
24+
// Export a converter if the service needs a specific format.
25+
// This makes the transformation testable independently.
26+
27+
/** {Name} event structure */
28+
export interface {Name}Event {
29+
// Define the target service's event shape
30+
timestamp: string
31+
level: string
32+
data: Record<string, unknown>
33+
}
34+
35+
/**
36+
* Convert a WideEvent to {Name}'s event format.
37+
*/
38+
export function to{Name}Event(event: WideEvent): {Name}Event {
39+
const { timestamp, level, ...rest } = event
40+
41+
return {
42+
timestamp,
43+
level,
44+
data: rest,
45+
}
46+
}
47+
48+
// --- 3. Runtime Config Helper ---
49+
// Dynamic require to avoid bundling issues outside Nitro.
50+
// Returns undefined when not in a Nitro context.
51+
function getRuntimeConfig(): {
52+
evlog?: { {name}?: Partial<{Name}Config> }
53+
{name}?: Partial<{Name}Config>
54+
} | undefined {
55+
try {
56+
// eslint-disable-next-line @typescript-eslint/no-require-imports
57+
const { useRuntimeConfig } = require('nitropack/runtime')
58+
return useRuntimeConfig()
59+
} catch {
60+
return undefined
61+
}
62+
}
63+
64+
// --- 4. Factory Function ---
65+
// Returns a drain function that resolves config at call time.
66+
// Config priority: overrides > runtimeConfig.evlog.{name} > runtimeConfig.{name} > env vars
67+
68+
/**
69+
* Create a drain function for sending logs to {Name}.
70+
*
71+
* Configuration priority (highest to lowest):
72+
* 1. Overrides passed to create{Name}Drain()
73+
* 2. runtimeConfig.evlog.{name}
74+
* 3. runtimeConfig.{name}
75+
* 4. Environment variables: NUXT_{NAME}_*, {NAME}_*
76+
*
77+
* @example
78+
* ```ts
79+
* // Zero config - set NUXT_{NAME}_API_KEY env var
80+
* nitroApp.hooks.hook('evlog:drain', create{Name}Drain())
81+
*
82+
* // With overrides
83+
* nitroApp.hooks.hook('evlog:drain', create{Name}Drain({
84+
* apiKey: 'my-key',
85+
* }))
86+
* ```
87+
*/
88+
export function create{Name}Drain(overrides?: Partial<{Name}Config>): (ctx: DrainContext) => Promise<void> {
89+
return async (ctx: DrainContext) => {
90+
const runtimeConfig = getRuntimeConfig()
91+
const evlogConfig = runtimeConfig?.evlog?.{name}
92+
const rootConfig = runtimeConfig?.{name}
93+
94+
// Build config with fallbacks
95+
const config: Partial<{Name}Config> = {
96+
apiKey: overrides?.apiKey ?? evlogConfig?.apiKey ?? rootConfig?.apiKey
97+
?? process.env.NUXT_{NAME}_API_KEY ?? process.env.{NAME}_API_KEY,
98+
endpoint: overrides?.endpoint ?? evlogConfig?.endpoint ?? rootConfig?.endpoint
99+
?? process.env.NUXT_{NAME}_ENDPOINT ?? process.env.{NAME}_ENDPOINT,
100+
timeout: overrides?.timeout ?? evlogConfig?.timeout ?? rootConfig?.timeout,
101+
}
102+
103+
// Validate required fields
104+
if (!config.apiKey) {
105+
console.error('[evlog/{name}] Missing apiKey. Set NUXT_{NAME}_API_KEY env var or pass to create{Name}Drain()')
106+
return
107+
}
108+
109+
try {
110+
await sendTo{Name}(ctx.event, config as {Name}Config)
111+
} catch (error) {
112+
console.error('[evlog/{name}] Failed to send event:', error)
113+
}
114+
}
115+
}
116+
117+
// --- 5. Send Functions ---
118+
// Exported for direct use and testability.
119+
// sendTo{Name} wraps sendBatchTo{Name} for single events.
120+
121+
/**
122+
* Send a single event to {Name}.
123+
*/
124+
export async function sendTo{Name}(event: WideEvent, config: {Name}Config): Promise<void> {
125+
await sendBatchTo{Name}([event], config)
126+
}
127+
128+
/**
129+
* Send a batch of events to {Name}.
130+
*/
131+
export async function sendBatchTo{Name}(events: WideEvent[], config: {Name}Config): Promise<void> {
132+
if (events.length === 0) return
133+
134+
const endpoint = (config.endpoint ?? 'https://api.{name}.com').replace(/\/$/, '')
135+
const timeout = config.timeout ?? 5000
136+
// Construct the full URL for the service's ingest API
137+
const url = `${endpoint}/v1/ingest`
138+
139+
const headers: Record<string, string> = {
140+
'Content-Type': 'application/json',
141+
'Authorization': `Bearer ${config.apiKey}`,
142+
// Add service-specific headers here
143+
}
144+
145+
// Transform events if the service needs a specific format
146+
const payload = events.map(to{Name}Event)
147+
// Or send raw: JSON.stringify(events)
148+
149+
const controller = new AbortController()
150+
const timeoutId = setTimeout(() => controller.abort(), timeout)
151+
152+
try {
153+
const response = await fetch(url, {
154+
method: 'POST',
155+
headers,
156+
body: JSON.stringify(payload),
157+
signal: controller.signal,
158+
})
159+
160+
if (!response.ok) {
161+
const text = await response.text().catch(() => 'Unknown error')
162+
const safeText = text.length > 200 ? `${text.slice(0, 200)}...[truncated]` : text
163+
throw new Error(`{Name} API error: ${response.status} ${response.statusText} - ${safeText}`)
164+
}
165+
} finally {
166+
clearTimeout(timeoutId)
167+
}
168+
}
169+
```
170+
171+
## Customization Notes
172+
173+
- **Auth style**: Some services use `Authorization: Bearer`, others use a custom header like `X-API-Key`. Adjust the headers accordingly.
174+
- **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.
175+
- **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.
176+
- **URL construction**: Match the service's API endpoint pattern. Some use path-based routing (`/v1/datasets/{id}/ingest`), others use a flat endpoint (`/batch/`).
177+
- **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).

0 commit comments

Comments
 (0)