From 099ee527f5654928493409200b204e104e3d933c Mon Sep 17 00:00:00 2001 From: "evlogai[bot]" Date: Thu, 8 Oct 2026 22:46:36 +0000 Subject: [PATCH] fix: serialize RegExp redact patterns across the nuxt and nitro config bridges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A RegExp in redact.patterns serializes to {} under JSON.stringify, so custom patterns declared in nuxt.config.ts or Nitro module options never reached the server. prepareRedactForBridge rewrites them in place to { source, flags } — the shape deserializeRegexList reads back — before the options land in a runtimeConfig or a JSON bridge, and covers the function-valued redact policy warning the Nuxt module was missing. deserializeRegexList now reports pattern objects without a source field instead of skipping them silently. Also corrects stale config docs: the ModuleOptions.retention default (@evlog/nuxthub uses '7d'), the EvlogConfig JSDoc, the sampling feature snippet labelled evlog.config.ts, and the signals README registration note. --- .changeset/regexp-redact-patterns-bridge.md | 5 +++ .../components/features/FeatureSampling.vue | 2 +- packages/evlog/src/nitro-v3/module.ts | 10 ++--- packages/evlog/src/nitro/module.ts | 10 ++--- packages/evlog/src/nuxt/module.ts | 9 +++- packages/evlog/src/redact.ts | 34 ++++++++++++--- packages/evlog/src/shared/define.ts | 2 +- packages/evlog/test/core/redact.test.ts | 42 ++++++++++++++++++- packages/signals/README.md | 2 + 9 files changed, 97 insertions(+), 19 deletions(-) create mode 100644 .changeset/regexp-redact-patterns-bridge.md diff --git a/.changeset/regexp-redact-patterns-bridge.md b/.changeset/regexp-redact-patterns-bridge.md new file mode 100644 index 000000000..0c3ea1feb --- /dev/null +++ b/.changeset/regexp-redact-patterns-bridge.md @@ -0,0 +1,5 @@ +--- +'evlog': patch +--- + +Fix custom RegExp `redact.patterns` declared in `nuxt.config.ts` (or Nitro module options) being silently dropped: patterns are now serialized as `{ source, flags }` across the config bridges and rebuilt into RegExp on the server. A pattern object without a `source` field is reported instead of skipped, and a function-valued `redact.replacement` / `redact.transform` in the Nuxt module now prints the same warning the standalone Nitro modules already print. diff --git a/apps/docs/app/components/features/FeatureSampling.vue b/apps/docs/app/components/features/FeatureSampling.vue index e993640ff..3cbf10ac3 100644 --- a/apps/docs/app/components/features/FeatureSampling.vue +++ b/apps/docs/app/components/features/FeatureSampling.vue @@ -149,7 +149,7 @@ function getLevelColor(level: string): string {
- evlog.config.ts + logger.ts
initLogger({
diff --git a/packages/evlog/src/nitro-v3/module.ts b/packages/evlog/src/nitro-v3/module.ts
index 69ac234cd..aa2b0645d 100644
--- a/packages/evlog/src/nitro-v3/module.ts
+++ b/packages/evlog/src/nitro-v3/module.ts
@@ -3,7 +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'
+import { prepareRedactForBridge } from '../redact'
 
 export type { NitroModuleOptions }
 
@@ -39,10 +39,10 @@ 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)
-      }
+      // runtimeConfig and the JSON bridges below serialize the config:
+      // RegExps would collapse to {} and function-valued redact policy
+      // would vanish silently.
+      prepareRedactForBridge(options?.redact)
 
       // Inject config into runtimeConfig — works in production where the
       // plugin is bundled through Nitro's builder and the virtual
diff --git a/packages/evlog/src/nitro/module.ts b/packages/evlog/src/nitro/module.ts
index 29c1eaf60..a39e6eff0 100644
--- a/packages/evlog/src/nitro/module.ts
+++ b/packages/evlog/src/nitro/module.ts
@@ -3,7 +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'
+import { prepareRedactForBridge } from '../redact'
 
 export type { NitroModuleOptions }
 
@@ -35,10 +35,10 @@ 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)
-      }
+      // runtimeConfig and the JSON bridges below serialize the config:
+      // RegExps would collapse to {} and function-valued redact policy
+      // would vanish silently.
+      prepareRedactForBridge(options?.redact)
 
       // Inject config into runtimeConfig — works in production where the
       // plugin is bundled through Nitro's builder and the virtual
