diff --git a/AGENTS.md b/AGENTS.md index 479e736..aff3609 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -165,6 +165,7 @@ export default defineNuxtConfig({ | `env.environment` | `string` | Auto-detected | Environment name | | `include` | `string[]` | `undefined` | Route patterns to log (glob). If not set, all routes are logged | | `pretty` | `boolean` | `true` in dev | Pretty print logs with tree formatting | +| `inset` | `string` | `undefined` | Nest evlog data inside a property when pretty is disabled | | `sampling.rates` | `object` | `undefined` | Head sampling rates per log level (0-100%). Error defaults to 100% | | `sampling.keep` | `array` | `undefined` | Tail sampling conditions to force-keep logs (see below) | | `transport.enabled` | `boolean` | `false` | Enable sending client logs to the server | diff --git a/README.md b/README.md index 8dcf114..53e972f 100644 --- a/README.md +++ b/README.md @@ -594,6 +594,7 @@ initLogger({ pretty?: boolean // Pretty print (default: true in dev) stringify?: boolean // JSON.stringify output (default: true, false for Workers) include?: string[] // Route patterns to log (glob), e.g. ['/api/**'] + inset?: string // Nest all log data inside this property sampling?: { rates?: { // Head sampling (random per level) info?: number // 0-100, default 100 @@ -668,6 +669,29 @@ export default defineNitroPlugin((nitroApp) => { }) ``` +### Inset - Nesting Logs + +By default, ```pretty``` is disabled, soevlog will log the object at root level when logging in production; however, for services like Cloudflare Observability, you may wish to nest the data inside an arbitrary property name. This can help organize your data and make it easier to query and analyze. + +> **Note**: Nesting can have adverse effects if your logging system expects root-level json data, such as requestIds, or tracing ids. + + +In this example, your logs will then be nested under the `data` property: + +```json [Observability Logs] +{ + "$data": { + "timestamp": "2026-02-05T06:48:18.122Z", + "level": "info", + "message": "This is an info log", + "method": "GET", + ... + }, + "$metadata": {...}, + "$workers": {...} +} +``` + ### Pretty Output Format In development, evlog uses a compact tree format: diff --git a/apps/docs/content/1.getting-started/2.installation.md b/apps/docs/content/1.getting-started/2.installation.md index be7111e..08de45a 100644 --- a/apps/docs/content/1.getting-started/2.installation.md +++ b/apps/docs/content/1.getting-started/2.installation.md @@ -46,6 +46,8 @@ export default defineNuxtConfig({ include: ['/api/**'], // Optional: exclude specific routes from logging exclude: ['/api/_nuxt_icon/**'], + // Optional: nested property name for wide events + inset: 'evlog' }, }) ``` @@ -60,6 +62,7 @@ export default defineNuxtConfig({ | `exclude` | `string[]` | `undefined` | Route patterns to exclude from logging. Supports glob (`/api/_nuxt_icon/**`). Exclusions take precedence over inclusions | | `routes` | `Record` | `undefined` | Route-specific service configuration. Allows setting different service names for different routes using glob patterns | | `pretty` | `boolean` | `true` in dev | Pretty print with tree formatting | +| `inset` | `string` | `undefined` | Nested property name for wide events | | `sampling.rates` | `object` | `undefined` | Head sampling rates per log level (0-100%). See [Sampling](#sampling) | | `sampling.keep` | `array` | `undefined` | Tail sampling conditions to force-keep logs. See [Sampling](#sampling) | | `transport.enabled` | `boolean` | `false` | Enable sending client logs to the server. See [Client Transport](#client-transport) | @@ -116,6 +119,39 @@ All logs from matching routes will automatically include the configured service You can also override the service name per handler using `useLogger(event, 'service-name')`. See [Quick Start - Service Identification](/getting-started/quick-start#service-identification) for details. +### Nesting log data + +By default, evlog will log the object at root level when logging without `pretty` enabled (see [Configuration Options](/getting-started/installation#configuration-options)); however, for services like Cloudflare Observability, you may wish to nest the data inside an arbitrary property name. This can help organize your data and make it easier to query and analyze. + +::callout{icon="i-lucide-info" color="warning"} +**Note:** Nesting can have adverse effects if your logging system expects root-level json data, such as requestIds, or tracing ids. +:: + +```typescript [nuxt.config.ts] +export default defineNuxtConfig({ + modules: ['evlog/nuxt'], + evlog: { + inset: "data", + }, +}); +``` + +In this example, your logs will then be nested under the `data` property: + +```json [Observability Logs] +{ + "$data": { + "timestamp": "2026-02-05T06:48:18.122Z", + "level": "info", + "message": "This is an info log", + "method": "GET", + ... + }, + "$metadata": {...}, + "$workers": {...} +} +``` + ### Sampling At scale, logging everything can become expensive. evlog supports two sampling strategies: diff --git a/packages/evlog/src/index.ts b/packages/evlog/src/index.ts index 1d2197e..eec9d16 100644 --- a/packages/evlog/src/index.ts +++ b/packages/evlog/src/index.ts @@ -24,4 +24,5 @@ export type { TailSamplingContext, TransportConfig, WideEvent, + InsetWideEvent } from './types' diff --git a/packages/evlog/src/logger.ts b/packages/evlog/src/logger.ts index e8988c2..639b079 100644 --- a/packages/evlog/src/logger.ts +++ b/packages/evlog/src/logger.ts @@ -1,4 +1,5 @@ -import type { EnvironmentContext, Log, LogLevel, LoggerConfig, RequestLogger, RequestLoggerOptions, SamplingConfig, TailSamplingContext, WideEvent } from './types' +import { defu } from 'defu' +import type { EnvironmentContext, InsetWideEvent, Log, LogLevel, LoggerConfig, RequestLogger, RequestLoggerOptions, SamplingConfig, TailSamplingContext, WideEvent } from './types' import { colors, detectEnvironment, formatDuration, getConsoleMethod, getLevelColor, isDev, matchesPattern } from './utils' function isPlainObject(val: unknown): val is Record { @@ -27,6 +28,7 @@ let globalEnv: EnvironmentContext = { let globalPretty = isDev() let globalSampling: SamplingConfig = {} let globalStringify = true +let globalInset: string | undefined = undefined /** * Initialize the logger with configuration. @@ -46,6 +48,7 @@ export function initLogger(config: LoggerConfig = {}): void { globalPretty = config.pretty ?? isDev() globalSampling = config.sampling ?? {} globalStringify = config.stringify ?? true + globalInset = config.inset ?? undefined } /** @@ -92,12 +95,19 @@ export function shouldKeep(ctx: TailSamplingContext): boolean { }) } -function emitWideEvent(level: LogLevel, event: Record, skipSamplingCheck = false): WideEvent | null { +function emitWideEvent(level: LogLevel, event: Record, skipSamplingCheck = false): WideEvent | InsetWideEvent | null { if (!skipSamplingCheck && !shouldSample(level)) { return null } - const formatted: WideEvent = { + const formatted: InsetWideEvent | WideEvent = !globalPretty && globalInset ? { + [`$${globalInset}`]: { + timestamp: new Date().toISOString(), + level, + ...globalEnv, + ...event, + } + } : { timestamp: new Date().toISOString(), level, ...globalEnv, @@ -256,7 +266,7 @@ export function createRequestLogger(options: RequestLoggerOptions = {}): Request context = deepDefaults(errorData, context) as Record }, - emit(overrides?: Record & { _forceKeep?: boolean }): WideEvent | null { + emit(overrides?: Record & { _forceKeep?: boolean }): WideEvent | InsetWideEvent | null { const durationMs = Date.now() - startTime const duration = formatDuration(durationMs) const level: LogLevel = hasError ? 'error' : 'info' diff --git a/packages/evlog/src/nitro/plugin.ts b/packages/evlog/src/nitro/plugin.ts index 8669d85..006c120 100644 --- a/packages/evlog/src/nitro/plugin.ts +++ b/packages/evlog/src/nitro/plugin.ts @@ -12,6 +12,7 @@ interface EvlogConfig { exclude?: string[] routes?: Record sampling?: SamplingConfig + inset?: string } function shouldLog(path: string, include?: string[], exclude?: string[]): boolean { @@ -172,6 +173,7 @@ export default defineNitroPlugin((nitroApp) => { env: evlogConfig?.env, pretty: evlogConfig?.pretty, sampling: evlogConfig?.sampling, + inset: evlogConfig?.inset, }) nitroApp.hooks.hook('request', (event) => { diff --git a/packages/evlog/src/nuxt/module.ts b/packages/evlog/src/nuxt/module.ts index 1ee85b6..597b215 100644 --- a/packages/evlog/src/nuxt/module.ts +++ b/packages/evlog/src/nuxt/module.ts @@ -134,8 +134,28 @@ export interface ModuleOptions { headers?: Record /** Request timeout in milliseconds. Default: 5000 */ timeout?: number - } - + }, + /** + * Nest logs inside a specific property instead of the root of the log object. + * + * @default undefined + * Logs will be root level objects + * + * @example + * ```ts + * inset: "evlog" + * + * // Resulting Logs + * // { + * // $evlog: { + * // level: 'info', + * // message: 'Hello World', + * // timestamp: '2023-03-01T12:00:00.000Z', + * // } + * // } + * ``` + */ + inset?: string, /** * PostHog adapter configuration. * When configured, use `createPostHogDrain()` from `evlog/posthog` to send logs. diff --git a/packages/evlog/src/types.ts b/packages/evlog/src/types.ts index d599951..8b2273c 100644 --- a/packages/evlog/src/types.ts +++ b/packages/evlog/src/types.ts @@ -248,6 +248,8 @@ export interface LoggerConfig { * @default true */ stringify?: boolean + /** Nested property name for wide events */ + inset?: string; } /** @@ -263,6 +265,13 @@ export interface BaseWideEvent { region?: string } +/** + * Wide event inside a nested property from global config: inset + */ +export type InsetWideEvent = { + [key: string]: BaseWideEvent & Record +} + /** * Wide event with arbitrary additional fields */ @@ -294,7 +303,7 @@ export interface RequestLogger { * Emit the final wide event with all accumulated context. * Returns the emitted WideEvent, or null if the log was sampled out. */ - emit: (overrides?: Record) => WideEvent | null + emit: (overrides?: Record) => WideEvent | InsetWideEvent | null /** * Get the current accumulated context diff --git a/packages/evlog/test/logger.test.ts b/packages/evlog/test/logger.test.ts index a7d085d..680bb80 100644 --- a/packages/evlog/test/logger.test.ts +++ b/packages/evlog/test/logger.test.ts @@ -729,3 +729,40 @@ describe('tail sampling', () => { expect(errorSpy).toHaveBeenCalledTimes(0) }) }) + +describe('inset configuration', () => { + let infoSpy: ReturnType + + beforeEach(() => { + infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('wraps wide event in $ property when pretty is false', () => { + initLogger({ inset: 'evlog', pretty: false }) + + log.info({ action: 'test' }) + + expect(infoSpy).toHaveBeenCalled() + const [[output]] = infoSpy.mock.calls + const parsed = JSON.parse(output) + expect(parsed).toHaveProperty('$evlog') + expect(parsed.$evlog).toHaveProperty('level', 'info') + expect(parsed.$evlog).toHaveProperty('action', 'test') + }) + + it('pretty mode outputs correctly even when inset is configured', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + initLogger({ inset: 'evlog', pretty: true }) + + log.info({ action: 'test' }) + + expect(logSpy).toHaveBeenCalled() + const allOutput = logSpy.mock.calls.map(c => c[0]).join('\n') + expect(allOutput).toContain('INFO') + expect(allOutput).toMatch(/action:.*test/) + }) +}) diff --git a/skills/evlog/SKILL.md b/skills/evlog/SKILL.md index c5a737a..1dc65a9 100644 --- a/skills/evlog/SKILL.md +++ b/skills/evlog/SKILL.md @@ -205,6 +205,8 @@ export default defineNuxtConfig({ }, // Optional: only log specific routes (supports glob patterns) include: ['/api/**'], + // Optional: nest log data under a property (e.g., for Cloudflare Observability) + inset: 'evlog', // Optional: send client logs to server (default: false) transport: { enabled: true, @@ -340,6 +342,29 @@ nitroApp.hooks.hook('evlog:drain', async (ctx) => { }) ``` +## Inset (Nested Log Data) + +`inset` nests the wide event inside a `$`-prefixed property. Only applies when `pretty: false` (production JSON). + +- Use when the platform injects root-level metadata (e.g., Cloudflare Observability adds `$metadata`, `$workers`) +- Do NOT use with Axiom, Datadog, Grafana — they expect flat root-level JSON + +```typescript +// inset: 'evlog' → wraps under $evlog +{ "$evlog": { "level": "info", "service": "api", "user": { "id": "123" }, ... } } + +// Without inset (default) → flat root +{ "level": "info", "service": "api", "user": { "id": "123" }, ... } +``` + +```typescript +// ❌ Don't use with flat-log consumers +evlog: { inset: 'data' } // Axiom/Datadog expect root-level fields + +// ✅ Use when platform adds root-level metadata +evlog: { inset: 'evlog' } // Cloudflare Observability +``` + ## Log Draining & Adapters evlog provides built-in adapters to send logs to external observability platforms. @@ -468,6 +493,7 @@ When reviewing code, check for: 8. **Client-side logging** → Use `log` API for debugging in Vue components 9. **Client log centralization** → Enable `transport.enabled: true` to send client logs to server 10. **Missing log draining** → Set up adapters (`evlog/axiom`, `evlog/otlp`) for production log export +11. **Inset misconfiguration** → Only enable `inset` when the platform adds root-level metadata (e.g., Cloudflare Observability). Do not use with systems expecting flat root-level JSON (Axiom, Datadog, Grafana). ## Loading Reference Files diff --git a/skills/evlog/references/code-review.md b/skills/evlog/references/code-review.md index 61ca5a4..b3df4cb 100644 --- a/skills/evlog/references/code-review.md +++ b/skills/evlog/references/code-review.md @@ -273,6 +273,11 @@ export default defineEventHandler(async (event) => { - [ ] Business context is domain-specific and useful for debugging - [ ] No sensitive data in logs (passwords, tokens, full card numbers) +### Configuration + +- [ ] `inset` is only enabled when the platform adds root-level metadata (e.g., Cloudflare Workers Observability) +- [ ] `inset` is not used with systems expecting flat root-level JSON (Axiom, Datadog, Grafana) + ## Anti-Pattern Summary | Anti-Pattern | Fix | @@ -283,6 +288,7 @@ export default defineEventHandler(async (event) => { | No logging in request handlers | Add `useLogger(event)` (Nuxt/Nitro) or `createRequestLogger()` (standalone) | | Flat log data | Grouped objects: `{ user: {...}, cart: {...} }` | | Abbreviated field names | Descriptive names: `userId` not `uid` | +| `inset` with flat-log consumers | Only use `inset` for platforms that add root-level metadata (e.g., Cloudflare) | ## Suggested Review Comments diff --git a/skills/evlog/references/wide-events.md b/skills/evlog/references/wide-events.md index 890d076..66e87d7 100644 --- a/skills/evlog/references/wide-events.md +++ b/skills/evlog/references/wide-events.md @@ -390,3 +390,88 @@ log.set({ pm: 'card', }) ``` + +## Inset: Nested Wide Events + +By default, wide events are emitted as flat root-level JSON. The `inset` option wraps the entire wide event inside a named property (prefixed with `$`), which is useful when your observability platform injects its own metadata at the root level. + +### Default Output (no inset) + +```json +{ + "timestamp": "2026-01-24T10:23:45.235Z", + "level": "info", + "service": "api", + "method": "POST", + "path": "/checkout", + "duration": "234ms", + "user": { "id": "user_123", "plan": "premium" }, + "cart": { "items": 3, "total": 9999 } +} +``` + +### With Inset (`inset: 'evlog'`) + +```json +{ + "$evlog": { + "timestamp": "2026-01-24T10:23:45.235Z", + "level": "info", + "service": "api", + "method": "POST", + "path": "/checkout", + "duration": "234ms", + "user": { "id": "user_123", "plan": "premium" }, + "cart": { "items": 3, "total": 9999 } + } +} +``` + +### Cloudflare Workers Observability Example + +Cloudflare injects `$metadata` and `$workers` at the root level. With inset, your data stays cleanly separated: + +```json +{ + "$evlog": { + "timestamp": "2026-02-05T06:48:18.122Z", + "level": "info", + "service": "edge-api", + "method": "POST", + "path": "/api/checkout", + "duration": "456ms", + "user": { "id": "user_789", "plan": "enterprise" }, + "cart": { "items": 5, "total": 24999 }, + "checkout": { "step": "payment", "paymentMethod": "card" }, + "fraud": { "score": 12, "riskLevel": "low", "passed": true } + }, + "$metadata": { "..." }, + "$workers": { "..." } +} +``` + +This makes it easy to query all your application data under a single namespace (e.g., `$evlog.user.plan` or `$evlog.cart.total`) without collision with platform fields. + +### Configuration + +```typescript +// Nuxt +export default defineNuxtConfig({ + modules: ['evlog/nuxt'], + evlog: { + inset: 'evlog', // → logs nested under $evlog + }, +}) + +// Standalone +initLogger({ + env: { service: 'my-worker' }, + inset: 'data', // → logs nested under $data +}) +``` + +### Important Considerations + +- **Pretty mode is unaffected.** Inset only applies to JSON output (`pretty: false`). Development pretty-print remains unchanged. +- **Property prefix.** The value is prefixed with `$` automatically: `inset: 'evlog'` → `$evlog`. +- **Platform compatibility.** Do not enable inset if your logging system expects fields like `level`, `requestId`, or `timestamp` at the root level (e.g., Axiom, Datadog, Grafana). Only use when the platform adds its own root-level metadata.