
Create Evlog Framework Integration
- 485 installs
- 1.7k repo stars
- Updated August 4, 2026
- hugorcd/evlog
create-evlog-framework-integration is a contributor agent skill that scaffolds all mandatory files for a new evlog HTTP framework middleware integration for developers adding structured wide-event logging to Express, Hon
About
create-evlog-framework-integration is a hugorcd/evlog agent skill for contributors adding a new framework integration to the evlog structured logging library. Every integration follows shared architecture on createMiddlewareLogger from evlog/toolkit, with manifest mode via defineFrameworkIntegration covering roughly 80% of (ctx, next) middleware frameworks in about 30 lines of glue code. The skill mandates 11 touchpoints: integration source under packages/evlog/src, tsdown build config, package.json exports, tests, framework docs page, overview and installation cards, landing snippet, FeatureFrameworks.vue tab, README section, and public SKILL.md updates. Developers reach for it when evlog lacks built-in support for their HTTP framework and they need the same drain, enrich, keep, and tail-sampling pipeline as Hono, Express, Fastify, Elysia, NestJS, or SvelteKit integrations.
- Generates full evlog framework integration in one command
- Produces structured logging, correlation IDs, and context enrichment
- Creates ready-to-use logger modules with multiple output handlers
- Includes observability instrumentation and error tracking setup
- Delivers configuration templates and example usage patterns
Create Evlog Framework Integration by the numbers
- 485 all-time installs (skills.sh)
- +5 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #420 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/hugorcd/evlog --skill create-evlog-framework-integrationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 485 |
|---|---|
| repo stars | ★ 1.7k |
| Last updated | August 4, 2026 |
| Repository | hugorcd/evlog ↗ |
How do you add a new evlog framework integration?
Instantly scaffold a complete event logging framework with structured logging, context enrichment, and observability hooks that works with any agent workflow.
Who is it for?
evlog contributors or maintainers adding middleware logging support for an HTTP framework using the library's shared toolkit primitives.
Skip if: Application developers who only need to install existing evlog integrations without contributing new framework adapters to the monorepo.
When should I use this skill?
A new HTTP framework needs evlog middleware support and the contributor must hit every mandatory monorepo touchpoint without skipping files.
What you get
Integration source file, tests, package exports, docs pages, README section, and PR-ready feat({framework}) middleware integration.
- Framework integration source
- Test file
- Documentation pages
By the numbers
- Requires 11 mandatory monorepo touchpoints per new integration
- Manifest mode covers about 80% of middleware frameworks in roughly 30 lines
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
Pick create-evlog-framework-integration over generic logging guides when contributing a new built-in adapter to the evlog monorepo with full docs and export wiring.
FAQ
How many touchpoints does create-evlog-framework-integration require?
create-evlog-framework-integration mandates 11 touchpoints including integration source, tsdown build entry, package.json exports, tests, framework docs, overview cards, landing snippet, Vue feature tab, README, and public skill updates.
When should create-evlog-framework-integration use manifest mode?
create-evlog-framework-integration recommends defineFrameworkIntegration manifest mode for standard (ctx, next) HTTP middleware frameworks, producing about 30 lines of glue while createMiddlewareLogger handles drain, enrich, keep, and tail sampling.