diff --git a/.changeset/repo-hardening-perf.md b/.changeset/repo-hardening-perf.md new file mode 100644 index 00000000..97da7d65 --- /dev/null +++ b/.changeset/repo-hardening-perf.md @@ -0,0 +1,14 @@ +--- +"evlog": patch +"@evlog/nuxthub": patch +--- + +Hardening and performance improvements across the package: + +- **Redaction**: path matchers are now precompiled once per resolved config instead of on every event, and case-insensitive leaf lookups are O(1). +- **Pipeline**: the idle flush scheduling timer is `unref()`'d so it never holds a Node process open on shutdown — call `flush()` to deliver buffered events before exit (unchanged, documented contract). Retry backoff timers stay ref'd so in-flight batches are not dropped mid-retry. +- **Ingest endpoint**: request bodies are capped at 32KB (413 beyond) and parsed as strict JSON. +- **Audit**: `stableStringify` guards against circular references in audit `changes` instead of recursing forever; shared (non-circular) references keep stable signatures. +- **Toolkit**: new `applyDeprecatedAlias` helper to map deprecated config fields onto their replacement with a one-time warning, used by the Axiom and Better Stack adapters. +- **Vite**: warns when `sourceLocation` is enabled for a production build (source paths embedded in the client bundle). +- Published packages now declare `engines.node >= 18`. diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 73f7c4d5..4d50929d 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -7,6 +7,9 @@ on: - cron: '0 4 * * 1' workflow_dispatch: +permissions: + contents: read + jobs: mutate: runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a55ca43f..95ca52a0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,7 @@ jobs: - uses: actions/setup-node@v6 with: - node-version: latest + node-version: 22 registry-url: https://registry.npmjs.org - name: Install dependencies @@ -41,7 +41,7 @@ jobs: - name: Create Release PR or Publish id: changesets - uses: changesets/action@v1 + uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0 with: title: "chore(repo): version packages" version: pnpm run version diff --git a/.github/workflows/semantic-pull-request.yml b/.github/workflows/semantic-pull-request.yml index 24663106..1dee9858 100644 --- a/.github/workflows/semantic-pull-request.yml +++ b/.github/workflows/semantic-pull-request.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: amannn/action-semantic-pull-request@v6 + - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 id: lint_pr_title with: scopes: | @@ -77,7 +77,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - uses: marocchino/sticky-pull-request-comment@v3 + - uses: marocchino/sticky-pull-request-comment@d4d6b0936434b21bc8345ad45a440c5f7d2c40ff # v3.0.3 # When the previous steps fail, the workflow would stop. By adding this # condition you can continue the execution with the populated error message. if: always() && (steps.lint_pr_title.outputs.error_message != null) @@ -96,7 +96,7 @@ jobs: # Delete a previous comment when the issue has been resolved - if: ${{ steps.lint_pr_title.outputs.error_message == null }} - uses: marocchino/sticky-pull-request-comment@v3 + uses: marocchino/sticky-pull-request-comment@d4d6b0936434b21bc8345ad45a440c5f7d2c40ff # v3.0.3 with: header: pr-title-lint-error message: | diff --git a/package.json b/package.json index 48befa0c..e55e20f7 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "@changesets/changelog-github": "^0.6.0", "@changesets/cli": "^2.31.0", "@hrcd/eslint-config": "^3.0.3", - "@types/node": "latest", + "@types/node": "^25.9.1", "ai": "^6.0.168", "automd": "^0.4.3", "dotenv-cli": "^11.0.0", diff --git a/packages/evlog/package.json b/packages/evlog/package.json index a5e49115..a2229af0 100644 --- a/packages/evlog/package.json +++ b/packages/evlog/package.json @@ -35,6 +35,9 @@ "license": "MIT", "type": "module", "sideEffects": false, + "engines": { + "node": ">=18.0.0" + }, "exports": { ".": { "types": "./dist/index.d.mts", diff --git a/packages/evlog/src/adapters/axiom.ts b/packages/evlog/src/adapters/axiom.ts index b12ef705..a60f6335 100644 --- a/packages/evlog/src/adapters/axiom.ts +++ b/packages/evlog/src/adapters/axiom.ts @@ -1,6 +1,6 @@ import type { WideEvent } from '../types' import type { ConfigField } from '../shared/config' -import { resolveAdapterConfig } from '../shared/config' +import { applyDeprecatedAlias, resolveAdapterConfig } from '../shared/config' import { defineHttpDrain } from '../shared/drain' import { httpPost } from '../shared/http' @@ -63,17 +63,13 @@ const AXIOM_FIELDS: ConfigField[] = [ { key: 'retries' }, ] -let warnedAboutToken = false - function applyApiKeyAlias(config: Partial): Partial { - if (!config.apiKey && config.token) { - if (!warnedAboutToken) { - warnedAboutToken = true - console.warn('[evlog/axiom] `token` is deprecated, use `apiKey` instead. (Env: NUXT_AXIOM_TOKEN/AXIOM_TOKEN → NUXT_AXIOM_API_KEY/AXIOM_API_KEY.)') - } - config.apiKey = config.token - } - return config + return applyDeprecatedAlias(config, { + adapter: 'axiom', + from: 'token', + to: 'apiKey', + envHint: 'Env: NUXT_AXIOM_TOKEN/AXIOM_TOKEN → NUXT_AXIOM_API_KEY/AXIOM_API_KEY.', + }) } /** diff --git a/packages/evlog/src/adapters/better-stack.ts b/packages/evlog/src/adapters/better-stack.ts index f90d97c8..d94d7a5a 100644 --- a/packages/evlog/src/adapters/better-stack.ts +++ b/packages/evlog/src/adapters/better-stack.ts @@ -1,6 +1,6 @@ import type { WideEvent } from '../types' import type { ConfigField } from '../shared/config' -import { resolveAdapterConfig } from '../shared/config' +import { applyDeprecatedAlias, resolveAdapterConfig } from '../shared/config' import { defineHttpDrain } from '../shared/drain' import { httpPost } from '../shared/http' @@ -29,17 +29,13 @@ const BETTER_STACK_FIELDS: ConfigField[] = [ { key: 'retries' }, ] -let warnedAboutSourceToken = false - function applyApiKeyAlias(config: BetterStackConfig): BetterStackConfig { - if (!config.apiKey && config.sourceToken) { - if (!warnedAboutSourceToken) { - warnedAboutSourceToken = true - console.warn('[evlog/better-stack] `sourceToken` is deprecated, use `apiKey` instead. (Env: NUXT_BETTER_STACK_SOURCE_TOKEN → NUXT_BETTER_STACK_API_KEY.)') - } - config.apiKey = config.sourceToken - } - return config + return applyDeprecatedAlias(config, { + adapter: 'better-stack', + from: 'sourceToken', + to: 'apiKey', + envHint: 'Env: NUXT_BETTER_STACK_SOURCE_TOKEN/BETTER_STACK_SOURCE_TOKEN → NUXT_BETTER_STACK_API_KEY/BETTER_STACK_API_KEY.', + }) } /** diff --git a/packages/evlog/src/audit.ts b/packages/evlog/src/audit.ts index b765e0ee..fd111a36 100644 --- a/packages/evlog/src/audit.ts +++ b/packages/evlog/src/audit.ts @@ -40,12 +40,17 @@ function isPlainObject(value: unknown): value is Record { return proto === Object.prototype || value.constructor === Object } -function stableStringify(value: unknown): string { +function stableStringify(value: unknown, ancestors = new WeakSet()): string { if (value === null || typeof value !== 'object') return JSON.stringify(value) - if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]` - if (!isPlainObject(value)) return JSON.stringify(value) - const keys = Object.keys(value).sort() - return `{${keys.map(k => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}` + // Track only the current recursion path: shared (diamond) references are fine, true cycles are not. + if (ancestors.has(value)) return '"[Circular]"' + if (!Array.isArray(value) && !isPlainObject(value)) return JSON.stringify(value) + ancestors.add(value) + const out = Array.isArray(value) + ? `[${value.map(v => stableStringify(v, ancestors)).join(',')}]` + : `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${stableStringify(value[k], ancestors)}`).join(',')}}` + ancestors.delete(value) + return out } /** diff --git a/packages/evlog/src/pipeline.ts b/packages/evlog/src/pipeline.ts index b0c839cf..5b2f9d90 100644 --- a/packages/evlog/src/pipeline.ts +++ b/packages/evlog/src/pipeline.ts @@ -47,6 +47,18 @@ export interface PipelineDrainFn { * nitroApp.hooks.hook('close', () => drain.flush()) * ``` */ +/** + * Unref a timer on runtimes that support it (Node, Bun) so an idle flush + * scheduling timer never holds the process open. Buffered events are delivered + * on shutdown via the documented `flush()` contract, not by keeping timers + * alive. Retry backoff timers are intentionally left ref'd: an in-flight batch + * is active work, and an unref'd timer awaited by `flush()` would let the + * process exit mid-retry. + */ +function unrefTimer(timer: ReturnType): void { + (timer as { unref?: () => void }).unref?.() +} + export function createDrainPipeline(options?: DrainPipelineOptions): (drain: (batch: T[]) => void | Promise) => PipelineDrainFn { const batchSize = options?.batch?.size ?? 50 const intervalMs = options?.batch?.intervalMs ?? 5000 @@ -94,6 +106,7 @@ export function createDrainPipeline(options?: DrainPipelineOptions< timer = null if (!activeFlush) startFlush() }, intervalMs) + unrefTimer(timer) } function getRetryDelay(attempt: number): number { diff --git a/packages/evlog/src/redact.ts b/packages/evlog/src/redact.ts index d424911e..ff87465d 100644 --- a/packages/evlog/src/redact.ts +++ b/packages/evlog/src/redact.ts @@ -10,7 +10,7 @@ export interface RedactPathMatchers { exactPaths: Set pathGlobs: RegExp[] keyGlobs: RegExp[] - /** Single-segment shorthands (`password` → `**.password`) matched case-insensitively on leaf keys. */ + /** Single-segment shorthands (`password` → `**.password`), stored lowercased, matched case-insensitively on leaf keys. */ caseInsensitiveLeaves: Set } @@ -72,7 +72,7 @@ function addPathGlobPattern( const leaf = pattern.match(/^\*\*\.([^.?*]+)$/) if (leaf) { exactPaths.add(leaf[1]!) - caseInsensitiveLeaves.add(leaf[1]!) + caseInsensitiveLeaves.add(leaf[1]!.toLowerCase()) } } @@ -83,10 +83,7 @@ function addPathGlobPattern( export function matchesRedactPath(fullPath: string, leafKey: string, matchers: RedactPathMatchers): boolean { if (matchers.exactPaths.has(fullPath)) return true - const leafLower = leafKey.toLowerCase() - for (const name of matchers.caseInsensitiveLeaves) { - if (leafLower === name.toLowerCase()) return true - } + if (matchers.caseInsensitiveLeaves.has(leafKey.toLowerCase())) return true for (const glob of matchers.pathGlobs) { glob.lastIndex = 0 @@ -259,7 +256,10 @@ export function resolveRedactConfig(input: boolean | RedactConfig | undefined): } if (input.builtins === false) { - return input + return { + ...input, + _pathMatchers: compileRedactPathMatchers(input.paths), + } } const maskers = Array.isArray(input.builtins) @@ -272,6 +272,7 @@ export function resolveRedactConfig(input: boolean | RedactConfig | undefined): return { ...input, _maskers: maskers, + _pathMatchers: compileRedactPathMatchers(input.paths), } } @@ -329,7 +330,8 @@ export function redactEvent(event: Record, config: RedactConfig const clone = cloneForRedaction(event) const replacement = config.replacement ?? DEFAULT_REPLACEMENT - const pathMatchers = compileRedactPathMatchers(config.paths) + // Configs resolved via resolveRedactConfig carry precompiled matchers; compile lazily for ad-hoc configs. + const pathMatchers = config._pathMatchers ?? compileRedactPathMatchers(config.paths) if (pathMatchers) { redactPathsInTree(clone, pathMatchers, replacement) } @@ -375,7 +377,6 @@ function redactPatterns(obj: unknown, patterns: RegExp[], replacement: string): function applyPatterns(value: string, patterns: RegExp[], replacement: string): string { let result = value for (const pattern of patterns) { - pattern.lastIndex = 0 result = result.replace(pattern, replacement) } return result @@ -411,7 +412,6 @@ function applyMaskersToTree(obj: unknown, maskers: Masker[]): void { function applyMaskers(value: string, maskers: Masker[]): string { let result = value for (const [pattern, mask] of maskers) { - pattern.lastIndex = 0 result = result.replace(pattern, mask) } return result diff --git a/packages/evlog/src/runtime/server/routes/_evlog/ingest.post.ts b/packages/evlog/src/runtime/server/routes/_evlog/ingest.post.ts index e7608b02..eda4099d 100644 --- a/packages/evlog/src/runtime/server/routes/_evlog/ingest.post.ts +++ b/packages/evlog/src/runtime/server/routes/_evlog/ingest.post.ts @@ -1,4 +1,4 @@ -import { createError, defineEventHandler, getHeader, getHeaders, getRequestHost, readBody, setResponseStatus } from 'h3' +import { createError, defineEventHandler, getHeader, getHeaders, getRequestHost, readRawBody, setResponseStatus } from 'h3' import { useNitroApp } from 'nitropack/runtime' import type { IngestPayload, WideEvent } from '../../../../types' import { getEnvironment, getGlobalPluginRunner } from '../../../../logger' @@ -32,6 +32,34 @@ function validateOrigin(event: Parameters[0] extends } } +/** + * Maximum accepted ingest body size in bytes. Client wide events are small; + * anything larger is rejected before it reaches the enrich/drain pipeline. + */ +const MAX_BODY_BYTES = 32 * 1024 + +async function readJsonBody(event: Parameters[0] extends (e: infer E) => unknown ? E : never): Promise { + const contentLength = Number(getHeader(event, 'content-length')) + if (Number.isFinite(contentLength) && contentLength > MAX_BODY_BYTES) { + throw createError({ statusCode: 413, message: 'Payload too large' }) + } + + const raw = await readRawBody(event, 'utf8') + if (!raw) { + throw createError({ statusCode: 400, message: 'Invalid request body' }) + } + // Measure actual UTF-8 bytes so multi-byte payloads can't slip past the cap. + if (new TextEncoder().encode(raw).byteLength > MAX_BODY_BYTES) { + throw createError({ statusCode: 413, message: 'Payload too large' }) + } + + try { + return JSON.parse(raw) + } catch { + throw createError({ statusCode: 400, message: 'Invalid request body' }) + } +} + // ISO 8601 datetime pattern (e.g., 2024-01-31T14:00:00.000Z) const ISO_8601_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/ @@ -108,10 +136,17 @@ function resolveWaitUntilContext(event: unknown): WaitUntilHost | undefined { return context } +/** + * Client log ingestion endpoint. + * + * The origin check is CSRF-level protection only: it blocks cross-site browser + * requests but is trivially satisfied by non-browser clients. Treat ingested + * events as untrusted input — this endpoint is intentionally unauthenticated. + */ export default defineEventHandler(async (event) => { validateOrigin(event) - const body = await readBody(event) + const body = await readJsonBody(event) const payload = validatePayload(body) const nitroApp = useNitroApp() const env = getEnvironment() diff --git a/packages/evlog/src/shared/config.ts b/packages/evlog/src/shared/config.ts index 3812ac44..de9f8014 100644 --- a/packages/evlog/src/shared/config.ts +++ b/packages/evlog/src/shared/config.ts @@ -46,6 +46,31 @@ export async function resolveAdapterConfig( return config as Partial } +const warnedDeprecatedAliases = new Set() + +/** + * Copy a deprecated config field onto its replacement when the replacement is + * unset, warning once per adapter/field pair. + */ +export function applyDeprecatedAlias( + config: T, + opts: { adapter: string, from: keyof T & string, to: keyof T & string, envHint?: string }, +): T { + const record = config as Record + if (record[opts.to] === undefined || record[opts.to] === null) { + const fromValue = record[opts.from] + if (fromValue !== undefined && fromValue !== null) { + const warnKey = `${opts.adapter}:${opts.from}` + if (!warnedDeprecatedAliases.has(warnKey)) { + warnedDeprecatedAliases.add(warnKey) + console.warn(`[evlog/${opts.adapter}] \`${opts.from}\` is deprecated, use \`${opts.to}\` instead.${opts.envHint ? ` (${opts.envHint})` : ''}`) + } + record[opts.to] = fromValue + } + } + return config +} + // Avoid the Nitro virtual-module import when env/overrides already resolve // every env-backed field — optional tuning fields (timeout, retries) should // not trigger a runtime probe in non-Nitro runtimes. diff --git a/packages/evlog/src/types.ts b/packages/evlog/src/types.ts index f72724a0..bb77356e 100644 --- a/packages/evlog/src/types.ts +++ b/packages/evlog/src/types.ts @@ -126,6 +126,13 @@ export interface RedactConfig { replacement?: string /** @internal Resolved masker functions from built-in patterns. Not user-facing. */ _maskers?: Array<[RegExp, (match: string) => string]> + /** @internal Precompiled matchers for `paths`, built once by `resolveRedactConfig`. Not user-facing. */ + _pathMatchers?: { + exactPaths: Set + pathGlobs: RegExp[] + keyGlobs: RegExp[] + caseInsensitiveLeaves: Set + } } /** diff --git a/packages/evlog/src/utils.ts b/packages/evlog/src/utils.ts index 2ce976bf..2623af48 100644 --- a/packages/evlog/src/utils.ts +++ b/packages/evlog/src/utils.ts @@ -124,6 +124,8 @@ export const SENSITIVE_HEADERS = [ 'proxy-authorization', ] +const SENSITIVE_HEADER_SET = new Set(SENSITIVE_HEADERS) + /** * Filter out undefined values and sensitive headers from a raw header map. * @@ -137,7 +139,7 @@ export function filterSafeHeaders(headers: Partial = {} for (const [key, value] of Object.entries(headers)) { - if (value !== undefined && !SENSITIVE_HEADERS.includes(key.toLowerCase())) { + if (value !== undefined && !SENSITIVE_HEADER_SET.has(key.toLowerCase())) { safeHeaders[key] = value } } diff --git a/packages/evlog/src/vite/source-location.ts b/packages/evlog/src/vite/source-location.ts index 0ffddbac..fed9d9c5 100644 --- a/packages/evlog/src/vite/source-location.ts +++ b/packages/evlog/src/vite/source-location.ts @@ -13,6 +13,9 @@ export function createSourceLocationPlugin(enabled?: boolean): Plugin { configResolved({ command, root: configRoot }) { active = enabled ?? command === 'serve' root = configRoot + if (enabled === true && command === 'build') { + console.warn('[evlog] sourceLocation is enabled for a production build: source file paths will be embedded in the client bundle.') + } }, transform: { diff --git a/packages/evlog/test/core/audit.test.ts b/packages/evlog/test/core/audit.test.ts index 03c21c09..14b8fce8 100644 --- a/packages/evlog/test/core/audit.test.ts +++ b/packages/evlog/test/core/audit.test.ts @@ -500,6 +500,29 @@ describe('stableStringify plain-object guard', () => { expect(sig1).toBeDefined() expect(sig1).toBe(sig2) }) + + it('does not hang on circular references in audit changes', async () => { + const calls: WideEvent[] = [] + const drain = signed((ctx: DrainContext) => { + calls.push(ctx.event) + }, { strategy: 'hmac', secret: 'test-secret' }) + + const circular: Record = { name: 'loop' } + circular.self = circular + const shared = { id: 'x' } + + await drain(createDrainCtx({ + audit: { + action: 'update', + actor: { type: 'user', id: 'u1' }, + outcome: 'success', + changes: { circular, a: shared, b: shared }, + }, + })) + + const audit = defined(defined(calls[0], 'event').audit as AuditFields) + expect(audit.signature).toBeDefined() + }) }) describe('end-to-end: audit + auditOnly + global drain', () => { diff --git a/packages/evlog/test/core/redact.test.ts b/packages/evlog/test/core/redact.test.ts index f63a6c7f..ebe982f6 100644 --- a/packages/evlog/test/core/redact.test.ts +++ b/packages/evlog/test/core/redact.test.ts @@ -422,6 +422,23 @@ describe('resolveRedactConfig', () => { expect(config._maskers).toHaveLength(1) expect(config.patterns).toHaveLength(1) }) + + it('precompiles path matchers', () => { + const config = defined(resolveRedactConfig({ paths: ['user.password', '*_token'] }), 'redact config') + expect(config._pathMatchers).toBeDefined() + + const redacted = redactEvent({ user: { password: 'hunter2' }, api_token: 'abc' }, config) + expect((redacted.user as Record).password).toBe('[REDACTED]') + expect(redacted.api_token).toBe('[REDACTED]') + }) + + it('redacts identically with and without precompiled matchers', () => { + const event = { user: { Password: 'hunter2', name: 'jo' }, nested: { secret_token: 'x' } } + const paths = ['password', '*_token'] + const resolved = defined(resolveRedactConfig({ builtins: false, paths }), 'redact config') + const adHoc: RedactConfig = { builtins: false, paths } + expect(redactEvent(event, resolved)).toEqual(redactEvent(event, adHoc)) + }) }) describe('normalizeRedactConfig', () => { diff --git a/packages/evlog/test/toolkit/__snapshots__/api-surface.test.ts.snap b/packages/evlog/test/toolkit/__snapshots__/api-surface.test.ts.snap index 4ea18414..5bcfde30 100644 --- a/packages/evlog/test/toolkit/__snapshots__/api-surface.test.ts.snap +++ b/packages/evlog/test/toolkit/__snapshots__/api-surface.test.ts.snap @@ -219,6 +219,7 @@ exports[`public API surface > matches snapshot for all subpath exports 1`] = ` "EVLOG_VERSION", "OTEL_SEVERITY_NUMBER", "OTEL_SEVERITY_TEXT", + "applyDeprecatedAlias", "attachForkToLogger", "bindStreamingResponseLifecycle", "composeDrains", diff --git a/packages/nuxthub/package.json b/packages/nuxthub/package.json index 2c86d302..85be88bd 100644 --- a/packages/nuxthub/package.json +++ b/packages/nuxthub/package.json @@ -15,6 +15,9 @@ "license": "MIT", "type": "module", "sideEffects": false, + "engines": { + "node": ">=18.0.0" + }, "exports": { ".": { "types": "./dist/types.d.mts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5cb7147..d738f10f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,7 +25,7 @@ importers: specifier: ^3.0.3 version: 3.0.3(@types/estree@1.0.8)(@typescript-eslint/utils@8.59.1(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) '@types/node': - specifier: latest + specifier: ^25.9.1 version: 25.9.1 ai: specifier: ^6.0.168