Skip to content
Merged
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
24 changes: 24 additions & 0 deletions .changeset/programmable-redaction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"evlog": minor
---

Make emit-time redaction programmable

`RedactConfig.replacement` now accepts a function, so a replacement can be derived from the value it replaces instead of being a constant β€” a stable fingerprint keeps requests correlatable without exposing the credential:

```ts
initLogger({
redact: {
patterns: [/\/public\/claim\/([A-Za-z0-9._-]{12,})/g],
replacement: (_match, ctx) => `/public/claim/[tok:${fingerprint(ctx.groups[0])}]`,
},
})
```

`RedactConfig.transform` covers policies that cannot be expressed declaratively β€” conditional on a sibling field, tenant-scoped, or allowlist-shaped. It runs before the declarative stages, so it sees raw values and `paths` / `builtins` / `patterns` still apply to whatever it leaves behind.

Both run where redaction already runs: after the event is built, before the console write and before any drain. Failures are caught and reported like drain failures β€” a `replacement` function that throws or returns a non-string falls back to `[REDACTED]` rather than emitting the raw value, and a throwing `transform` is skipped without stopping the event from being logged.

Function-valued policy cannot survive the build-time config bridges, which serialize to JSON; the Nitro modules now warn instead of dropping it silently.

Closes #463
66 changes: 60 additions & 6 deletions apps/docs/content/2.learn/6.redaction.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,56 @@ evlog: {
}
```

### Computed Replacements

When the replacement has to be **derived** from the value it replaces, pass a function instead of a string. It runs at the same point as the rest of redaction β€” before the console write, before any drain.

The common case is keeping requests correlatable without exposing the credential that identifies them:

```typescript
initLogger({
redact: {
patterns: [/\/public\/claim\/([A-Za-z0-9._-]{12,})/g],
replacement: (_match, ctx) => `/public/claim/[tok:${fingerprint(ctx.groups[0])}]`,
},
})
// /public/claim/eyJhbGciOi... β†’ /public/claim/[tok:9f3a1c]
```

The function receives the matched value and a context object:

| Field | Type | Description |
|-------|------|-------------|
| `path` | `string` | Dot-notation path from the event root (`user.email`, `items.0.token`) |
| `key` | `string` | Leaf key of the field (`email`) |
| `groups` | `string[]` | Capture groups of the matching `patterns` entry. Only set for `patterns` |

For `paths`, the matched value is the **whole field value** β€” any type, since path redaction replaces entire subtrees. For `patterns`, it is the matched substring.

If the function throws or returns a non-string, redaction falls back to `[REDACTED]` and logs the failure. A broken policy degrades to over-redaction, never to leaking the value it was meant to scrub.

### Conditional Policies

Some policies cannot be expressed as a list of paths β€” redact a field only for certain tenants, only when a sibling field has a given value, or keep an allowlist rather than a denylist. Use `transform`:

```typescript
initLogger({
redact: {
transform: (event) => {
if (event.tenant === 'regulated') delete event.query
},
},
})
```

`transform` runs **before** `paths`, `builtins`, and `patterns`, so it sees raw values and the declarative rules still apply to whatever it leaves behind β€” a hook that misses a field is not your last line of defence. Mutate the event in place; it is already a private clone, so the object you logged is never touched.

It must be synchronous, since it runs on the emit path before the console write. Errors are caught and reported like drain failures: the declarative stages still run and the event is still logged.

::callout{icon="i-lucide-triangle-alert" color="warning"}
Function-valued `replacement` and `transform` cannot be declared in `nuxt.config.ts` or a Nitro module's options β€” that config is serialized to JSON at build time, which drops functions. Declare them at runtime with `initLogger()` from a server plugin, or with `createEvlog()`. The modules emit a build-time warning if you do it anyway.
::

### Disable Built-ins

If you only want custom redaction:
Expand All @@ -152,19 +202,23 @@ evlog: {
| `paths` | `string[]` | `undefined` | Dot-notation paths with globs (`password`, `**.password`, `*_token`, `user.*`) |
| `patterns` | `RegExp[]` | `undefined` | Custom regex on string values. Uses flat `replacement` string |
| `builtins` | `false \| string[]` | All enabled | `false` disables built-ins. Array selects specific ones |
| `replacement` | `string` | `'[REDACTED]'` | Replacement for paths and custom patterns. Built-ins use smart masking instead |
| `replacement` | `string \| (matched, ctx) => string` | `'[REDACTED]'` | Replacement for paths and custom patterns. Built-ins use smart masking instead. A function computes it from the matched value |
| `transform` | `(event) => void` | `undefined` | Escape hatch for policies that are conditional, tenant-scoped, or allowlist-shaped. Runs before the declarative stages |

Available built-in names: `creditCard`, `email`, `ipv4`, `phone`, `jwt`, `bearer`, `iban`.

## How It Works

Redaction runs inside the emit pipeline, after the wide event is fully built but before any output:

1. **Path redaction** β€” exact paths and globs replaced with `[REDACTED]`
2. **Smart masking** β€” built-in patterns scan all string values recursively with partial masking
3. **Pattern redaction** β€” custom regex patterns scan all string values with flat replacement
4. **Console output** β€” masked event printed to stdout
5. **Drain** β€” masked event sent to external services
1. **Transform** β€” your `transform` hook, if any, sees the raw event first
2. **Path redaction** β€” exact paths and globs replaced with `[REDACTED]`
3. **Smart masking** β€” built-in patterns scan all string values recursively with partial masking
4. **Pattern redaction** β€” custom regex patterns scan all string values with flat replacement
5. **Console output** β€” masked event printed to stdout
6. **Drain** β€” masked event sent to external services

Redaction is the only stage that runs before the console write. `enrich` and drains run after it, so they cannot scrub what has already reached stdout β€” anything that needs to happen before output belongs in `transform` or a function-valued `replacement`.

::callout{icon="i-lucide-zap" color="info"}
Redaction runs **after** the HTTP response is sent, so it adds zero latency to your API responses.
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/7.reference/1.configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ initLogger({
| `redact` | `boolean \| RedactConfig` | `true` in production | Enabled by default in production. `false` to disable. Object for fine-grained control. See [Auto-Redaction](/learn/redaction) |
| `drain` | `(ctx: DrainContext) => void` | `undefined` | Drain callback for sending events to external services |

`RedactConfig` fields (when `redact` is an object): `paths` (dot-notation with globs), `patterns` (regex on string values), `builtins`, `replacement`. Full table in [Auto-Redaction](/learn/redaction#configuration-reference).
`RedactConfig` fields (when `redact` is an object): `paths` (dot-notation with globs), `patterns` (regex on string values), `builtins`, `replacement` (string, or a function computing it from the matched value), `transform` (hook for conditional policies). Full table in [Auto-Redaction](/learn/redaction#configuration-reference).

### `minLevel` vs sampling

Expand Down
2 changes: 2 additions & 0 deletions packages/evlog/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ export type {
LogLevel,
ParsedError,
RedactConfig,
RedactReplacement,
RedactReplacementContext,
RegisteredAuditCatalogs,
RegisteredErrorCatalogs,
RequestLogger,
Expand Down
6 changes: 6 additions & 0 deletions packages/evlog/src/nitro-v3/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'
import type { Nitro } from 'nitro/types'
import type { NitroModuleOptions } from '../nitro'
import { prependNitroErrorHandler } from '../nitro'
import { FUNCTION_REDACT_POLICY_WARNING, hasFunctionRedactPolicy } from '../redact'

export type { NitroModuleOptions }

Expand Down Expand Up @@ -38,6 +39,11 @@ export default function evlog(options?: NitroModuleOptions) {
)
nitro.options.errorHandler = Array.isArray(handlers) ? handlers : [handlers]

// JSON.stringify below drops function-valued redact policy silently.
if (hasFunctionRedactPolicy(options?.redact)) {
console.warn(FUNCTION_REDACT_POLICY_WARNING)
}

// Inject config into runtimeConfig β€” works in production where the
// plugin is bundled through Nitro's builder and the virtual
// runtime-config module resolves correctly.
Expand Down
6 changes: 6 additions & 0 deletions packages/evlog/src/nitro/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'
import type { Nitro } from 'nitropack'
import type { NitroModuleOptions } from '../nitro'
import { prependNitroErrorHandler } from '../nitro'
import { FUNCTION_REDACT_POLICY_WARNING, hasFunctionRedactPolicy } from '../redact'

export type { NitroModuleOptions }

Expand Down Expand Up @@ -35,6 +36,11 @@ export default function evlog(options?: NitroModuleOptions) {

nitro.options.noExternals = true

// JSON.stringify below drops function-valued redact policy silently.
if (hasFunctionRedactPolicy(options?.redact)) {
console.warn(FUNCTION_REDACT_POLICY_WARNING)
}

// Inject config into runtimeConfig β€” works in production where the
// plugin is bundled through Nitro's builder and the virtual
// runtime-config module resolves correctly.
Expand Down
Loading
Loading