diff --git a/AGENTS.md b/AGENTS.md index 2693c17..ab29dba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -220,6 +220,7 @@ evlog provides built-in adapters for popular observability platforms. Use the `e | Axiom | `evlog/axiom` | Send logs to Axiom for querying and dashboards | | OTLP | `evlog/otlp` | OpenTelemetry Protocol for Grafana, Datadog, Honeycomb, etc. | | PostHog | `evlog/posthog` | Send logs to PostHog as events for product analytics | +| Sentry | `evlog/sentry` | Send logs to Sentry Logs for structured logging and debugging | **Using Axiom Adapter:** @@ -260,6 +261,19 @@ export default defineNitroPlugin((nitroApp) => { Set environment variable: `NUXT_POSTHOG_API_KEY` (and optionally `NUXT_POSTHOG_HOST` for EU or self-hosted instances). +**Using Sentry Adapter:** + +```typescript +// server/plugins/evlog-drain.ts +import { createSentryDrain } from 'evlog/sentry' + +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('evlog:drain', createSentryDrain()) +}) +``` + +Set environment variable: `NUXT_SENTRY_DSN`. + **Multiple Destinations:** ```typescript diff --git a/README.md b/README.md index 959b4c2..236b2ed 100644 --- a/README.md +++ b/README.md @@ -431,6 +431,23 @@ Set environment variables: NUXT_OTLP_ENDPOINT=http://localhost:4318 ``` +### Sentry + +```typescript +// server/plugins/evlog-drain.ts +import { createSentryDrain } from 'evlog/sentry' + +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('evlog:drain', createSentryDrain()) +}) +``` + +Set environment variables: + +```bash +NUXT_SENTRY_DSN=https://public@o0.ingest.sentry.io/123 +``` + ### Multiple Destinations Send logs to multiple services: diff --git a/apps/docs/content/3.adapters/1.overview.md b/apps/docs/content/3.adapters/1.overview.md index 85a5c4b..1253186 100644 --- a/apps/docs/content/3.adapters/1.overview.md +++ b/apps/docs/content/3.adapters/1.overview.md @@ -1,6 +1,6 @@ --- title: Adapters Overview -description: Send your logs to external services with evlog adapters. Built-in support for Axiom, OTLP, and custom destinations. +description: Send your logs to external services with evlog adapters. Built-in support for Axiom, OTLP, Sentry, and custom destinations. navigation: title: Overview icon: i-custom-plug @@ -20,6 +20,11 @@ links: to: /adapters/posthog color: neutral variant: subtle + - label: Sentry + icon: i-simple-icons-sentry + to: /adapters/sentry + color: neutral + variant: subtle --- Adapters let you send logs to external observability platforms. evlog provides built-in adapters for popular services, and you can create custom adapters for any destination. @@ -70,6 +75,15 @@ export default defineNitroPlugin((nitroApp) => { Send logs to PostHog for product analytics and wide event querying. ::: + :::card + --- + icon: i-simple-icons-sentry + title: Sentry + to: /adapters/sentry + --- + Send structured logs to Sentry Logs for high-cardinality querying. + ::: + :::card --- icon: i-lucide-code @@ -126,6 +140,9 @@ NUXT_OTLP_ENDPOINT=https://otlp.example.com # PostHog NUXT_POSTHOG_API_KEY=phc_xxx + +# Sentry +NUXT_SENTRY_DSN=https://key@o0.ingest.sentry.io/123 ``` ```typescript [server/plugins/evlog-drain.ts] diff --git a/apps/docs/content/3.adapters/5.sentry.md b/apps/docs/content/3.adapters/5.sentry.md new file mode 100644 index 0000000..2e5b93a --- /dev/null +++ b/apps/docs/content/3.adapters/5.sentry.md @@ -0,0 +1,187 @@ +--- +title: Sentry Adapter +description: Send structured logs to Sentry Logs for high-cardinality querying and debugging. Zero-config setup with environment variables. +navigation: + title: Sentry + icon: i-simple-icons-sentry +links: + - label: Sentry Dashboard + icon: i-lucide-external-link + to: https://sentry.io + target: _blank + color: neutral + variant: subtle + - label: OTLP Adapter + icon: i-simple-icons-opentelemetry + to: /adapters/otlp + color: neutral + variant: subtle +--- + +[Sentry](https://sentry.io) is an error tracking and performance monitoring platform. The evlog Sentry adapter sends your wide events as **Sentry Structured Logs**, visible in **Explore > Logs** in the Sentry dashboard with high-cardinality searchable attributes. + +## Installation + +The Sentry adapter comes bundled with evlog: + +```typescript [server/plugins/evlog-drain.ts] +import { createSentryDrain } from 'evlog/sentry' +``` + +## Quick Start + +### 1. Get your Sentry DSN + +1. Create a [Sentry account](https://sentry.io) +2. Create a new project (Node.js or JavaScript) +3. Find your DSN in **Settings > Projects > [Your Project] > Client Keys (DSN)** + +### 2. Set environment variables + +```bash [.env] +NUXT_SENTRY_DSN=https://your-public-key@o0.ingest.sentry.io/your-project-id +``` + +### 3. Create the drain plugin + +```typescript [server/plugins/evlog-drain.ts] +import { createSentryDrain } from 'evlog/sentry' + +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('evlog:drain', createSentryDrain()) +}) +``` + +That's it! Your logs will now appear in **Explore > Logs** in Sentry. + +## Configuration + +The adapter reads configuration from multiple sources (highest priority first): + +1. **Overrides** passed to `createSentryDrain()` +2. **Runtime config** at `runtimeConfig.evlog.sentry` +3. **Runtime config** at `runtimeConfig.sentry` +4. **Environment variables** (`NUXT_SENTRY_*` or `SENTRY_*`) + +### Environment Variables + +| Variable | Description | +|----------|-------------| +| `NUXT_SENTRY_DSN` | Sentry DSN (required) | +| `NUXT_SENTRY_ENVIRONMENT` | Environment name override | +| `NUXT_SENTRY_RELEASE` | Release version override | + +You can also use `SENTRY_DSN`, `SENTRY_ENVIRONMENT`, and `SENTRY_RELEASE` as fallbacks. + +### Runtime Config + +Configure via `nuxt.config.ts` for type-safe configuration: + +```typescript [nuxt.config.ts] +export default defineNuxtConfig({ + modules: ['evlog/nuxt'], + evlog: { + sentry: { + dsn: '', // Set via NUXT_SENTRY_DSN + environment: 'production', + release: '1.0.0', + }, + }, +}) +``` + +### Override Options + +Pass options directly to override any configuration: + +```typescript [server/plugins/evlog-drain.ts] +import { createSentryDrain } from 'evlog/sentry' + +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('evlog:drain', createSentryDrain({ + dsn: 'https://key@o0.ingest.sentry.io/123', + tags: { team: 'backend' }, + timeout: 10000, // 10 seconds + })) +}) +``` + +### Full Configuration Reference + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `dsn` | `string` | - | Sentry DSN (required) | +| `environment` | `string` | Event environment | Environment name | +| `release` | `string` | Event version | Release version | +| `tags` | `Record` | - | Additional attributes to attach | +| `timeout` | `number` | `5000` | Request timeout in milliseconds | + +## Log Transformation + +evlog wide events are converted to Sentry Logs using `toSentryLog()`: + +- **Level mapping**: evlog levels map directly (`debug`, `info`, `warn`, `error`) +- **Severity numbers**: Follow the OpenTelemetry spec (`debug=5`, `info=9`, `warn=13`, `error=17`) +- **Body**: Derived from the event's `message`, `action`, or `path` fields (first available) +- **Attributes**: All wide event fields are sent as typed attributes (string, integer, double, boolean). Complex objects are serialized to JSON strings. +- **Sentry attributes**: `sentry.environment` and `sentry.release` are set automatically +- **Trace ID**: Uses `event.traceId` if available, otherwise generates a random one + +## Querying Logs in Sentry + +evlog sends wide events as structured logs. In the Sentry dashboard: + +- **Explore > Logs**: View all evlog wide events with full attribute search +- **Filter by attributes**: `service:my-app`, `level:error`, or any wide event field +- **Trace correlation**: Logs are linked to traces via `trace_id` for cross-referencing + +::callout{icon="i-lucide-info" color="info"} +Sentry Structured Logs support high-cardinality attributes, making them a great fit for evlog's wide events. Every field in your wide event becomes a searchable attribute in Sentry. +:: + +## Troubleshooting + +### Missing DSN error + +``` +[evlog/sentry] Missing DSN. Set NUXT_SENTRY_DSN/SENTRY_DSN env var or pass to createSentryDrain() +``` + +Make sure your environment variable is set and the server was restarted after adding it. + +### Invalid DSN + +If the DSN is malformed (missing public key or project ID), the adapter will throw an error. Verify your DSN format: + +``` +https://@/ +``` + +### 401 Unauthorized + +Your DSN may be revoked or invalid. Generate a new DSN in **Settings > Projects > Client Keys (DSN)**. + +## Direct API Usage + +For advanced use cases, you can use the lower-level functions: + +```typescript [server/utils/sentry.ts] +import { sendToSentry, sendBatchToSentry } from 'evlog/sentry' + +// Send a single event as a Sentry log +await sendToSentry(event, { + dsn: process.env.SENTRY_DSN!, +}) + +// Send multiple events in one request +await sendBatchToSentry(events, { + dsn: process.env.SENTRY_DSN!, +}) +``` + +## Next Steps + +- [Axiom Adapter](/adapters/axiom) - Send logs to Axiom for querying and dashboards +- [OTLP Adapter](/adapters/otlp) - Send logs via OpenTelemetry Protocol +- [PostHog Adapter](/adapters/posthog) - Send logs to PostHog +- [Custom Adapters](/adapters/custom) - Build your own adapter diff --git a/apps/docs/content/3.adapters/5.custom.md b/apps/docs/content/3.adapters/6.custom.md similarity index 100% rename from apps/docs/content/3.adapters/5.custom.md rename to apps/docs/content/3.adapters/6.custom.md diff --git a/apps/playground/nuxt.config.ts b/apps/playground/nuxt.config.ts index 64968f3..d235c8c 100644 --- a/apps/playground/nuxt.config.ts +++ b/apps/playground/nuxt.config.ts @@ -28,6 +28,7 @@ export default defineNuxtConfig({ { status: 400 }, // Keep errors { duration: 500 }, // Keep slow requests (>500ms) { path: '/api/test/critical/**' }, // Keep critical paths + { path: '/api/test/drain' }, // Always keep drain test logs ], }, }, diff --git a/apps/playground/server/plugins/evlog-drain.ts b/apps/playground/server/plugins/evlog-drain.ts index 2954abc..4244aca 100644 --- a/apps/playground/server/plugins/evlog-drain.ts +++ b/apps/playground/server/plugins/evlog-drain.ts @@ -1,5 +1,6 @@ // import { createAxiomDrain } from 'evlog/axiom' // import { createPostHogDrain } from 'evlog/posthog' +// import { createSentryDrain } from 'evlog/sentry' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', (ctx) => { @@ -12,11 +13,12 @@ export default defineNitroPlugin((nitroApp) => { // const axiomDrain = createAxiomDrain({ // dataset: 'evlog', // }) - // axiomDrain(ctx) // const posthogDrain = createPostHogDrain() - // posthogDrain(ctx) + + // const sentryDrain = createSentryDrain() + // sentryDrain(ctx) }) }) diff --git a/packages/evlog/README.md b/packages/evlog/README.md index 959b4c2..236b2ed 100644 --- a/packages/evlog/README.md +++ b/packages/evlog/README.md @@ -431,6 +431,23 @@ Set environment variables: NUXT_OTLP_ENDPOINT=http://localhost:4318 ``` +### Sentry + +```typescript +// server/plugins/evlog-drain.ts +import { createSentryDrain } from 'evlog/sentry' + +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('evlog:drain', createSentryDrain()) +}) +``` + +Set environment variables: + +```bash +NUXT_SENTRY_DSN=https://public@o0.ingest.sentry.io/123 +``` + ### Multiple Destinations Send logs to multiple services: diff --git a/packages/evlog/build.config.ts b/packages/evlog/build.config.ts index 5ab8204..4ab16b9 100644 --- a/packages/evlog/build.config.ts +++ b/packages/evlog/build.config.ts @@ -19,6 +19,7 @@ export default defineBuildConfig({ { input: 'src/adapters/axiom', name: 'adapters/axiom' }, { input: 'src/adapters/otlp', name: 'adapters/otlp' }, { input: 'src/adapters/posthog', name: 'adapters/posthog' }, + { input: 'src/adapters/sentry', name: 'adapters/sentry' }, ], declaration: true, clean: true, diff --git a/packages/evlog/package.json b/packages/evlog/package.json index 1c082ba..653f4c4 100644 --- a/packages/evlog/package.json +++ b/packages/evlog/package.json @@ -50,6 +50,10 @@ "./posthog": { "types": "./dist/adapters/posthog.d.mts", "import": "./dist/adapters/posthog.mjs" + }, + "./sentry": { + "types": "./dist/adapters/sentry.d.mts", + "import": "./dist/adapters/sentry.mjs" } }, "main": "./dist/index.mjs", @@ -76,6 +80,9 @@ ], "posthog": [ "./dist/adapters/posthog.d.mts" + ], + "sentry": [ + "./dist/adapters/sentry.d.mts" ] } }, diff --git a/packages/evlog/src/adapters/sentry.ts b/packages/evlog/src/adapters/sentry.ts new file mode 100644 index 0000000..8c6e44d --- /dev/null +++ b/packages/evlog/src/adapters/sentry.ts @@ -0,0 +1,313 @@ +import type { DrainContext, LogLevel, WideEvent } from '../types' + +export interface SentryConfig { + /** Sentry DSN */ + dsn: string + /** Environment override (defaults to event.environment) */ + environment?: string + /** Release version override (defaults to event.version) */ + release?: string + /** Additional tags to attach as attributes */ + tags?: Record + /** Request timeout in milliseconds. Default: 5000 */ + timeout?: number +} + +/** Sentry Log attribute value with type annotation */ +export interface SentryAttributeValue { + value: string | number | boolean + type: 'string' | 'integer' | 'double' | 'boolean' +} + +/** Sentry Structured Log payload */ +export interface SentryLog { + timestamp: number + trace_id: string + level: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal' + body: string + severity_number: number + attributes?: Record +} + +interface SentryDsnParts { + publicKey: string + secretKey?: string + projectId: string + origin: string + basePath: string +} + +/** Based on OpenTelemetry Logs Data Model specification */ +const SEVERITY_MAP: Record = { + debug: 5, + info: 9, + warn: 13, + error: 17, +} + +/** + * Try to get runtime config from Nitro/Nuxt environment. + * Returns undefined if not in a Nitro context. + */ +function getRuntimeConfig(): { evlog?: { sentry?: Partial }, sentry?: Partial } | undefined { + try { + // Dynamic import to avoid bundling issues when not in Nitro + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { useRuntimeConfig } = require('nitropack/runtime') + return useRuntimeConfig() + } catch { + return undefined + } +} + +function parseSentryDsn(dsn: string): SentryDsnParts { + const url = new URL(dsn) + const publicKey = url.username + if (!publicKey) { + throw new Error('Invalid Sentry DSN: missing public key') + } + + const secretKey = url.password || undefined + + const pathParts = url.pathname.split('/').filter(Boolean) + const projectId = pathParts.pop() + if (!projectId) { + throw new Error('Invalid Sentry DSN: missing project ID') + } + + const basePath = pathParts.length > 0 ? `/${pathParts.join('/')}` : '' + + return { + publicKey, + secretKey, + projectId, + origin: `${url.protocol}//${url.host}`, + basePath, + } +} + +function getSentryEnvelopeUrl(dsn: string): { url: string, authHeader: string } { + const { publicKey, secretKey, projectId, origin, basePath } = parseSentryDsn(dsn) + const url = `${origin}${basePath}/api/${projectId}/envelope/` + let authHeader = `Sentry sentry_version=7, sentry_key=${publicKey}, sentry_client=evlog` + if (secretKey) { + authHeader += `, sentry_secret=${secretKey}` + } + return { url, authHeader } +} + +function createTraceId(): string { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return globalThis.crypto.randomUUID().replace(/-/g, '') + } + + return Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16)).join('') +} + +function getFirstStringValue(event: WideEvent, keys: string[]): string | undefined { + for (const key of keys) { + const value = event[key] + if (typeof value === 'string' && value.length > 0) return value + } + return undefined +} + +function toAttributeValue(value: unknown): SentryAttributeValue | undefined { + if (value === null || value === undefined) { + return undefined + } + if (typeof value === 'string') { + return { value, type: 'string' } + } + if (typeof value === 'boolean') { + return { value, type: 'boolean' } + } + if (typeof value === 'number') { + if (Number.isInteger(value)) { + return { value, type: 'integer' } + } + return { value, type: 'double' } + } + return { value: JSON.stringify(value), type: 'string' } +} + +export function toSentryLog(event: WideEvent, config: SentryConfig): SentryLog { + const { timestamp, level, service, environment, version, ...rest } = event + + const body = getFirstStringValue(event, ['message', 'action', 'path']) + ?? 'evlog wide event' + + const traceId = (typeof event.traceId === 'string' && event.traceId.length > 0) + ? event.traceId + : createTraceId() + + const attributes: Record = {} + + const env = config.environment ?? environment + if (env) { + attributes['sentry.environment'] = { value: env, type: 'string' } + } + + const rel = config.release ?? version + if (typeof rel === 'string' && rel.length > 0) { + attributes['sentry.release'] = { value: rel, type: 'string' } + } + + attributes['service'] = { value: service, type: 'string' } + + if (config.tags) { + for (const [key, value] of Object.entries(config.tags)) { + attributes[key] = { value, type: 'string' } + } + } + + for (const [key, value] of Object.entries(rest)) { + if (key === 'traceId' || key === 'spanId') continue + if (value === undefined || value === null) continue + const attr = toAttributeValue(value) + if (attr) { + attributes[key] = attr + } + } + + return { + timestamp: new Date(timestamp).getTime() / 1000, + trace_id: traceId, + level: level as SentryLog['level'], + body, + severity_number: SEVERITY_MAP[level] ?? 9, + attributes, + } +} + +/** + * Build the Sentry Envelope body for a list of logs. + * + * Envelope format (line-delimited): + * - Line 1: Envelope headers (dsn, sent_at) + * - Line 2: Item header (type: log, item_count, content_type) + * - Line 3: Item payload ({"items": [...]}) + */ +function buildEnvelopeBody(logs: SentryLog[], dsn: string): string { + const envelopeHeader = JSON.stringify({ + dsn, + sent_at: new Date().toISOString(), + }) + + const itemHeader = JSON.stringify({ + type: 'log', + item_count: logs.length, + content_type: 'application/vnd.sentry.items.log+json', + }) + + const itemPayload = JSON.stringify({ items: logs }) + + return `${envelopeHeader}\n${itemHeader}\n${itemPayload}\n` +} + +/** + * Create a drain function for sending logs to Sentry. + * + * Sends wide events as Sentry Structured Logs, visible in Explore > Logs + * in the Sentry dashboard. + * + * Configuration priority (highest to lowest): + * 1. Overrides passed to createSentryDrain() + * 2. runtimeConfig.evlog.sentry + * 3. runtimeConfig.sentry + * 4. Environment variables: NUXT_SENTRY_*, SENTRY_* + * + * @example + * ```ts + * // Zero config - just set NUXT_SENTRY_DSN env var + * nitroApp.hooks.hook('evlog:drain', createSentryDrain()) + * + * // With overrides + * nitroApp.hooks.hook('evlog:drain', createSentryDrain({ + * dsn: 'https://public@o0.ingest.sentry.io/123', + * })) + * ``` + */ +export function createSentryDrain(overrides?: Partial): (ctx: DrainContext) => Promise { + return async (ctx: DrainContext) => { + const runtimeConfig = getRuntimeConfig() + const evlogSentry = runtimeConfig?.evlog?.sentry + const rootSentry = runtimeConfig?.sentry + + const config: Partial = { + dsn: overrides?.dsn ?? evlogSentry?.dsn ?? rootSentry?.dsn ?? process.env.NUXT_SENTRY_DSN ?? process.env.SENTRY_DSN, + environment: overrides?.environment ?? evlogSentry?.environment ?? rootSentry?.environment ?? process.env.NUXT_SENTRY_ENVIRONMENT ?? process.env.SENTRY_ENVIRONMENT, + release: overrides?.release ?? evlogSentry?.release ?? rootSentry?.release ?? process.env.NUXT_SENTRY_RELEASE ?? process.env.SENTRY_RELEASE, + tags: overrides?.tags ?? evlogSentry?.tags ?? rootSentry?.tags, + timeout: overrides?.timeout ?? evlogSentry?.timeout ?? rootSentry?.timeout, + } + + if (!config.dsn) { + console.error('[evlog/sentry] Missing DSN. Set NUXT_SENTRY_DSN/SENTRY_DSN env var or pass to createSentryDrain()') + return + } + + try { + await sendToSentry(ctx.event, config as SentryConfig) + } catch (error) { + console.error('[evlog/sentry] Failed to send log:', error) + } + } +} + +/** + * Send a single event to Sentry as a structured log. + * + * @example + * ```ts + * await sendToSentry(event, { + * dsn: process.env.SENTRY_DSN!, + * }) + * ``` + */ +export async function sendToSentry(event: WideEvent, config: SentryConfig): Promise { + await sendBatchToSentry([event], config) +} + +/** + * Send a batch of events to Sentry as structured logs via the Envelope endpoint. + * + * @example + * ```ts + * await sendBatchToSentry(events, { + * dsn: process.env.SENTRY_DSN!, + * }) + * ``` + */ +export async function sendBatchToSentry(events: WideEvent[], config: SentryConfig): Promise { + if (events.length === 0) return + + const { url, authHeader } = getSentryEnvelopeUrl(config.dsn) + const timeout = config.timeout ?? 5000 + + const logs = events.map(event => toSentryLog(event, config)) + const body = buildEnvelopeBody(logs, config.dsn) + + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), timeout) + + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-sentry-envelope', + 'X-Sentry-Auth': authHeader, + }, + body, + 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(`Sentry API error: ${response.status} ${response.statusText} - ${safeText}`) + } + } finally { + clearTimeout(timeoutId) + } +} diff --git a/packages/evlog/src/nuxt/module.ts b/packages/evlog/src/nuxt/module.ts index 2534d32..1ee85b6 100644 --- a/packages/evlog/src/nuxt/module.ts +++ b/packages/evlog/src/nuxt/module.ts @@ -135,6 +135,54 @@ export interface ModuleOptions { /** Request timeout in milliseconds. Default: 5000 */ timeout?: number } + + /** + * PostHog adapter configuration. + * When configured, use `createPostHogDrain()` from `evlog/posthog` to send logs. + * + * @example + * ```ts + * posthog: { + * apiKey: process.env.POSTHOG_API_KEY, + * } + * ``` + */ + posthog?: { + /** PostHog project API key */ + apiKey: string + /** PostHog host URL. Default: https://us.i.posthog.com */ + host?: string + /** PostHog event name. Default: evlog_wide_event */ + eventName?: string + /** Override distinct_id (defaults to event.service) */ + distinctId?: string + /** Request timeout in milliseconds. Default: 5000 */ + timeout?: number + } + + /** + * Sentry adapter configuration. + * When configured, use `createSentryDrain()` from `evlog/sentry` to send logs. + * + * @example + * ```ts + * sentry: { + * dsn: process.env.SENTRY_DSN, + * } + * ``` + */ + sentry?: { + /** Sentry DSN */ + dsn: string + /** Environment override (defaults to event.environment) */ + environment?: string + /** Release version override (defaults to event.version) */ + release?: string + /** Additional tags to attach as attributes */ + tags?: Record + /** Request timeout in milliseconds. Default: 5000 */ + timeout?: number + } } export default defineNuxtModule({ diff --git a/packages/evlog/test/adapters/sentry.test.ts b/packages/evlog/test/adapters/sentry.test.ts new file mode 100644 index 0000000..bfdea64 --- /dev/null +++ b/packages/evlog/test/adapters/sentry.test.ts @@ -0,0 +1,300 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { WideEvent } from '../../src/types' +import { sendBatchToSentry, sendToSentry, toSentryLog } from '../../src/adapters/sentry' + +describe('sentry adapter', () => { + let fetchSpy: ReturnType + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(null, { status: 200 }), + ) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + const createTestEvent = (overrides?: Partial): WideEvent => ({ + timestamp: '2024-01-01T12:00:00.000Z', + level: 'info', + service: 'test-service', + environment: 'test', + ...overrides, + }) + + describe('toSentryLog', () => { + it('converts timestamp to unix seconds', () => { + const event = createTestEvent() + const result = toSentryLog(event, { dsn: 'https://public@o0.ingest.sentry.io/123' }) + + expect(result.timestamp).toBe(new Date('2024-01-01T12:00:00.000Z').getTime() / 1000) + }) + + it('maps severity numbers correctly', () => { + const levels = { debug: 5, info: 9, warn: 13, error: 17 } as const + + for (const [level, expectedSeverity] of Object.entries(levels)) { + const event = createTestEvent({ level: level as WideEvent['level'] }) + const result = toSentryLog(event, { dsn: 'https://public@o0.ingest.sentry.io/123' }) + + expect(result.severity_number).toBe(expectedSeverity) + expect(result.level).toBe(level) + } + }) + + it('keeps warn as warn (not warning)', () => { + const event = createTestEvent({ level: 'warn' }) + const result = toSentryLog(event, { dsn: 'https://public@o0.ingest.sentry.io/123' }) + + expect(result.level).toBe('warn') + }) + + it('uses event message as body', () => { + const event = createTestEvent({ message: 'Order created' }) + const result = toSentryLog(event, { dsn: 'https://public@o0.ingest.sentry.io/123' }) + + expect(result.body).toBe('Order created') + }) + + it('falls back to action then path for body', () => { + const eventWithAction = createTestEvent({ action: 'checkout' }) + expect(toSentryLog(eventWithAction, { dsn: 'https://public@o0.ingest.sentry.io/123' }).body).toBe('checkout') + + const eventWithPath = createTestEvent({ path: '/api/users' }) + expect(toSentryLog(eventWithPath, { dsn: 'https://public@o0.ingest.sentry.io/123' }).body).toBe('/api/users') + }) + + it('uses default body when no message/action/path', () => { + const event = createTestEvent() + const result = toSentryLog(event, { dsn: 'https://public@o0.ingest.sentry.io/123' }) + + expect(result.body).toBe('evlog wide event') + }) + + it('generates a 32-char hex trace_id', () => { + const event = createTestEvent() + const result = toSentryLog(event, { dsn: 'https://public@o0.ingest.sentry.io/123' }) + + expect(result.trace_id).toMatch(/^[a-f0-9]{32}$/) + }) + + it('uses event traceId when available', () => { + const event = createTestEvent({ traceId: 'abcdef1234567890abcdef1234567890' }) + const result = toSentryLog(event, { dsn: 'https://public@o0.ingest.sentry.io/123' }) + + expect(result.trace_id).toBe('abcdef1234567890abcdef1234567890') + }) + + it('includes service as typed attribute', () => { + const event = createTestEvent() + const result = toSentryLog(event, { dsn: 'https://public@o0.ingest.sentry.io/123' }) + + expect(result.attributes?.service).toEqual({ value: 'test-service', type: 'string' }) + }) + + it('includes sentry.environment attribute', () => { + const event = createTestEvent() + const result = toSentryLog(event, { dsn: 'https://public@o0.ingest.sentry.io/123' }) + + expect(result.attributes?.['sentry.environment']).toEqual({ value: 'test', type: 'string' }) + }) + + it('uses config environment over event environment', () => { + const event = createTestEvent({ environment: 'staging' }) + const result = toSentryLog(event, { + dsn: 'https://public@o0.ingest.sentry.io/123', + environment: 'production', + }) + + expect(result.attributes?.['sentry.environment']).toEqual({ value: 'production', type: 'string' }) + }) + + it('types attributes correctly', () => { + const event = createTestEvent({ + requestId: 'req-123', + duration: 234, + ratio: 0.75, + success: true, + nested: { key: 'value' }, + }) + const result = toSentryLog(event, { dsn: 'https://public@o0.ingest.sentry.io/123' }) + + expect(result.attributes?.requestId).toEqual({ value: 'req-123', type: 'string' }) + expect(result.attributes?.duration).toEqual({ value: 234, type: 'integer' }) + expect(result.attributes?.ratio).toEqual({ value: 0.75, type: 'double' }) + expect(result.attributes?.success).toEqual({ value: true, type: 'boolean' }) + expect(result.attributes?.nested).toEqual({ value: '{"key":"value"}', type: 'string' }) + }) + + it('includes custom tags from config as attributes', () => { + const event = createTestEvent() + const result = toSentryLog(event, { + dsn: 'https://public@o0.ingest.sentry.io/123', + tags: { team: 'backend', region: 'eu' }, + }) + + expect(result.attributes?.team).toEqual({ value: 'backend', type: 'string' }) + expect(result.attributes?.region).toEqual({ value: 'eu', type: 'string' }) + }) + + it('excludes traceId and spanId from attributes', () => { + const event = createTestEvent({ traceId: 'abc123', spanId: 'def456' }) + const result = toSentryLog(event, { dsn: 'https://public@o0.ingest.sentry.io/123' }) + + expect(result.attributes?.traceId).toBeUndefined() + expect(result.attributes?.spanId).toBeUndefined() + }) + }) + + describe('sendToSentry', () => { + it('sends log to correct Sentry envelope URL', async () => { + const event = createTestEvent() + + await sendToSentry(event, { + dsn: 'https://public@o123.ingest.sentry.io/456', + }) + + expect(fetchSpy).toHaveBeenCalledTimes(1) + const [url] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://o123.ingest.sentry.io/api/456/envelope/') + }) + + it('supports DSNs with path prefixes', async () => { + const event = createTestEvent() + + await sendToSentry(event, { + dsn: 'https://public@localhost:8080/sentry/456', + }) + + const [url] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://localhost:8080/sentry/api/456/envelope/') + }) + + it('sets Sentry auth header', async () => { + const event = createTestEvent() + + await sendToSentry(event, { + dsn: 'https://public@o123.ingest.sentry.io/456', + }) + + const [, options] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(options.headers).toEqual(expect.objectContaining({ + 'X-Sentry-Auth': expect.stringContaining('sentry_key=public'), + })) + }) + + it('sets Content-Type to application/x-sentry-envelope', async () => { + const event = createTestEvent() + + await sendToSentry(event, { + dsn: 'https://public@o123.ingest.sentry.io/456', + }) + + const [, options] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(options.headers).toEqual(expect.objectContaining({ + 'Content-Type': 'application/x-sentry-envelope', + })) + }) + + it('sends valid log envelope payload', async () => { + const event = createTestEvent({ level: 'error', message: 'boom' }) + + await sendToSentry(event, { + dsn: 'https://public@o123.ingest.sentry.io/456', + }) + + const [, options] = fetchSpy.mock.calls[0] as [string, RequestInit] + const lines = (options.body as string).split('\n').filter(line => line.length > 0) + + const envelopeHeader = JSON.parse(lines[0]) + expect(envelopeHeader.dsn).toBe('https://public@o123.ingest.sentry.io/456') + expect(envelopeHeader.sent_at).toBeDefined() + + const itemHeader = JSON.parse(lines[1]) + expect(itemHeader.type).toBe('log') + expect(itemHeader.item_count).toBe(1) + expect(itemHeader.content_type).toBe('application/vnd.sentry.items.log+json') + + const itemPayload = JSON.parse(lines[2]) + expect(itemPayload.items).toHaveLength(1) + + const [log] = itemPayload.items + expect(log.level).toBe('error') + expect(log.severity_number).toBe(17) + expect(log.body).toBe('boom') + expect(log.trace_id).toMatch(/^[a-f0-9]{32}$/) + expect(log.timestamp).toBeGreaterThan(0) + }) + + it('throws error on non-OK response', async () => { + fetchSpy.mockResolvedValueOnce( + new Response('Bad Request', { status: 400, statusText: 'Bad Request' }), + ) + + const event = createTestEvent() + + await expect(sendToSentry(event, { + dsn: 'https://public@o123.ingest.sentry.io/456', + })).rejects.toThrow('Sentry API error: 400 Bad Request') + }) + }) + + describe('sendBatchToSentry', () => { + it('sends multiple logs in a single request', async () => { + const events = [ + createTestEvent({ requestId: '1' }), + createTestEvent({ requestId: '2' }), + createTestEvent({ requestId: '3' }), + ] + + await sendBatchToSentry(events, { + dsn: 'https://public@o123.ingest.sentry.io/456', + }) + + expect(fetchSpy).toHaveBeenCalledTimes(1) + + const [, options] = fetchSpy.mock.calls[0] as [string, RequestInit] + const lines = (options.body as string).split('\n').filter(line => line.length > 0) + + const itemHeader = JSON.parse(lines[1]) + expect(itemHeader.item_count).toBe(3) + + const itemPayload = JSON.parse(lines[2]) + expect(itemPayload.items).toHaveLength(3) + }) + + it('does not send request for empty events array', async () => { + await sendBatchToSentry([], { + dsn: 'https://public@o123.ingest.sentry.io/456', + }) + + expect(fetchSpy).not.toHaveBeenCalled() + }) + }) + + describe('timeout handling', () => { + it('uses default timeout of 5000ms', async () => { + const event = createTestEvent() + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout') + + await sendToSentry(event, { + dsn: 'https://public@o123.ingest.sentry.io/456', + }) + + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 5000) + }) + + it('uses custom timeout when provided', async () => { + const event = createTestEvent() + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout') + + await sendToSentry(event, { + dsn: 'https://public@o123.ingest.sentry.io/456', + timeout: 10000, + }) + + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 10000) + }) + }) +})