Back to skills
SkillHub ClubShip Full StackFull StackTesting

create-evlog-adapter

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 all documentation.

Packaged view

This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.

Stars
881
Hot score
99
Updated
March 20, 2026
Overall rating
C3.9
Composite score
3.9
Best-practice grade
B81.2

Install command

npx @skill-hub/cli install hugorcd-evlog-create-adapter

Repository

HugoRCD/evlog

Skill path: .agents/skills/create-adapter

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 all documentation.

Open repository

Best for

Primary workflow: Ship Full Stack.

Technical facets: Full Stack, Testing.

Target audience: everyone.

License: Unknown.

Original source

Catalog source: SkillHub Club.

Repository owner: HugoRCD.

This is still a mirrored public skill entry. Review the repository before installing into production workflows.

What it helps with

  • Install create-evlog-adapter into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
  • Review https://github.com/HugoRCD/evlog before adding create-evlog-adapter to shared team environments
  • Use create-evlog-adapter for development workflows

Works across

Claude CodeCodex CLIGemini CLIOpenCode

Favorites: 0.

Sub-skills: 0.

Aggregator: No.

Original source / Raw SKILL.md

---
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 all documentation.
---

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

## PR Title

Recommended format for the pull request title:

```
feat: add {name} adapter
```

The exact wording may vary depending on the adapter (e.g., `feat: add OTLP adapter`, `feat: add Axiom drain adapter`), but it should always follow the `feat:` conventional commit prefix.

## Touchpoints Checklist

| # | File | Action |
|---|------|--------|
| 1 | `packages/evlog/src/adapters/{name}.ts` | Create adapter source |
| 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/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 |

**Important**: Do NOT consider the task complete until all 8 touchpoints have been addressed.

## 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()`** -- 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

## Step 2: Build Config

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

```typescript
'adapters/{name}': 'src/adapters/{name}.ts',
```

Place it after the last adapter entry (currently `sentry` at line 22).

## 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: 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/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

## 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) |
```

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.

## Step 8: Renumber `custom.md`

If the new adapter's number conflicts with `custom.md`, renumber `custom.md` to be the last entry. For example, if the new adapter is `5.{name}.md`, rename `5.custom.md` to `6.custom.md`.

## 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
```


---

## Referenced Files

> The following files are referenced in this skill and included for context.

### references/adapter-template.md

```markdown
# 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'
import { getRuntimeConfig } from './_utils'

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

```

### references/test-template.md

```markdown
# 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<typeof vi.spyOn>

  // --- 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>): 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`).

```

create-evlog-adapter | SkillHub