Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
36 changes: 36 additions & 0 deletions apps/docs/content/1.getting-started/2.installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'
},
})
```
Expand All @@ -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<string, RouteConfig>` | `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) |
Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions packages/evlog/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,5 @@ export type {
TailSamplingContext,
TransportConfig,
WideEvent,
InsetWideEvent
} from './types'
18 changes: 14 additions & 4 deletions packages/evlog/src/logger.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { EnvironmentContext, Log, LogLevel, LoggerConfig, RequestLogger, RequestLoggerOptions, SamplingConfig, TailSamplingContext, WideEvent } from './types'
import { defu } from 'defu'
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made sure that the project no longer has dependencies. Could you replace that with the deep merge function from the repo?

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<string, unknown> {
Expand Down Expand Up @@ -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.
Expand All @@ -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
}

/**
Expand Down Expand Up @@ -92,12 +95,19 @@ export function shouldKeep(ctx: TailSamplingContext): boolean {
})
}

function emitWideEvent(level: LogLevel, event: Record<string, unknown>, skipSamplingCheck = false): WideEvent | null {
function emitWideEvent(level: LogLevel, event: Record<string, unknown>, 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,
Expand Down Expand Up @@ -256,7 +266,7 @@ export function createRequestLogger(options: RequestLoggerOptions = {}): Request
context = deepDefaults(errorData, context) as Record<string, unknown>
},

emit(overrides?: Record<string, unknown> & { _forceKeep?: boolean }): WideEvent | null {
emit(overrides?: Record<string, unknown> & { _forceKeep?: boolean }): WideEvent | InsetWideEvent | null {
const durationMs = Date.now() - startTime
const duration = formatDuration(durationMs)
const level: LogLevel = hasError ? 'error' : 'info'
Expand Down
2 changes: 2 additions & 0 deletions packages/evlog/src/nitro/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ interface EvlogConfig {
exclude?: string[]
routes?: Record<string, RouteConfig>
sampling?: SamplingConfig
inset?: string
}

function shouldLog(path: string, include?: string[], exclude?: string[]): boolean {
Expand Down Expand Up @@ -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) => {
Expand Down
24 changes: 22 additions & 2 deletions packages/evlog/src/nuxt/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,28 @@ export interface ModuleOptions {
headers?: Record<string, string>
/** 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.
Expand Down
11 changes: 10 additions & 1 deletion packages/evlog/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,8 @@ export interface LoggerConfig {
* @default true
*/
stringify?: boolean
/** Nested property name for wide events */
inset?: string;
}

/**
Expand All @@ -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<string, unknown>
}

/**
* Wide event with arbitrary additional fields
*/
Expand Down Expand Up @@ -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<string, unknown>) => WideEvent | null
emit: (overrides?: Record<string, unknown>) => WideEvent | InsetWideEvent | null

/**
* Get the current accumulated context
Expand Down
37 changes: 37 additions & 0 deletions packages/evlog/test/logger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -729,3 +729,40 @@ describe('tail sampling', () => {
expect(errorSpy).toHaveBeenCalledTimes(0)
})
})

describe('inset configuration', () => {
let infoSpy: ReturnType<typeof vi.spyOn>

beforeEach(() => {
infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
})

afterEach(() => {
vi.restoreAllMocks()
})

it('wraps wide event in $<inset> 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/)
})
})
26 changes: 26 additions & 0 deletions skills/evlog/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions skills/evlog/references/code-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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

Expand Down
Loading
Loading