|
| 1 | +# Adapter Source Template |
| 2 | + |
| 3 | +Complete TypeScript template for `packages/evlog/src/adapters/{name}.ts`. |
| 4 | + |
| 5 | +Replace `{Name}`, `{name}`, and `{NAME}` with the actual service name. |
| 6 | + |
| 7 | +```typescript |
| 8 | +import type { DrainContext, WideEvent } from '../types' |
| 9 | + |
| 10 | +// --- 1. Config Interface --- |
| 11 | +// Define all service-specific configuration fields. |
| 12 | +// Always include optional `timeout`. |
| 13 | +export interface {Name}Config { |
| 14 | + /** {Name} API key / token */ |
| 15 | + apiKey: string |
| 16 | + /** {Name} API endpoint. Default: https://api.{name}.com */ |
| 17 | + endpoint?: string |
| 18 | + /** Request timeout in milliseconds. Default: 5000 */ |
| 19 | + timeout?: number |
| 20 | + // Add service-specific fields here (dataset, project, region, etc.) |
| 21 | +} |
| 22 | + |
| 23 | +// --- 2. Event Transformation (optional) --- |
| 24 | +// Export a converter if the service needs a specific format. |
| 25 | +// This makes the transformation testable independently. |
| 26 | + |
| 27 | +/** {Name} event structure */ |
| 28 | +export interface {Name}Event { |
| 29 | + // Define the target service's event shape |
| 30 | + timestamp: string |
| 31 | + level: string |
| 32 | + data: Record<string, unknown> |
| 33 | +} |
| 34 | + |
| 35 | +/** |
| 36 | + * Convert a WideEvent to {Name}'s event format. |
| 37 | + */ |
| 38 | +export function to{Name}Event(event: WideEvent): {Name}Event { |
| 39 | + const { timestamp, level, ...rest } = event |
| 40 | + |
| 41 | + return { |
| 42 | + timestamp, |
| 43 | + level, |
| 44 | + data: rest, |
| 45 | + } |
| 46 | +} |
| 47 | + |
| 48 | +// --- 3. Runtime Config Helper --- |
| 49 | +// Dynamic require to avoid bundling issues outside Nitro. |
| 50 | +// Returns undefined when not in a Nitro context. |
| 51 | +function getRuntimeConfig(): { |
| 52 | + evlog?: { {name}?: Partial<{Name}Config> } |
| 53 | + {name}?: Partial<{Name}Config> |
| 54 | +} | undefined { |
| 55 | + try { |
| 56 | + // eslint-disable-next-line @typescript-eslint/no-require-imports |
| 57 | + const { useRuntimeConfig } = require('nitropack/runtime') |
| 58 | + return useRuntimeConfig() |
| 59 | + } catch { |
| 60 | + return undefined |
| 61 | + } |
| 62 | +} |
| 63 | + |
| 64 | +// --- 4. Factory Function --- |
| 65 | +// Returns a drain function that resolves config at call time. |
| 66 | +// Config priority: overrides > runtimeConfig.evlog.{name} > runtimeConfig.{name} > env vars |
| 67 | + |
| 68 | +/** |
| 69 | + * Create a drain function for sending logs to {Name}. |
| 70 | + * |
| 71 | + * Configuration priority (highest to lowest): |
| 72 | + * 1. Overrides passed to create{Name}Drain() |
| 73 | + * 2. runtimeConfig.evlog.{name} |
| 74 | + * 3. runtimeConfig.{name} |
| 75 | + * 4. Environment variables: NUXT_{NAME}_*, {NAME}_* |
| 76 | + * |
| 77 | + * @example |
| 78 | + * ```ts |
| 79 | + * // Zero config - set NUXT_{NAME}_API_KEY env var |
| 80 | + * nitroApp.hooks.hook('evlog:drain', create{Name}Drain()) |
| 81 | + * |
| 82 | + * // With overrides |
| 83 | + * nitroApp.hooks.hook('evlog:drain', create{Name}Drain({ |
| 84 | + * apiKey: 'my-key', |
| 85 | + * })) |
| 86 | + * ``` |
| 87 | + */ |
| 88 | +export function create{Name}Drain(overrides?: Partial<{Name}Config>): (ctx: DrainContext) => Promise<void> { |
| 89 | + return async (ctx: DrainContext) => { |
| 90 | + const runtimeConfig = getRuntimeConfig() |
| 91 | + const evlogConfig = runtimeConfig?.evlog?.{name} |
| 92 | + const rootConfig = runtimeConfig?.{name} |
| 93 | + |
| 94 | + // Build config with fallbacks |
| 95 | + const config: Partial<{Name}Config> = { |
| 96 | + apiKey: overrides?.apiKey ?? evlogConfig?.apiKey ?? rootConfig?.apiKey |
| 97 | + ?? process.env.NUXT_{NAME}_API_KEY ?? process.env.{NAME}_API_KEY, |
| 98 | + endpoint: overrides?.endpoint ?? evlogConfig?.endpoint ?? rootConfig?.endpoint |
| 99 | + ?? process.env.NUXT_{NAME}_ENDPOINT ?? process.env.{NAME}_ENDPOINT, |
| 100 | + timeout: overrides?.timeout ?? evlogConfig?.timeout ?? rootConfig?.timeout, |
| 101 | + } |
| 102 | + |
| 103 | + // Validate required fields |
| 104 | + if (!config.apiKey) { |
| 105 | + console.error('[evlog/{name}] Missing apiKey. Set NUXT_{NAME}_API_KEY env var or pass to create{Name}Drain()') |
| 106 | + return |
| 107 | + } |
| 108 | + |
| 109 | + try { |
| 110 | + await sendTo{Name}(ctx.event, config as {Name}Config) |
| 111 | + } catch (error) { |
| 112 | + console.error('[evlog/{name}] Failed to send event:', error) |
| 113 | + } |
| 114 | + } |
| 115 | +} |
| 116 | + |
| 117 | +// --- 5. Send Functions --- |
| 118 | +// Exported for direct use and testability. |
| 119 | +// sendTo{Name} wraps sendBatchTo{Name} for single events. |
| 120 | + |
| 121 | +/** |
| 122 | + * Send a single event to {Name}. |
| 123 | + */ |
| 124 | +export async function sendTo{Name}(event: WideEvent, config: {Name}Config): Promise<void> { |
| 125 | + await sendBatchTo{Name}([event], config) |
| 126 | +} |
| 127 | + |
| 128 | +/** |
| 129 | + * Send a batch of events to {Name}. |
| 130 | + */ |
| 131 | +export async function sendBatchTo{Name}(events: WideEvent[], config: {Name}Config): Promise<void> { |
| 132 | + if (events.length === 0) return |
| 133 | + |
| 134 | + const endpoint = (config.endpoint ?? 'https://api.{name}.com').replace(/\/$/, '') |
| 135 | + const timeout = config.timeout ?? 5000 |
| 136 | + // Construct the full URL for the service's ingest API |
| 137 | + const url = `${endpoint}/v1/ingest` |
| 138 | +
|
| 139 | + const headers: Record<string, string> = { |
| 140 | + 'Content-Type': 'application/json', |
| 141 | + 'Authorization': `Bearer ${config.apiKey}`, |
| 142 | + // Add service-specific headers here |
| 143 | + } |
| 144 | +
|
| 145 | + // Transform events if the service needs a specific format |
| 146 | + const payload = events.map(to{Name}Event) |
| 147 | + // Or send raw: JSON.stringify(events) |
| 148 | +
|
| 149 | + const controller = new AbortController() |
| 150 | + const timeoutId = setTimeout(() => controller.abort(), timeout) |
| 151 | +
|
| 152 | + try { |
| 153 | + const response = await fetch(url, { |
| 154 | + method: 'POST', |
| 155 | + headers, |
| 156 | + body: JSON.stringify(payload), |
| 157 | + signal: controller.signal, |
| 158 | + }) |
| 159 | +
|
| 160 | + if (!response.ok) { |
| 161 | + const text = await response.text().catch(() => 'Unknown error') |
| 162 | + const safeText = text.length > 200 ? `${text.slice(0, 200)}...[truncated]` : text |
| 163 | + throw new Error(`{Name} API error: ${response.status} ${response.statusText} - ${safeText}`) |
| 164 | + } |
| 165 | + } finally { |
| 166 | + clearTimeout(timeoutId) |
| 167 | + } |
| 168 | +} |
| 169 | +``` |
| 170 | + |
| 171 | +## Customization Notes |
| 172 | + |
| 173 | +- **Auth style**: Some services use `Authorization: Bearer`, others use a custom header like `X-API-Key`. Adjust the headers accordingly. |
| 174 | +- **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. |
| 175 | +- **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. |
| 176 | +- **URL construction**: Match the service's API endpoint pattern. Some use path-based routing (`/v1/datasets/{id}/ingest`), others use a flat endpoint (`/batch/`). |
| 177 | +- **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). |
0 commit comments