diff --git a/packages/evlog/src/nuxt/module.ts b/packages/evlog/src/nuxt/module.ts
index adc38904d..db98683be 100644
--- a/packages/evlog/src/nuxt/module.ts
+++ b/packages/evlog/src/nuxt/module.ts
@@ -13,6 +13,7 @@ import type { NitroConfig } from 'nitropack'
 import type { EnvironmentContext, LogLevel, RedactConfig, RouteConfig, SamplingConfig, TransportConfig } from '../types'
 import type { DevTerminalInput } from '../shared/dev-terminal'
 import { prependNitroErrorHandler } from '../nitro'
+import { prepareRedactForBridge } from '../redact'
 import { createStripPlugin } from '../vite/strip'
 import { createSourceLocationPlugin } from '../vite/source-location'
 import { name, version } from '../../package.json'
@@ -314,7 +315,7 @@ export interface ModuleOptions {
   /**
    * How long to retain events before cleanup (used by @evlog/nuxthub).
    * Supports "30d" (days), "24h" (hours), "60m" (minutes).
-   * @default '30d'
+   * @default '7d'
    */
   retention?: string
 }
@@ -357,6 +358,12 @@ export default defineNuxtModule({
     const streamEnabled = normalizedStream !== false
     options.stream = normalizedStream
 
+    // runtimeConfig and the JSON bridges in the nitro:config hook below
+    // serialize the config: RegExps in redact.patterns would collapse to {}
+    // and be dropped server-side, and function-valued redact policy would
+    // vanish silently.
+    prepareRedactForBridge(options.redact)
+
     nuxt.options.runtimeConfig.evlog = options
 
     // Register custom error handler for proper EvlogError serialization
diff --git a/packages/evlog/src/redact.ts b/packages/evlog/src/redact.ts
index 58d839ea9..5280228b4 100644
--- a/packages/evlog/src/redact.ts
+++ b/packages/evlog/src/redact.ts
@@ -538,6 +538,27 @@ export const FUNCTION_REDACT_POLICY_WARNING
   = '[evlog] redact.replacement / redact.transform is a function and cannot be serialized into the build config. '
     + 'Declare it at runtime instead — initLogger({ redact: { ... } }) from a server plugin — or use a string replacement.'
 
+/**
+ * Prepare a `redact` config for the config bridges (`__EVLOG_CONFIG__`,
+ * `process.env.__EVLOG_CONFIG`, a framework runtimeConfig). RegExp patterns
+ * are rewritten in place to `{ source, flags }` — the shape
+ * `deserializeRegexList` reads back — because JSON.stringify reduces a
+ * RegExp to `{}` and the pattern is silently dropped. A function-valued
+ * policy cannot survive any bridge; warn so the missing redaction is loud.
+ * Modules that serialize user config call this before `JSON.stringify`.
+ */
+export function prepareRedactForBridge(redact: unknown): void {
+  if (hasFunctionRedactPolicy(redact)) {
+    console.warn(FUNCTION_REDACT_POLICY_WARNING)
+  }
+  if (!redact || typeof redact !== 'object') return
+  const { patterns } = redact as { patterns?: unknown }
+  if (!Array.isArray(patterns)) return
+  ;(redact as { patterns: unknown[] }).patterns = patterns.map(p =>
+    p instanceof RegExp ? { source: p.source, flags: p.flags } : p,
+  )
+}
+
 /**
  * Normalize a redact config that may have been deserialized from JSON
  * (e.g. via `process.env.__EVLOG_CONFIG`). Converts pattern strings
@@ -595,12 +616,15 @@ function deserializeRegexList(raw: unknown[]): RegExp[] {
         patterns.push(new RegExp(p, 'g'))
         continue
       }
-      if (typeof p === 'object' && p !== null && typeof (p as { source?: unknown }).source === 'string') {
-        const flags = typeof (p as { flags?: unknown }).flags === 'string'
-          ? (p as { flags: string }).flags
-          : 'g'
-        patterns.push(new RegExp((p as { source: string }).source, flags))
+      if (typeof p !== 'object' || p === null) continue
+      if (typeof (p as { source?: unknown }).source !== 'string') {
+        console.warn('[normalizeRedactConfig] Ignoring redact pattern object without a `source` field')
+        continue
       }
+      const flags = typeof (p as { flags?: unknown }).flags === 'string'
+        ? (p as { flags: string }).flags
+        : 'g'
+      patterns.push(new RegExp((p as { source: string }).source, flags))
     } catch {
       console.warn('[normalizeRedactConfig] Ignoring invalid redact regex entry')
     }
diff --git a/packages/evlog/src/shared/define.ts b/packages/evlog/src/shared/define.ts
index 778126304..a6726e4d8 100644
--- a/packages/evlog/src/shared/define.ts
+++ b/packages/evlog/src/shared/define.ts
@@ -5,7 +5,7 @@ import { pickBaseEvlogOptions } from './middleware'
 
 /**
  * Single-config shape accepted everywhere evlog is bootstrapped: at
- * `initLogger`, in framework middleware, and in the Nuxt module. Authored
+ * `initLogger` and in framework middleware. Authored
  * with {@link defineEvlog} and split via {@link toLoggerConfig} /
  * {@link toMiddlewareOptions}.
  */
diff --git a/packages/evlog/test/core/redact.test.ts b/packages/evlog/test/core/redact.test.ts
index f0cee0735..e4616c449 100644
--- a/packages/evlog/test/core/redact.test.ts
+++ b/packages/evlog/test/core/redact.test.ts
@@ -1,5 +1,5 @@
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { redactEvent, normalizeRedactConfig, resolveRedactConfig, builtinPatterns, hasFunctionRedactPolicy, compileRedactPathMatchers, redactPathsInTree } from '../../src/redact'
+import { redactEvent, normalizeRedactConfig, resolveRedactConfig, builtinPatterns, hasFunctionRedactPolicy, FUNCTION_REDACT_POLICY_WARNING, prepareRedactForBridge, compileRedactPathMatchers, redactPathsInTree } from '../../src/redact'
 import type { RedactConfig } from '../../src/types'
 import { createLogger, initLogger } from '../../src/logger'
 import { defined } from '../helpers/defined'
@@ -609,6 +609,17 @@ describe('normalizeRedactConfig', () => {
     warn.mockRestore()
   })
 
+  it('warns on pattern objects without a source field instead of dropping them silently', () => {
+    const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+    const config = normalizeRedactConfig({
+      builtins: false,
+      patterns: [{ source: '\\d+' }, {}],
+    })
+    expect(config?.patterns).toHaveLength(1)
+    expect(warn).toHaveBeenCalledWith('[normalizeRedactConfig] Ignoring redact pattern object without a `source` field')
+    warn.mockRestore()
+  })
+
   it('handles builtins field from deserialized JSON', () => {
     const config = normalizeRedactConfig({
       builtins: ['email', 'creditCard'],
@@ -845,4 +856,33 @@ describe('function-valued redact policy across the config bridge', () => {
     expect(hasFunctionRedactPolicy(serialized)).toBe(false)
     expect(normalizeRedactConfig(serialized)?.replacement).toBeUndefined()
   })
+
+  it('warns when a function-valued policy crosses the bridge', () => {
+    const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+    prepareRedactForBridge({ replacement: () => '[x]' })
+    expect(warn).toHaveBeenCalledWith(FUNCTION_REDACT_POLICY_WARNING)
+    warn.mockRestore()
+  })
+})
+
+describe('RegExp patterns across the config bridge', () => {
+  it('rewrites RegExp patterns to source/flags so a JSON round-trip still redacts', () => {
+    const redact = { builtins: false, patterns: [/sk_live_\w+/g] }
+    prepareRedactForBridge(redact)
+    const serialized = JSON.parse(JSON.stringify(redact))
+
+    const resolved = defined(normalizeRedactConfig(serialized), 'redact config')
+    expect(resolved.patterns).toHaveLength(1)
+    expect(defined(resolved.patterns?.[0], 'patterns[0]').flags).toBe('g')
+
+    const event = redactEvent({ key: 'sk_live_abc123' }, resolved)
+    expect(event.key).toBe('[REDACTED]')
+  })
+
+  it('keeps patterns intact when the config is passed live, not through the bridge', () => {
+    const redact = { builtins: false, patterns: [/sk_live_\w+/g] }
+    prepareRedactForBridge(redact)
+    const resolved = defined(normalizeRedactConfig(redact), 'redact config')
+    expect(defined(resolved.patterns?.[0], 'patterns[0]')).toBeInstanceOf(RegExp)
+  })
 })
diff --git a/packages/signals/README.md b/packages/signals/README.md
index 6c427f4c9..01da64f25 100644
--- a/packages/signals/README.md
+++ b/packages/signals/README.md
@@ -66,6 +66,8 @@ export default defineEvlog({
 })
 ```
 
+`defineEvlog` only authors the config. Nothing registers until the object reaches evlog: pass it to `initLogger` or the framework integration you already use.
+
 Every event that passes a signal's `when` gets judged. All due signals for one event go into a single model call.
 
 ## What lands on the event