diff --git a/.agents/skills/create-adapter/SKILL.md b/.agents/skills/create-adapter/SKILL.md new file mode 100644 index 0000000..21522e3 --- /dev/null +++ b/.agents/skills/create-adapter/SKILL.md @@ -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)` returns `(ctx: DrainContext) => Promise` +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 +``` diff --git a/.agents/skills/create-adapter/references/adapter-template.md b/.agents/skills/create-adapter/references/adapter-template.md new file mode 100644 index 0000000..4e9f796 --- /dev/null +++ b/.agents/skills/create-adapter/references/adapter-template.md @@ -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 +} + +/** + * 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 { + 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 { + await sendBatchTo{Name}([event], config) +} + +/** + * Send a batch of events to {Name}. + */ +export async function sendBatchTo{Name}(events: WideEvent[], config: {Name}Config): Promise { + 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 = { + '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). diff --git a/.agents/skills/create-adapter/references/test-template.md b/.agents/skills/create-adapter/references/test-template.md new file mode 100644 index 0000000..cfc9cef --- /dev/null +++ b/.agents/skills/create-adapter/references/test-template.md @@ -0,0 +1,185 @@ +# Test Template + +Complete test template for `packages/evlog/test/adapters/{name}.test.ts`. + +Replace `{Name}`, `{name}` with the actual service name. + +```typescript +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { WideEvent } from '../../src/types' +import { sendBatchTo{Name}, sendTo{Name} } from '../../src/adapters/{name}' + +describe('{name} adapter', () => { + let fetchSpy: ReturnType + + // --- Setup: mock globalThis.fetch to return 200 --- + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(null, { status: 200 }), + ) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + // --- Test event factory --- + const createTestEvent = (overrides?: Partial): WideEvent => ({ + timestamp: '2024-01-01T12:00:00.000Z', + level: 'info', + service: 'test-service', + environment: 'test', + ...overrides, + }) + + // --- 1. URL Construction --- + describe('sendTo{Name}', () => { + it('sends event to correct URL', async () => { + const event = createTestEvent() + + await sendTo{Name}(event, { + apiKey: 'test-key', + }) + + expect(fetchSpy).toHaveBeenCalledTimes(1) + const [url] = fetchSpy.mock.calls[0] as [string, RequestInit] + // Verify the default endpoint URL + expect(url).toBe('https://api.{name}.com/v1/ingest') + }) + + it('uses custom endpoint when provided', async () => { + const event = createTestEvent() + + await sendTo{Name}(event, { + apiKey: 'test-key', + endpoint: 'https://custom.{name}.com', + }) + + const [url] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://custom.{name}.com/v1/ingest') + }) + + // --- 2. Headers --- + it('sets correct Authorization header', async () => { + const event = createTestEvent() + + await sendTo{Name}(event, { + apiKey: 'my-secret-key', + }) + + const [, options] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(options.headers).toEqual(expect.objectContaining({ + 'Authorization': 'Bearer my-secret-key', + })) + }) + + it('sets Content-Type to application/json', async () => { + const event = createTestEvent() + + await sendTo{Name}(event, { + apiKey: 'test-key', + }) + + const [, options] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(options.headers).toEqual(expect.objectContaining({ + 'Content-Type': 'application/json', + })) + }) + + // Add service-specific header tests here + // Example: orgId, project header, region header, etc. + + // --- 3. Request Body --- + it('sends event in correct format', async () => { + const event = createTestEvent({ action: 'test-action', userId: '123' }) + + await sendTo{Name}(event, { + apiKey: 'test-key', + }) + + const [, options] = fetchSpy.mock.calls[0] as [string, RequestInit] + const body = JSON.parse(options.body as string) + // Verify the body matches the expected format + // Adapt this to match the service's expected payload structure + expect(body).toBeInstanceOf(Array) + expect(body).toHaveLength(1) + }) + + // --- 4. Error Handling --- + it('throws error on non-OK response', async () => { + fetchSpy.mockResolvedValueOnce( + new Response('Bad Request', { status: 400, statusText: 'Bad Request' }), + ) + + const event = createTestEvent() + + await expect(sendTo{Name}(event, { + apiKey: 'test-key', + })).rejects.toThrow('{Name} API error: 400 Bad Request') + }) + }) + + // --- 5. Batch Operations --- + describe('sendBatchTo{Name}', () => { + it('sends multiple events in a single request', async () => { + const events = [ + createTestEvent({ requestId: '1' }), + createTestEvent({ requestId: '2' }), + createTestEvent({ requestId: '3' }), + ] + + await sendBatchTo{Name}(events, { + apiKey: 'test-key', + }) + + expect(fetchSpy).toHaveBeenCalledTimes(1) + const [, options] = fetchSpy.mock.calls[0] as [string, RequestInit] + const body = JSON.parse(options.body as string) + expect(body).toHaveLength(3) + }) + + it('skips fetch when events array is empty', async () => { + await sendBatchTo{Name}([], { + apiKey: 'test-key', + }) + + expect(fetchSpy).not.toHaveBeenCalled() + }) + }) + + // --- 6. Timeout Handling --- + describe('timeout handling', () => { + it('uses default timeout of 5000ms', async () => { + const event = createTestEvent() + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout') + + await sendTo{Name}(event, { + apiKey: 'test-key', + }) + + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 5000) + }) + + it('uses custom timeout when provided', async () => { + const event = createTestEvent() + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout') + + await sendTo{Name}(event, { + apiKey: 'test-key', + timeout: 10000, + }) + + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 10000) + }) + }) +}) +``` + +## Customization Notes + +- **URL assertions**: Update the expected URLs to match the actual service API. +- **Auth headers**: If the service uses a custom auth header (e.g., `X-API-Key` instead of `Authorization: Bearer`), update the header assertions. +- **Body format**: Adapt body assertions to match the service's expected payload. Some services wrap events in an object (PostHog: `{ api_key, batch }`), others accept raw arrays (Axiom). +- **Empty batch**: The template asserts `fetchSpy` is NOT called for empty arrays. If your adapter sends empty arrays (like Axiom does), change this to match. +- **Event transformation**: If you export a `to{Name}Event()` converter, add dedicated tests for it (see `otlp.test.ts` for `toOTLPLogRecord` tests as a reference). +- **Service-specific tests**: Add tests for any service-specific features (e.g., Axiom's `orgId` header, OTLP's severity mapping, PostHog's `distinct_id`).