
Create Evlog Adapter
- 785 installs
- 1.7k repo stars
- Updated August 4, 2026
- hugorcd/evlog
create-evlog-adapter is a TypeScript scaffolding skill that generates a complete evlog drain adapter file using defineHttpDrain and resolveAdapterConfig so developers who add new event-logging services get correctly stru
About
create-evlog-adapter is an evlog toolkit skill that instantly produces a complete TypeScript adapter at packages/evlog/src/adapters/{name}.ts. The template wires service-specific config fields—apiKey, endpoint, serviceName, timeout—through resolveAdapterConfig and implements HTTP draining with defineHttpDrain against WideEvent types. Developers replace {Name}, {name}, and {NAME} placeholders with the target service identifier. The skill fits when onboarding Datadog, Honeycomb, or any new HTTP event sink into the shared evlog monorepo without hand-copying boilerplate. Output follows the public toolkit primitives and config interface patterns already used by existing evlog adapters.
- Complete TypeScript template for packages/evlog/src/adapters/{name}.ts
- Uses defineHttpDrain and resolveAdapterConfig primitives from the public toolkit
- Includes configurable interface with standard fields (apiKey, endpoint, timeout)
- Environment variable mapping for both NUXT_ and plain prefixes
- Optional event transformation layer for service-specific shaping
Create Evlog Adapter by the numbers
- 785 all-time installs (skills.sh)
- +3 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #486 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/hugorcd/evlog --skill create-evlog-adapterAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 785 |
|---|---|
| repo stars | ★ 1.7k |
| Security audit | 3 / 3 scanners passed |
| Last updated | August 4, 2026 |
| Repository | hugorcd/evlog ↗ |
How do you scaffold an evlog TypeScript drain adapter?
Instantly generate a complete, correctly structured TypeScript adapter for any new event-logging service using the shared evlog toolkit primitives.
Who is it for?
Backend developers extending the evlog monorepo who need a correctly structured HTTP drain adapter for a new event-logging vendor.
Skip if: Teams not using the evlog toolkit or integrations that require non-HTTP transports without adapting the template.
When should I use this skill?
User asks to add a new evlog adapter, integrate an event-logging service, or scaffold packages/evlog/src/adapters code.
What you get
Complete TypeScript adapter file with config interface, HTTP drain definition, and evlog toolkit imports
- TypeScript adapter source file
By the numbers
- Generates adapter scaffold with 2 core toolkit primitives: defineHttpDrain and resolveAdapterConfig
- Template includes 4 standard config field names: apiKey, endpoint, serviceName, and timeout
Files
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
Recommended format for the pull request title:
feat: add {name} adapterThe 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 (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 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 |
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 |
If a service historically used a different name (token, sourceToken, …) keep it as a deprecated alias — 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 for the full annotated template.
The contract is now defineHttpDrain<TConfig>({ 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)` — produce { url, headers, body } for a batch of events (or null to skip). HTTP transport, retries, timeout, and error logging are handled by defineHttpDrain.
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 amodeoption (see PostHog). - No HTTP code in the adapter. Don't call
fetchdirectly — letdefineHttpDraindo it. If your service truly needs custom transport (e.g. binary envelopes), usedefineDrainand callhttpPostfromevlog/toolkit. - No bespoke config resolution. Always go through
resolveAdapterConfig. If you need to support a deprecated alias (token→apiKey), include both in theConfigField[]and fall through inresolve(). - Exported converters. If the service needs a specific event shape, export a
to{Name}Event()(orbuildPayload()) helper so it can be tested independently.
Step 2: Build Config
Add a build entry in packages/evlog/tsdown.config.ts alongside the existing adapters:
'adapters/{name}': 'src/adapters/{name}.ts',Place it after the last adapter entry in tsdown.config.ts (follow existing ordering in that file).
Step 3: Package Exports
In packages/evlog/package.json, add two entries:
In `exports` (after the last adapter, currently ./posthog):
"./{name}": {
"types": "./dist/adapters/{name}.d.mts",
"import": "./dist/adapters/{name}.mjs"
}*In `typesVersions[""]`** (after the last adapter):
"{name}": [
"./dist/adapters/{name}.d.mts"
]Step 4: Tests
Create packages/evlog/test/adapters/{name}.test.ts.
Read 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. Skip behavior when apiKey (or required field) is missing 5. Batch operations 6. Deprecated alias still works (when applicable)
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:
| {Name} | `evlog/{name}` | `{NAME}_API_KEY`, `{NAME}_DATASET` (or equivalent) |Follow the pattern of the existing rows (Axiom, OTLP, PostHog, Sentry, Better Stack).
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:
cd packages/evlog
pnpm run lint
pnpm run typecheck
pnpm run test
pnpm run buildAdapter Source Template
Complete TypeScript template for packages/evlog/src/adapters/{name}.ts using the public toolkit primitives defineHttpDrain + resolveAdapterConfig.
Replace {Name}, {name}, and {NAME} with the actual service name.
import type { WideEvent } from '../types'
import type { ConfigField } from '../shared/config'
import { formatPublicEnvKeys, resolveAdapterConfig } from '../shared/config'
import { defineHttpDrain } from '../shared/drain'
// --- 1. Config Interface -------------------------------------------------
// Service-specific fields. Standard names: apiKey, endpoint, serviceName, timeout.
export interface {Name}Config {
/** {Name} API key */
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.)
}
// Field manifest — drives both resolveAdapterConfig and runtime-config-aware
// drain initialization.
const FIELDS: ConfigField<{Name}Config>[] = [
{ key: 'apiKey', env: ['NUXT_{NAME}_API_KEY', '{NAME}_API_KEY'] },
{ key: 'endpoint', env: ['NUXT_{NAME}_ENDPOINT', '{NAME}_ENDPOINT'] },
{ key: 'timeout' },
]
// --- 2. Event Transformation (optional) ----------------------------------
// If the service needs a specific shape, expose a converter so it's testable
// independently. Otherwise pass `events` straight through in `encode`.
export interface {Name}Event {
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. Encode helper (pure, easy to test) -------------------------------
function build{Name}Payload(events: WideEvent[], config: {Name}Config) {
const endpoint = (config.endpoint ?? 'https://api.{name}.com').replace(/\/$/, '')
return {
url: `${endpoint}/v1/ingest`,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.apiKey}`,
},
body: JSON.stringify(events.map(to{Name}Event)),
}
}
// --- 4. Direct send helpers ----------------------------------------------
// Exported for direct use and testability.
/** 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 { url, headers, body } = build{Name}Payload(events, config)
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), config.timeout ?? 5000)
try {
const response = await fetch(url, { method: 'POST', headers, body, signal: controller.signal })
if (!response.ok) {
const text = await response.text().catch(() => 'Unknown error')
const safe = text.length > 200 ? `${text.slice(0, 200)}...[truncated]` : text
throw new Error(`{Name} API error: ${response.status} ${response.statusText} - ${safe}`)
}
} finally {
clearTimeout(timeoutId)
}
}
// --- 5. Factory built on `defineHttpDrain` ------------------------------
/**
* 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: {NAME}_*
*
* @example
* ```ts
* import { create{Name}Drain } from 'evlog/{name}'
*
* // Zero config — set {NAME}_API_KEY env var
* defineEvlog({ drain: create{Name}Drain() })
*
* // With overrides
* defineEvlog({ drain: create{Name}Drain({ apiKey: 'my-key' }) })
* ```
*/
export function create{Name}Drain(overrides?: Partial<{Name}Config>) {
return defineHttpDrain<{Name}Config>({
name: '{name}',
timeout: overrides?.timeout,
resolve: async () => {
const config = await resolveAdapterConfig<{Name}Config>('{name}', FIELDS, overrides)
if (!config.apiKey) {
console.error(`[evlog/{name}] Missing apiKey. Set ${formatPublicEnvKeys(['NUXT_{NAME}_API_KEY', '{NAME}_API_KEY'])} env var or pass to create{Name}Drain()`)
return null
}
return config as {Name}Config
},
encode: (events, config) => build{Name}Payload(events, config),
})
}Customization Notes
- Auth style: Some services use
Authorization: Bearer, others use a custom header likeX-API-Key. Adjustheadersinbuild{Name}Payload. - 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). Adaptbuild{Name}Payload. - Event transformation: If the service expects a specific schema, implement
to{Name}Event(). If it accepts arbitrary JSON, sendeventsdirectly. - Custom transport: If the service truly cannot fit
defineHttpDrain(e.g. binary envelopes, gRPC), fall back todefineDrainfrom../shared/drainand callhttpPost(from../shared/http) explicitly. - Deprecated aliases: When renaming a config field (e.g.
token→apiKey), keep both asConfigFieldentries and fall through inresolve(). Seeaxiom.tsandbetter-stack.tsfor the pattern.
Test Template
Complete test template for packages/evlog/test/adapters/{name}.test.ts.
Replace {Name}, {name} with the actual service name.
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 (only the direct helper throws — the drain
// itself swallows errors via `defineHttpDrain` so the request
// pipeline is never interrupted; that contract is covered by
// `test/toolkit.test.ts`).
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-Keyinstead ofAuthorization: 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
fetchSpyis 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 (seeotlp.test.tsfortoOTLPLogRecordtests as a reference). - Service-specific tests: Add tests for any service-specific features (e.g., Axiom's
orgIdheader, OTLP's severity mapping, PostHog'sdistinct_id).
Related skills
How it compares
Use create-evlog-adapter instead of manual copy-paste when adding HTTP-based drains to the evlog monorepo and consistent ConfigField typing matters.
FAQ
Which evlog primitives does create-evlog-adapter use?
create-evlog-adapter scaffolds adapters with defineHttpDrain for HTTP event delivery and resolveAdapterConfig for shared configuration resolution. Generated files import WideEvent types and ConfigField definitions from the evlog public toolkit.
Where does create-evlog-adapter place the generated TypeScript file?
create-evlog-adapter targets packages/evlog/src/adapters/{name}.ts in the evlog monorepo. Developers substitute {Name}, {name}, and {NAME} placeholders with the new logging service identifier before wiring service-specific config fields.
Is Create Evlog Adapter safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.