diff --git a/.agents/skills/create-adapter/SKILL.md b/.agents/skills/create-adapter/SKILL.md index b3e8cb8b..f1545995 100644 --- a/.agents/skills/create-adapter/SKILL.md +++ b/.agents/skills/create-adapter/SKILL.md @@ -25,8 +25,8 @@ The exact wording may vary depending on the adapter (e.g., `feat: add OTLP adapt | 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) | +| 5 | `apps/docs/content/5.adapters/{n}.{name}.md` | Create adapter doc page (before `custom.md`) | +| 6 | `apps/docs/content/5.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 | @@ -71,7 +71,7 @@ Add a build entry in `packages/evlog/tsdown.config.ts` alongside the existing ad 'adapters/{name}': 'src/adapters/{name}.ts', ``` -Place it after the last adapter entry (currently `hyperdx` in `tsdown.config.ts`). +Place it after the last adapter entry in `tsdown.config.ts` (follow existing ordering in that file). ## Step 3: Package Exports @@ -113,7 +113,7 @@ Required test categories: 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. +Use the existing Axiom adapter page (`apps/docs/content/5.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. diff --git a/.changeset/evl-144-datadog-adapter.md b/.changeset/evl-144-datadog-adapter.md new file mode 100644 index 00000000..936d052e --- /dev/null +++ b/.changeset/evl-144-datadog-adapter.md @@ -0,0 +1,5 @@ +--- +"evlog": minor +--- + +Add Datadog Logs HTTP drain adapter (`evlog/datadog`): `createDatadogDrain()`, `sendToDatadog` / `sendBatchToDatadog`, env vars `DD_API_KEY` / `NUXT_DATADOG_*` / `DD_SITE`, and intake URL for all Datadog sites. Maps wide events with a short `message` line, full payload under `evlog`, severity `status`, and recursive `httpStatusCode` renaming so HTTP `status` fields never clash with Datadog’s reserved severity ([EVL-144](https://linear.app/evlog/issue/EVL-144)). diff --git a/AGENTS.md b/AGENTS.md index 5d1107e7..3b595580 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,7 @@ evlog/ │ │ ├── vite/ # Vite plugin (evlog/vite) │ │ ├── shared/ # Toolkit: building blocks for custom framework integrations (evlog/toolkit) │ │ ├── ai/ # AI SDK integration (evlog/ai) -│ │ ├── adapters/ # Log drain adapters (Axiom, OTLP, HyperDX, PostHog, Sentry, Better Stack) +│ │ ├── adapters/ # Log drain adapters (Axiom, OTLP, HyperDX, PostHog, Sentry, Better Stack, Datadog) │ │ ├── enrichers/ # Built-in enrichers (UserAgent, Geo, RequestSize, TraceContext) │ │ └── runtime/ # Runtime code (client/, server/, utils/) │ └── test/ # Tests @@ -325,6 +325,7 @@ evlog provides built-in adapters for popular observability platforms. Use the `e | PostHog | `evlog/posthog` | Send logs to PostHog Logs via OTLP for structured logging and observability | | Sentry | `evlog/sentry` | Send logs to Sentry Logs for structured logging and debugging | | Better Stack | `evlog/better-stack` | Send logs to Better Stack for log management and alerting | +| Datadog | `evlog/datadog` | Send logs to Datadog Logs via the HTTP intake API (`DD-API-KEY`) | **Using Axiom Adapter:** @@ -404,6 +405,19 @@ export default defineNitroPlugin((nitroApp) => { Set environment variable: `NUXT_BETTER_STACK_SOURCE_TOKEN`. +**Using Datadog Adapter:** + +```typescript +// server/plugins/evlog-drain.ts +import { createDatadogDrain } from 'evlog/datadog' + +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('evlog:drain', createDatadogDrain()) +}) +``` + +Set environment variables: `NUXT_DATADOG_API_KEY` or `DD_API_KEY`, and optionally `DD_SITE` (e.g. `datadoghq.eu`). + **Multiple Destinations:** ```typescript diff --git a/apps/docs/app/components/app/AppHeaderCenter.vue b/apps/docs/app/components/app/AppHeaderCenter.vue index d60c473b..f35137f4 100644 --- a/apps/docs/app/components/app/AppHeaderCenter.vue +++ b/apps/docs/app/components/app/AppHeaderCenter.vue @@ -108,6 +108,12 @@ const landingItems = [ icon: 'i-simple-icons-betterstack', description: 'Stream logs to Better Stack', to: '/adapters/better-stack' + }, + { + label: 'Datadog', + icon: 'i-simple-icons-datadog', + description: 'Send logs to Datadog', + to: '/adapters/datadog' } ] }, diff --git a/apps/docs/content/5.adapters/1.overview.md b/apps/docs/content/5.adapters/1.overview.md index a1ece2a5..7aa2ca8e 100644 --- a/apps/docs/content/5.adapters/1.overview.md +++ b/apps/docs/content/5.adapters/1.overview.md @@ -35,6 +35,11 @@ links: to: /adapters/better-stack color: neutral variant: subtle + - label: Datadog + icon: i-simple-icons-datadog + to: /adapters/datadog + color: neutral + variant: subtle - label: File System icon: i-lucide-hard-drive to: /adapters/fs @@ -152,6 +157,15 @@ initLogger({ drain: createAxiomDrain() }) Send logs to Better Stack for log management and alerting. ::: + :::card + --- + icon: i-simple-icons-datadog + title: Datadog + to: /adapters/datadog + --- + Send logs to Datadog Logs via the native HTTP intake API. + ::: + :::card --- icon: i-lucide-hard-drive @@ -304,6 +318,10 @@ SENTRY_DSN=https://key@o0.ingest.sentry.io/123 # Better Stack (NUXT_BETTER_STACK_* or BETTER_STACK_*) BETTER_STACK_SOURCE_TOKEN=your-source-token + +# Datadog (NUXT_DATADOG_* or DATADOG_* or DD_*) +DD_API_KEY=your-api-key +DD_SITE=datadoghq.eu ``` Adapters auto-read from these variables, so just call `createXDrain()` with no arguments. diff --git a/apps/docs/content/5.adapters/9.custom.md b/apps/docs/content/5.adapters/10.custom.md similarity index 100% rename from apps/docs/content/5.adapters/9.custom.md rename to apps/docs/content/5.adapters/10.custom.md diff --git a/apps/docs/content/5.adapters/10.pipeline.md b/apps/docs/content/5.adapters/11.pipeline.md similarity index 100% rename from apps/docs/content/5.adapters/10.pipeline.md rename to apps/docs/content/5.adapters/11.pipeline.md diff --git a/apps/docs/content/5.adapters/11.browser.md b/apps/docs/content/5.adapters/12.browser.md similarity index 100% rename from apps/docs/content/5.adapters/11.browser.md rename to apps/docs/content/5.adapters/12.browser.md diff --git a/apps/docs/content/5.adapters/8.datadog.md b/apps/docs/content/5.adapters/8.datadog.md new file mode 100644 index 00000000..95deadf2 --- /dev/null +++ b/apps/docs/content/5.adapters/8.datadog.md @@ -0,0 +1,220 @@ +--- +title: Datadog Adapter +description: Send wide events to Datadog Logs via the native HTTP intake API. Supports all Datadog sites and DD_* environment variables. +navigation: + title: Datadog + icon: i-simple-icons-datadog +links: + - label: Datadog Logs + icon: i-lucide-external-link + to: https://docs.datadoghq.com/logs/ + target: _blank + color: neutral + variant: subtle + - label: OTLP Adapter + icon: i-simple-icons-opentelemetry + to: /adapters/otlp + color: neutral + variant: subtle +--- + +[Datadog](https://www.datadoghq.com) is a monitoring and security platform. The evlog Datadog adapter sends your wide events to [Datadog Logs](https://docs.datadoghq.com/logs/) using the **HTTP Logs intake API (v2)** with the `DD-API-KEY` header. + +For OpenTelemetry-based ingestion instead, see the [OTLP adapter](/adapters/otlp). + +::code-collapse + +```txt [Prompt] +Add the Datadog drain adapter to send evlog wide events to Datadog Logs. + +1. Identify which framework I'm using and follow its evlog integration pattern +2. Install evlog if not already installed +3. Import createDatadogDrain from 'evlog/datadog' +4. Wire createDatadogDrain() into my framework's drain configuration +5. Set DD_API_KEY (or DATADOG_API_KEY) and optionally DD_SITE in .env +6. Test by triggering a request and checking Log Explorer in Datadog + +Adapter docs: https://www.evlog.dev/adapters/datadog +Framework setup: https://www.evlog.dev/frameworks +``` + +:: + +## Installation + +The Datadog adapter comes bundled with evlog: + +```typescript [src/index.ts] +import { createDatadogDrain } from 'evlog/datadog' +``` + +## Quick Start + +### 1. Get your API key + +1. Open [Datadog Organization Settings → API Keys](https://app.datadoghq.com/organization-settings/api-keys) +2. Create or copy an API key with permission to submit logs + +### 2. Set environment variables + +```bash [.env] +DD_API_KEY=your-api-key +# Optional — defaults to datadoghq.com (US1) +DD_SITE=datadoghq.eu +``` + +### 3. Wire the drain to your framework + +::code-group +```typescript [Nuxt / Nitro] +// server/plugins/evlog-drain.ts +import { createDatadogDrain } from 'evlog/datadog' + +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('evlog:drain', createDatadogDrain()) +}) +``` +```typescript [Hono] +import { createDatadogDrain } from 'evlog/datadog' + +app.use(evlog({ drain: createDatadogDrain() })) +``` +```typescript [Express] +import { createDatadogDrain } from 'evlog/datadog' + +app.use(evlog({ drain: createDatadogDrain() })) +``` +```typescript [Fastify] +import { createDatadogDrain } from 'evlog/datadog' + +await app.register(evlog, { drain: createDatadogDrain() }) +``` +```typescript [Elysia] +import { createDatadogDrain } from 'evlog/datadog' + +app.use(evlog({ drain: createDatadogDrain() })) +``` +```typescript [NestJS] +import { createDatadogDrain } from 'evlog/datadog' + +EvlogModule.forRoot({ drain: createDatadogDrain() }) +``` +```typescript [Standalone] +import { createDatadogDrain } from 'evlog/datadog' + +initLogger({ drain: createDatadogDrain() }) +``` +:: + +Wide events appear in **Logs → Explorer**. The adapter sets `ddsource` to `evlog` and `message` to a JSON string of the full wide event for easy JSON parsing in pipelines. + +## Configuration + +The adapter reads configuration from multiple sources (highest priority first): + +1. **Overrides** passed to `createDatadogDrain()` +2. **Runtime config** at `runtimeConfig.datadog` or `runtimeConfig.evlog.datadog` (Nuxt/Nitro) +3. **Environment variables** — see table below + +### Environment Variables + +| Variable | Nuxt alias | Description | +|----------|------------|-------------| +| `DD_API_KEY` | `NUXT_DATADOG_API_KEY` | Datadog API key (required). Also: `DATADOG_API_KEY` | +| `DD_SITE` | `NUXT_DATADOG_SITE` | Site hostname (e.g. `datadoghq.com`, `datadoghq.eu`, `us3.datadoghq.com`). Also: `DATADOG_SITE` | +| `DATADOG_LOGS_URL` | `NUXT_DATADOG_LOGS_URL` | Full intake URL — overrides URL derived from `site` | + +### Runtime Config (Nuxt only) + +```typescript [nuxt.config.ts] +export default defineNuxtConfig({ + runtimeConfig: { + datadog: { + apiKey: '', // Set via NUXT_DATADOG_API_KEY or DD_API_KEY + site: 'datadoghq.eu', + }, + }, +}) +``` + +### Override Options + +```typescript [server/plugins/evlog-drain.ts] +const drain = createDatadogDrain({ + apiKey: '***', + site: 'us5.datadoghq.com', + timeout: 10000, +}) +``` + +### Full Configuration Reference + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `apiKey` | `string` | — | Datadog API key (required) | +| `site` | `string` | `datadoghq.com` | Site for intake host `http-intake.logs.${site}` | +| `intakeUrl` | `string` | from `site` | Full `POST` URL for `/api/v2/logs` | +| `timeout` | `number` | `5000` | Request timeout (ms) | +| `retries` | `number` | `2` | Retries on transient failures | + +## Log shape + +Each wide event becomes one Datadog log with: + +- **`message`** — short one-line summary for the list view (e.g. `ERROR GET /api/checkout (400)`), built with `formatDatadogMessageLine`. Easier to scan than a full JSON blob in Live Tail. +- **`evlog`** — full wide event as a **JSON object** (not a string). Numeric HTTP **`status`** fields anywhere in the tree are renamed to **`httpStatusCode`** so they never clash with Datadog’s reserved severity `status`. +- **`service`**, **`status`** (Datadog severity — drives Live Tail color), **`ddsource`**: `evlog`, **`ddtags`**: `env:…` and optional `version:…` +- **`timestamp`**: Unix milliseconds from `WideEvent.timestamp` + +**Severity (`status`)** at intake root is computed by the adapter from the wide event’s **`level`** and HTTP **`status`** (`resolveDatadogLogStatus` in `evlog/datadog`). Business-only fields on **HTTP 200** stay **`info`** unless you call **`log.error()`**. + +For advanced use, `sanitizeWideEventForDatadog(event)` returns only the sanitized object you would store under `evlog`. + +## Querying in Datadog + +- **Log Explorer**: `source:evlog`, `service:your-app`, `status:error` +- **Facets**: prefer `@evlog.path`, `@evlog.requestId`, `@evlog.level`, etc. — core fields are under **`evlog`**, not a JSON string in `message` +- **Metrics**: log-based metrics on `@evlog.*` attributes +- **Pipelines**: if you previously parsed a full JSON **string** inside `message`, move those facets to **`@evlog.*`**. The `message` field is now a short summary line only. + +## Simple logs vs wide events + +Plain-text lines in Live Tail (e.g. “Form field is empty”) usually come from **`log.info('tag', 'msg')`** or similar, not from the **wide event** sent on **`emit()`**. Those lines go to the console (and any Agent-based log stream), while the Datadog drain sends one structured log per wide event under **`source:evlog`**. + +## Troubleshooting + +### Missing API key + +```text [Console] +[evlog/datadog] Missing API key. Set NUXT_DATADOG_API_KEY, DATADOG_API_KEY, or DD_API_KEY... +``` + +Set `DD_API_KEY` (or unprefixed `DATADOG_API_KEY`) and restart the process. + +### 403 Forbidden + +The API key may lack log ingestion permission or belong to the wrong organization. Verify the key in Datadog and try a new key. + +### Wrong region / site + +If logs never appear, confirm `DD_SITE` matches your Datadog account (e.g. EU: `datadoghq.eu`). For a custom intake URL, set `DATADOG_LOGS_URL` / `NUXT_DATADOG_LOGS_URL`. + +## Direct API usage + +```typescript [server/utils/datadog.ts] +import { sendToDatadog, sendBatchToDatadog } from 'evlog/datadog' + +await sendToDatadog(event, { + apiKey: process.env.DD_API_KEY!, + site: process.env.DD_SITE, +}) + +await sendBatchToDatadog(events, { + apiKey: process.env.DD_API_KEY!, +}) +``` + +## Next Steps + +- [OTLP Adapter](/adapters/otlp) — Send logs via OpenTelemetry (works with Datadog Agent / OTLP endpoint) +- [Custom Adapters](/adapters/custom) — Build your own destination diff --git a/apps/docs/content/5.adapters/8.hyperdx.md b/apps/docs/content/5.adapters/9.hyperdx.md similarity index 100% rename from apps/docs/content/5.adapters/8.hyperdx.md rename to apps/docs/content/5.adapters/9.hyperdx.md diff --git a/apps/docs/skills/review-logging-patterns/SKILL.md b/apps/docs/skills/review-logging-patterns/SKILL.md index 16221af6..2e2efbc8 100644 --- a/apps/docs/skills/review-logging-patterns/SKILL.md +++ b/apps/docs/skills/review-logging-patterns/SKILL.md @@ -1,6 +1,6 @@ --- name: review-logging-patterns -description: Review code for logging patterns and suggest evlog adoption. Guides setup on Nuxt, Next.js, SvelteKit, Nitro, TanStack Start, React Router, NestJS, Express, Hono, Fastify, Elysia, Cloudflare Workers, and standalone TypeScript. Detects console.log spam, unstructured errors, and missing context. Covers wide events, structured errors, drain adapters (Axiom, OTLP, HyperDX, PostHog, Sentry, Better Stack), sampling, enrichers, and AI SDK integration (token usage, tool calls, streaming metrics). +description: Review code for logging patterns and suggest evlog adoption. Guides setup on Nuxt, Next.js, SvelteKit, Nitro, TanStack Start, React Router, NestJS, Express, Hono, Fastify, Elysia, Cloudflare Workers, and standalone TypeScript. Detects console.log spam, unstructured errors, and missing context. Covers wide events, structured errors, drain adapters (Axiom, OTLP, HyperDX, PostHog, Sentry, Better Stack, Datadog), sampling, enrichers, and AI SDK integration (token usage, tool calls, streaming metrics). license: MIT metadata: author: HugoRCD @@ -747,6 +747,7 @@ All options work in Nuxt (`evlog` key), Nitro (passed to `evlog()`), Next.js (`c | PostHog | `evlog/posthog` | `POSTHOG_API_KEY`, `POSTHOG_HOST` | | Sentry | `evlog/sentry` | `SENTRY_DSN` | | Better Stack | `evlog/better-stack` | `BETTER_STACK_SOURCE_TOKEN` | +| Datadog | `evlog/datadog` | `DD_API_KEY` or `DATADOG_API_KEY`, optional `DD_SITE` / `DATADOG_LOGS_URL` | | File System | `evlog/fs` | None (local file system) | In Nuxt/Nitro, use the `NUXT_` prefix (e.g., `NUXT_AXIOM_TOKEN`) so values are available via `useRuntimeConfig()`. All adapters also read unprefixed variables as fallback. diff --git a/apps/playground/server/plugins/evlog-drain.ts b/apps/playground/server/plugins/evlog-drain.ts index 1bd6793f..88091a90 100644 --- a/apps/playground/server/plugins/evlog-drain.ts +++ b/apps/playground/server/plugins/evlog-drain.ts @@ -3,6 +3,7 @@ // import { createSentryDrain } from 'evlog/sentry' // import { createBetterStackDrain } from 'evlog/better-stack' import { createFsDrain } from 'evlog/fs' +import { createDatadogDrain } from 'evlog/datadog' export default defineNitroPlugin((nitroApp) => { nitroApp.hooks.hook('evlog:drain', (ctx) => { @@ -26,6 +27,9 @@ export default defineNitroPlugin((nitroApp) => { // const betterStackDrain = createBetterStackDrain() // betterStackDrain(ctx) + const datadogDrain = createDatadogDrain() + datadogDrain(ctx) + const fsDrain = createFsDrain() fsDrain(ctx) }) diff --git a/examples/react-router/.gitignore b/examples/react-router/.gitignore index 31d2101d..a7fb7c0d 100644 --- a/examples/react-router/.gitignore +++ b/examples/react-router/.gitignore @@ -1 +1,2 @@ .react-router +build diff --git a/examples/sveltekit/.gitignore b/examples/sveltekit/.gitignore index 8ee84755..63623bc9 100644 --- a/examples/sveltekit/.gitignore +++ b/examples/sveltekit/.gitignore @@ -1 +1,2 @@ .svelte-kit +build diff --git a/packages/evlog/README.md b/packages/evlog/README.md index 16c3a001..c1c5dcf7 100644 --- a/packages/evlog/README.md +++ b/packages/evlog/README.md @@ -754,6 +754,29 @@ Set environment variables: NUXT_OTLP_ENDPOINT=http://localhost:4318 ``` +### Datadog + +```typescript +// server/plugins/evlog-drain.ts +import { createDatadogDrain } from 'evlog/datadog' + +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('evlog:drain', createDatadogDrain()) +}) +``` + +Set environment variables: + +```bash +NUXT_DATADOG_API_KEY=your-api-key +# Optional — defaults to datadoghq.com +NUXT_DATADOG_SITE=datadoghq.eu +``` + +You can also use standard Datadog names: `DD_API_KEY` and `DD_SITE`. + +Wide events are sent with a short **`message` line** (method, path, level) and full context under the **`evlog`** attribute (facets like `@evlog.path`). See the [Datadog adapter docs](https://www.evlog.dev/adapters/datadog). + ### PostHog ```typescript diff --git a/packages/evlog/package.json b/packages/evlog/package.json index df4e04a0..c812b417 100644 --- a/packages/evlog/package.json +++ b/packages/evlog/package.json @@ -27,7 +27,8 @@ "sveltekit", "react-router", "vite", - "typescript" + "typescript", + "datadog" ], "license": "MIT", "type": "module", @@ -88,6 +89,11 @@ "import": "./dist/adapters/hyperdx.mjs", "default": "./dist/adapters/hyperdx.mjs" }, + "./datadog": { + "types": "./dist/adapters/datadog.d.mts", + "import": "./dist/adapters/datadog.mjs", + "default": "./dist/adapters/datadog.mjs" + }, "./fs": { "types": "./dist/adapters/fs.d.mts", "import": "./dist/adapters/fs.mjs", @@ -216,6 +222,9 @@ "hyperdx": [ "./dist/adapters/hyperdx.d.mts" ], + "datadog": [ + "./dist/adapters/datadog.d.mts" + ], "fs": [ "./dist/adapters/fs.d.mts" ], diff --git a/packages/evlog/src/adapters/datadog.ts b/packages/evlog/src/adapters/datadog.ts new file mode 100644 index 00000000..1eb8cb39 --- /dev/null +++ b/packages/evlog/src/adapters/datadog.ts @@ -0,0 +1,199 @@ +import type { WideEvent } from '../types' +import type { ConfigField } from './_config' +import { resolveAdapterConfig } from './_config' +import { defineDrain } from './_drain' +import { httpPost } from './_http' + +export interface DatadogConfig { + /** Datadog API key with Logs intake permission */ + apiKey: string + /** + * Datadog site hostname (e.g. `datadoghq.com`, `datadoghq.eu`, `us3.datadoghq.com`, `ddog-gov.com`). + * Ignored when `intakeUrl` is set. Default: `datadoghq.com` + */ + site?: string + /** + * Full Logs HTTP intake URL. When set, overrides the URL derived from `site`. + * Default: `https://http-intake.logs.${site}/api/v2/logs` + */ + intakeUrl?: string + /** Request timeout in milliseconds. Default: 5000 */ + timeout?: number + /** Number of retry attempts on transient failures. Default: 2 */ + retries?: number +} + +const DATADOG_FIELDS: ConfigField[] = [ + { key: 'apiKey', env: ['NUXT_DATADOG_API_KEY', 'DATADOG_API_KEY', 'DD_API_KEY'] }, + { key: 'site', env: ['NUXT_DATADOG_SITE', 'DATADOG_SITE', 'DD_SITE'] }, + { key: 'intakeUrl', env: ['NUXT_DATADOG_LOGS_URL', 'DATADOG_LOGS_URL'] }, + { key: 'timeout' }, + { key: 'retries' }, +] + +const DEFAULT_SITE = 'datadoghq.com' + +/** + * Datadog treats **`status`** as log severity. evlog uses **`status`** for HTTP response codes on the wide event and + * inside **`error`** (structured errors). Rename every **numeric** `status` at any depth to **`httpStatusCode`** so + * nothing in the payload collides with reserved severity when Datadog processes attributes. + * + * Does not mutate the original {@link WideEvent} (builds new objects). + */ +export function sanitizeWideEventForDatadog(event: WideEvent): Record { + return deepRenameNumericHttpStatus(event as Record) as Record +} + +function deepRenameNumericHttpStatus(value: unknown): unknown { + if (value === null || typeof value !== 'object') return value + if (Array.isArray(value)) return value.map(deepRenameNumericHttpStatus) + const obj = value as Record + const out: Record = {} + for (const [k, v] of Object.entries(obj)) { + if (k === 'status' && typeof v === 'number') { + out.httpStatusCode = v + } else { + out[k] = deepRenameNumericHttpStatus(v) + } + } + return out +} + +/** + * Single-line summary for Datadog’s `message` column (Live Tail / Explorer list view). + * Full context stays under {@link toDatadogLog}'s `evlog` object. + */ +export function formatDatadogMessageLine(event: WideEvent): string { + const levelU = event.level.toUpperCase() + const method = typeof event.method === 'string' ? event.method : '' + const path = typeof event.path === 'string' ? event.path : '' + const code = typeof event.status === 'number' ? event.status : undefined + + const head = [levelU, method, path].filter(p => p.length > 0).join(' ') + let line = code !== undefined + ? (head ? `${head} (${code})` : `${levelU} (${code})`) + : (head || levelU) + + if (!method && !path && line === levelU && event.service) { + line = `${levelU} ${event.service}` + } + return line +} + +/** + * Severity for Datadog’s reserved `status` field (drives Live Tail coloring and facets). + * + * Uses the wide event’s **`level`** first (`log.error()` / `log.warn()`). If the level is + * still `info`, falls back to the HTTP **`status`** on the wide event (`status: 4xx` → `warn`, + * `5xx` → `error`) so client/server error responses are visible even when no `log.error()` + * ran. Purely business errors on **HTTP 200** only change Datadog if you call `log.error()`. + */ +export function resolveDatadogLogStatus(event: WideEvent): 'error' | 'warn' | 'info' | 'debug' { + if (event.level === 'error') return 'error' + if (event.level === 'warn') return 'warn' + if (event.level === 'debug') return 'debug' + const code = typeof event.status === 'number' ? event.status : undefined + if (code !== undefined && code >= 500) return 'error' + if (code !== undefined && code >= 400) return 'warn' + return 'info' +} + +/** + * Map an evlog wide event to a [Datadog Logs API v2](https://docs.datadoghq.com/api/latest/logs/) log object. + * + * Shape: + * - **`message`** — short line for the list view (`formatDatadogMessageLine`) + * - **`evlog`** — full sanitized wide event (HTTP codes as `httpStatusCode`); use facets like `@evlog.path` + * - **`status`**, **`service`**, **`ddsource`**, **`ddtags`**, **`timestamp`** — Datadog standard fields + */ +export function toDatadogLog(event: WideEvent): Record { + const ms = Date.parse(event.timestamp) + const tags = [`env:${event.environment}`] + const versionTag = event.version + if (versionTag !== undefined && versionTag !== null && versionTag !== '') { + tags.push(`version:${String(versionTag)}`) + } + + return { + message: formatDatadogMessageLine(event), + evlog: sanitizeWideEventForDatadog(event), + service: event.service, + status: resolveDatadogLogStatus(event), + ddsource: 'evlog', + ddtags: tags.join(','), + ...(Number.isFinite(ms) ? { timestamp: ms } : {}), + } +} + +/** + * Resolve the Logs intake URL from configuration. + */ +export function resolveDatadogIntakeUrl(config: Pick): string { + if (config.intakeUrl) { + return config.intakeUrl.replace(/\/+$/, '') + } + const site = (config.site ?? DEFAULT_SITE).replace(/^\./, '').replace(/\/+$/, '') + return `https://http-intake.logs.${site}/api/v2/logs` +} + +/** + * Create a drain function for sending logs to Datadog via the HTTP Logs intake API. + * + * Configuration priority (highest to lowest): + * 1. Overrides passed to `createDatadogDrain()` + * 2. `runtimeConfig.evlog.datadog` + * 3. `runtimeConfig.datadog` + * 4. Environment variables: `NUXT_DATADOG_*`, `DATADOG_*`, and common `DD_*` aliases + * + * @example + * ```ts + * // Zero config — set DD_API_KEY (or NUXT_DATADOG_API_KEY) and optionally DD_SITE + * nitroApp.hooks.hook('evlog:drain', createDatadogDrain()) + * + * nitroApp.hooks.hook('evlog:drain', createDatadogDrain({ + * site: 'datadoghq.eu', + * })) + * ``` + */ +export function createDatadogDrain(overrides?: Partial) { + return defineDrain({ + name: 'datadog', + resolve: async () => { + const config = await resolveAdapterConfig('datadog', DATADOG_FIELDS, overrides) + if (!config.apiKey) { + console.error('[evlog/datadog] Missing API key. Set NUXT_DATADOG_API_KEY, DATADOG_API_KEY, or DD_API_KEY, or pass apiKey to createDatadogDrain()') + return null + } + return config as DatadogConfig + }, + send: sendBatchToDatadog, + }) +} + +/** + * Send a single wide event to Datadog. + */ +export async function sendToDatadog(event: WideEvent, config: DatadogConfig): Promise { + await sendBatchToDatadog([event], config) +} + +/** + * Send a batch of wide events to Datadog in one request. + */ +export async function sendBatchToDatadog(events: WideEvent[], config: DatadogConfig): Promise { + if (events.length === 0) return + + const url = resolveDatadogIntakeUrl(config) + + await httpPost({ + url, + headers: { + 'Content-Type': 'application/json', + 'DD-API-KEY': config.apiKey, + }, + body: JSON.stringify(events.map(toDatadogLog)), + timeout: config.timeout ?? 5000, + retries: config.retries, + label: 'Datadog', + }) +} diff --git a/packages/evlog/test/adapters/datadog.test.ts b/packages/evlog/test/adapters/datadog.test.ts new file mode 100644 index 00000000..cbfedc89 --- /dev/null +++ b/packages/evlog/test/adapters/datadog.test.ts @@ -0,0 +1,312 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { WideEvent } from '../../src/types' +import { + formatDatadogMessageLine, + resolveDatadogIntakeUrl, + resolveDatadogLogStatus, + sanitizeWideEventForDatadog, + sendBatchToDatadog, + sendToDatadog, + toDatadogLog, +} from '../../src/adapters/datadog' + +describe('datadog 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('resolveDatadogIntakeUrl', () => { + it('uses default US1 site when not configured', () => { + expect(resolveDatadogIntakeUrl({})).toBe('https://http-intake.logs.datadoghq.com/api/v2/logs') + }) + + it('respects custom site', () => { + expect(resolveDatadogIntakeUrl({ site: 'datadoghq.eu' })).toBe( + 'https://http-intake.logs.datadoghq.eu/api/v2/logs', + ) + }) + + it('uses intakeUrl when set', () => { + expect( + resolveDatadogIntakeUrl({ + intakeUrl: 'https://custom.example.com/api/v2/logs/', + }), + ).toBe('https://custom.example.com/api/v2/logs') + }) + }) + + describe('resolveDatadogLogStatus', () => { + it('uses wide event level when set', () => { + expect(resolveDatadogLogStatus(createTestEvent({ level: 'error' }))).toBe('error') + expect(resolveDatadogLogStatus(createTestEvent({ level: 'warn' }))).toBe('warn') + expect(resolveDatadogLogStatus(createTestEvent({ level: 'debug' }))).toBe('debug') + }) + + it('maps HTTP 5xx to error when level is still info', () => { + expect(resolveDatadogLogStatus(createTestEvent({ status: 503 }))).toBe('error') + }) + + it('maps HTTP 4xx to warn when level is still info', () => { + expect(resolveDatadogLogStatus(createTestEvent({ status: 402 }))).toBe('warn') + }) + + it('keeps explicit error level even when HTTP status is 2xx', () => { + expect(resolveDatadogLogStatus(createTestEvent({ level: 'error', status: 200 }))).toBe('error') + }) + + it('keeps warn level over HTTP 5xx', () => { + expect(resolveDatadogLogStatus(createTestEvent({ level: 'warn', status: 503 }))).toBe('warn') + }) + }) + + describe('sanitizeWideEventForDatadog', () => { + it('renames top-level numeric status so Datadog attributes do not clobber log severity', () => { + const event = createTestEvent({ status: 400, level: 'error', path: '/api/pay' }) + const parsed = sanitizeWideEventForDatadog(event) + + expect(parsed.status).toBeUndefined() + expect(parsed.httpStatusCode).toBe(400) + expect(parsed.level).toBe('error') + }) + + it('renames nested error.status (e.g. EvlogError) the same way', () => { + const event = createTestEvent({ + level: 'error', + status: 400, + error: { + name: 'EvlogError', + message: 'Payment processing failed', + status: 400, + data: { why: 'Card declined' }, + }, + }) + const parsed = sanitizeWideEventForDatadog(event) + + expect(parsed.status).toBeUndefined() + expect(parsed.httpStatusCode).toBe(400) + const err = parsed.error as Record + expect(err.status).toBeUndefined() + expect(err.httpStatusCode).toBe(400) + }) + + it('omits httpStatusCode when HTTP status is absent', () => { + const event = createTestEvent({ path: '/ok' }) + const parsed = sanitizeWideEventForDatadog(event) + + expect(parsed.httpStatusCode).toBeUndefined() + expect(parsed.path).toBe('/ok') + }) + + it('does not mutate the original wide event', () => { + const event = createTestEvent({ + status: 418, + error: { name: 'E', message: 'm', status: 418 }, + }) + sanitizeWideEventForDatadog(event) + expect(event.status).toBe(418) + expect((event.error as Record).status).toBe(418) + }) + }) + + describe('formatDatadogMessageLine', () => { + it('includes level, method, path, and status code', () => { + const line = formatDatadogMessageLine( + createTestEvent({ method: 'GET', path: '/api/x', status: 400, level: 'warn' }), + ) + expect(line).toBe('WARN GET /api/x (400)') + }) + + it('falls back to service when method and path are missing', () => { + expect(formatDatadogMessageLine(createTestEvent())).toBe('INFO test-service') + }) + }) + + describe('toDatadogLog', () => { + it('maps wide event fields for Datadog Logs v2', () => { + const event = createTestEvent({ path: '/api/hello', userId: 'u1', method: 'POST' }) + const row = toDatadogLog(event) + + expect(row.service).toBe('test-service') + expect(row.status).toBe('info') + expect(row.ddsource).toBe('evlog') + expect(row.ddtags).toBe('env:test') + expect(row.timestamp).toBe(Date.parse('2024-01-01T12:00:00.000Z')) + expect(row.message).toBe('INFO POST /api/hello') + expect(row.evlog).toMatchObject({ + timestamp: '2024-01-01T12:00:00.000Z', + level: 'info', + service: 'test-service', + environment: 'test', + path: '/api/hello', + userId: 'u1', + }) + }) + + it('adds version to tags when present', () => { + const event = createTestEvent({ version: '1.2.3' }) + const row = toDatadogLog(event) + expect(row.ddtags).toBe('env:test,version:1.2.3') + }) + + it('sets intake status to error and keeps HTTP code only as httpStatusCode inside evlog', () => { + const event = createTestEvent({ + level: 'error', + status: 400, + path: '/api/test/error', + method: 'GET', + errorCode: 'card_declined', + }) + const row = toDatadogLog(event) + + expect(row.status).toBe('error') + expect(row.message).toBe('ERROR GET /api/test/error (400)') + const evlog = row.evlog as Record + expect(evlog.status).toBeUndefined() + expect(evlog.httpStatusCode).toBe(400) + expect(evlog.level).toBe('error') + }) + }) + + describe('sendToDatadog', () => { + it('posts to default intake URL', async () => { + const event = createTestEvent() + + await sendToDatadog(event, { + apiKey: 'test-key', + }) + + expect(fetchSpy).toHaveBeenCalledTimes(1) + const [url] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://http-intake.logs.datadoghq.com/api/v2/logs') + }) + + it('uses custom site in URL', async () => { + const event = createTestEvent() + + await sendToDatadog(event, { + apiKey: 'test-key', + site: 'us3.datadoghq.com', + }) + + const [url] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://http-intake.logs.us3.datadoghq.com/api/v2/logs') + }) + + it('uses intakeUrl when provided', async () => { + const event = createTestEvent() + + await sendToDatadog(event, { + apiKey: 'test-key', + intakeUrl: 'https://http-intake.logs.ap1.datadoghq.com/api/v2/logs', + }) + + const [url] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://http-intake.logs.ap1.datadoghq.com/api/v2/logs') + }) + + it('sets DD-API-KEY header', async () => { + const event = createTestEvent() + + await sendToDatadog(event, { + apiKey: 'dd-secret', + }) + + const [, options] = fetchSpy.mock.calls[0] as [string, RequestInit] + expect(options.headers).toEqual(expect.objectContaining({ + 'DD-API-KEY': 'dd-secret', + 'Content-Type': 'application/json', + })) + }) + + it('sends JSON array body with summary message and evlog payload', async () => { + const event = createTestEvent({ action: 'ping', method: 'GET', path: '/p' }) + + await sendToDatadog(event, { + apiKey: 'test-key', + }) + + const [, options] = fetchSpy.mock.calls[0] as [string, RequestInit] + const body = JSON.parse(options.body as string) + expect(body).toHaveLength(1) + expect(body[0].service).toBe('test-service') + expect(body[0].ddsource).toBe('evlog') + expect(body[0].message).toBe('INFO GET /p') + expect(body[0].evlog).toMatchObject({ action: 'ping' }) + }) + + it('throws on non-OK response', async () => { + fetchSpy.mockResolvedValueOnce( + new Response('Forbidden', { status: 403, statusText: 'Forbidden' }), + ) + + const event = createTestEvent() + + await expect( + sendToDatadog(event, { apiKey: 'test-key' }), + ).rejects.toThrow('Datadog API error: 403 Forbidden') + }) + }) + + describe('sendBatchToDatadog', () => { + it('sends multiple events in one request', async () => { + const events = [ + createTestEvent({ requestId: '1' }), + createTestEvent({ requestId: '2' }), + ] + + await sendBatchToDatadog(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(2) + }) + + it('skips fetch when events array is empty', async () => { + await sendBatchToDatadog([], { apiKey: 'test-key' }) + + expect(fetchSpy).not.toHaveBeenCalled() + }) + }) + + describe('timeout handling', () => { + it('uses default timeout of 5000ms', async () => { + const event = createTestEvent() + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout') + + await sendToDatadog(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 sendToDatadog(event, { + apiKey: 'test-key', + timeout: 12000, + }) + + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 12000) + }) + }) +}) diff --git a/packages/evlog/tsdown.config.ts b/packages/evlog/tsdown.config.ts index 0ae86c75..04a68256 100644 --- a/packages/evlog/tsdown.config.ts +++ b/packages/evlog/tsdown.config.ts @@ -29,6 +29,7 @@ export default defineConfig({ 'adapters/sentry': 'src/adapters/sentry.ts', 'adapters/better-stack': 'src/adapters/better-stack.ts', 'adapters/hyperdx': 'src/adapters/hyperdx.ts', + 'adapters/datadog': 'src/adapters/datadog.ts', 'adapters/fs': 'src/adapters/fs.ts', 'enrichers': 'src/enrichers/index.ts', 'pipeline': 'src/pipeline.ts',