- 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 Elasticsearch, Honeycomb, SigNoz, etc.) to the evlog package. Covers source code, build config, package exports, tests, e2e, and all documentation.
- metadata
- {"internal":true}
# Create evlog Adapter
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
```
feat({name}): add the {Name} drain adapter
```
Recent examples: `feat(loki): add the Grafana Loki drain adapter`, `feat(clickhouse): add the ClickHouse drain adapter`. Use the adapter name as the conventional-commit scope, and register that scope (see touchpoint 11).
**Scope timing caveat**: the semantic PR check reads its scope list from the **base branch**, so a brand-new scope can't validate the very PR that introduces it. Either register the scope in a small preceding PR (the Loki/ClickHouse pattern), or use an unscoped title (`feat: add the {Name} drain adapter`) on the introducing PR.
## Touchpoints Checklist
| # | File | Action |
|---|------|--------|
| 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 unit tests (use `test/helpers/fetch.ts`) |
| 5 | `packages/evlog/test/e2e/{name}.e2e.ts` | Create e2e test gated on env vars; extend the docker sandbox if self-hostable |
| 6 | `packages/evlog/test/toolkit/__snapshots__/api-surface.test.ts.snap` | Regenerated by running the tests after a build (`pnpm run build` then `pnpm test`) |
| 7 | `apps/docs/content/4.integrate/adapters/{category}/{NN}.{name}.md` | Create adapter doc page in the right category |
| 8 | `apps/docs/content/4.integrate/adapters/01.overview.md` | Add adapter to overview (frontmatter link + card) |
| 9 | `skills/review-logging-patterns/SKILL.md` | Add adapter row in the Drain Adapters table + frontmatter description |
| 10 | `.changeset/{name}-adapter.md` | Create changeset (`minor`) describing the adapter |
| 11 | `.github/workflows/semantic-pull-request.yml` + `.github/pull_request_template.md` | Register `{name}` as a PR scope in both files |
**Important**: Do NOT consider the task complete until all 11 touchpoints have been addressed.
## Naming Conventions
Use these placeholders consistently:
| Placeholder | Example (Loki) | Usage |
|-------------|----------------|-------|
| `{name}` | `loki` | File names, import paths, env var suffix, PR scope |
| `{Name}` | `Loki` | PascalCase in function/interface names |
| `{NAME}` | `LOKI` | SCREAMING_CASE in env var prefixes |
Standard option naming (use these exact names):
| Concept | Standard option name |
|---------|---------------------|
| Bearer-style API secret | `apiKey` |
| Base URL of the ingest API | `endpoint` |
| Service identifier | `serviceName` |
| Request timeout (ms) | `timeout` |
| Retry attempts on transient failures | `retries` |
If a service historically used a different name (`token`, `sourceToken`, …) keep it as a deprecated alias via `applyDeprecatedAlias`. See Axiom and Better Stack for the pattern.
## Step 1: Adapter Source: built on `defineHttpDrain`
Create `packages/evlog/src/adapters/{name}.ts`. Read [references/adapter-template.md](references/adapter-template.md) for the full annotated template. `loki.ts` and `clickhouse.ts` are the most recent reference implementations.
The contract is `defineHttpDrain<TConfig>({ name, label, 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}` → env vars). List `NUXT_{NAME}_*` before `{NAME}_*` in `ConfigField.env` for silent Nuxt compat; show only `{NAME}_*` in user-facing messages via `formatPublicEnvKeys`.
2. **`encode(events, config)`**: a private `encode{Name}Request(events, config): HttpDrainRequest` returning `{ url, headers, body }` for a batch. HTTP transport, identity headers, retries, timeout, and error logging are handled by `defineHttpDrain` (via `httpPost`).
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.** Never call `fetch` directly. If the service truly needs custom transport (binary envelopes, non-HTTP), use `defineDrain` from `../shared/drain` instead, see `fs.ts` and `memory.ts`.
- **Encode parity.** The standalone `sendTo{Name}` / `sendBatchTo{Name}` helpers must reuse the same private `encode{Name}Request()` and go through `sendEncodedDrainRequest(request, { label, source, timeout, retries })`, never a separate fetch path. `test/adapters/encode-parity.test.ts` pins this for a subset of adapters; add the new one to it (not every existing adapter is registered there yet, and that is a gap, not a license to skip).
- **No bespoke config resolution.** Always go through `resolveAdapterConfig`. Deprecated aliases (`token` → `apiKey`) go through `applyDeprecatedAlias`.
- **Exported converters.** If the service needs a specific event shape, export `to{Name}Event()` / `build{Name}Payload()` helpers so they're testable independently.
- **Edge-safe.** Adapters run on Cloudflare Workers: no `Buffer` (use `TextEncoder` + `btoa`, see `loki.ts`), no Node-only APIs. `fs.ts` shows the `isEdgeRuntime()` guard pattern when a runtime genuinely can't be supported.
## 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',
```
Follow the existing ordering in that file.
## Step 3: Package Exports
In `packages/evlog/package.json`, add two entries (after the last adapter, and check the current list rather than assuming):
**In `exports`**:
```json
"./{name}": {
"types": "./dist/adapters/{name}.d.mts",
"import": "./dist/adapters/{name}.mjs"
}
```
**In `typesVersions["*"]`**:
```json
"{name}": [
"./dist/adapters/{name}.d.mts"
]
```
Any export added to `package.json` without a matching `tsdown.config.ts` entry (and vice versa) fails `test/toolkit/api-surface.test.ts`, which is touchpoint 6.
## Step 4: Unit Tests
Create `packages/evlog/test/adapters/{name}.test.ts`. Read [references/test-template.md](references/test-template.md) for the full annotated template, and `packages/evlog/test/README.md` for the repo-wide conventions.
Non-negotiables from the test README:
- Use `mockFetch()` / `getFetchCall` / `getFetchJson` / `getFetchHeaders` from `test/helpers/fetch.ts`, never hand-roll `vi.spyOn(globalThis, 'fetch')` boilerplate.
- Clean up any env vars the adapter reads in `afterEach`.
- Test the exported pure helpers (`to{Name}Event`, `build{Name}Payload`, URL resolvers) directly, one `describe` per helper.
Required test categories:
1. URL construction (default + custom endpoint, trailing-slash tolerance)
2. Headers (auth, content-type, service-specific)
3. Request body format (JSON structure matches service API)
4. Skip behavior when `apiKey` (or required field) is missing
5. Batch operations (multiple events in one request, empty batch skips fetch)
6. Deprecated alias still works (when applicable)
7. Add the adapter to `test/adapters/encode-parity.test.ts`
## Step 5: E2E Test + Sandbox
Create `packages/evlog/test/e2e/{name}.e2e.ts`, gated on the adapter's env vars (skipped when absent). Run with `pnpm test:e2e`.
If the service is self-hostable, extend the local sandbox so the adapter can be exercised without cloud credentials:
- `packages/evlog/test/e2e/docker-compose.yml`: add the service
- `packages/evlog/test/e2e/seed.mjs`: fan the seeder out to the new backend
- `packages/evlog/test/e2e/README.md`: document it
- Root `package.json` `sandbox:e2e` script: add the local env var if needed
See the Loki and ClickHouse setups as references.
## Step 6: Adapter Documentation Page
Read `apps/docs/AGENTS.md` before touching anything under `apps/docs/` (steps 6 to 8).
Adapter docs live in three categories under `apps/docs/content/4.integrate/adapters/`:
| Category | Directory | Examples |
|----------|-----------|----------|
| Cloud (SaaS only) | `cloud/` | Axiom, PostHog, Sentry, Better Stack, Datadog |
| Cloud or Self-Hosted | `hybrid/` | Loki, ClickHouse, OTLP, HyperDX |
| Self-Hosted (local only) | `self-hosted/` | FS, NuxtHub, Memory |
Create `{NN}.{name}.md` in the right category with the next available number. Use the Loki page (`hybrid/01.loki.md`) as a reference for frontmatter, 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 the supported frameworks (Nuxt/Nitro, Hono, Express, Fastify, Elysia, NestJS, Standalone). Do not only show Nitro examples.
## Step 7: Update Adapters Overview Page
Edit `apps/docs/content/4.integrate/adapters/01.overview.md` in **two** places (follow the pattern of existing adapters):
1. **Frontmatter `links` array**: add a link entry with icon and `/integrate/adapters/{category}/{name}` path, in category order
2. **`::card-group` section**: add a card block in the matching position
## Step 8: Update the Public Skill
In `skills/review-logging-patterns/SKILL.md` (published on evlog.dev via `/.well-known/skills/`):
1. Add a row to the **Drain Adapters** table: `| {Name} | evlog/{name} | {NAME}_API_KEY, ... |`
2. Add the adapter name to the `description:` line in the YAML frontmatter
## Step 9: Changeset
Create `.changeset/{name}-adapter.md` with a `minor` bump. Write it like a release note: what the adapter does, the deployment modes it covers, the key options, the env vars, and the direct-send helpers. See `.changeset` entries from the Loki/ClickHouse PRs for the expected depth.
## Step 10: PR Scopes
Add `{name}` to the `scopes` list in `.github/workflows/semantic-pull-request.yml` **and** to the Scopes section of `.github/pull_request_template.md`, in alphabetical order. Remember the timing caveat from the PR Title section: this registration only takes effect for PRs whose base branch already contains it.
## Verification
After a clean install, prepare generated workspace types from the repo root first, then run the package checks:
```bash
pnpm run dev:prepare
cd packages/evlog
pnpm run lint
pnpm run typecheck
pnpm run build # required before test — api-surface snapshot is gated on dist/
pnpm run test
```
Auf GitHub ansehen