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
14 changes: 14 additions & 0 deletions .changeset/repo-hardening-perf.md
Original file line number Diff line number Diff line change
@@ -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`.
3 changes: 3 additions & 0 deletions .github/workflows/mutation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ on:
- cron: '0 4 * * 1'
workflow_dispatch:

permissions:
contents: read

jobs:
mutate:
runs-on: ubuntu-latest
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/semantic-pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down Expand Up @@ -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)
Expand All @@ -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: |
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"ai": "^6.0.168",
"automd": "^0.4.3",
"dotenv-cli": "^11.0.0",
Expand Down
3 changes: 3 additions & 0 deletions packages/evlog/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@
"license": "MIT",
"type": "module",
"sideEffects": false,
"engines": {
"node": ">=18.0.0"
},
"exports": {
".": {
"types": "./dist/index.d.mts",
Expand Down
18 changes: 7 additions & 11 deletions packages/evlog/src/adapters/axiom.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -63,17 +63,13 @@ const AXIOM_FIELDS: ConfigField<ResolvedAxiomConfig>[] = [
{ key: 'retries' },
]

let warnedAboutToken = false

function applyApiKeyAlias(config: Partial<ResolvedAxiomConfig>): Partial<ResolvedAxiomConfig> {
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.',
})
}

/**
Expand Down
18 changes: 7 additions & 11 deletions packages/evlog/src/adapters/better-stack.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -29,17 +29,13 @@ const BETTER_STACK_FIELDS: ConfigField<BetterStackConfig>[] = [
{ 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.',
})
}

/**
Expand Down
15 changes: 10 additions & 5 deletions packages/evlog/src/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,17 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
return proto === Object.prototype || value.constructor === Object
}

function stableStringify(value: unknown): string {
function stableStringify(value: unknown, ancestors = new WeakSet<object>()): 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
}

/**
Expand Down
13 changes: 13 additions & 0 deletions packages/evlog/src/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,18 @@ export interface PipelineDrainFn<T> {
* 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<typeof setTimeout>): void {
(timer as { unref?: () => void }).unref?.()
}

export function createDrainPipeline<T = unknown>(options?: DrainPipelineOptions<T>): (drain: (batch: T[]) => void | Promise<void>) => PipelineDrainFn<T> {
const batchSize = options?.batch?.size ?? 50
const intervalMs = options?.batch?.intervalMs ?? 5000
Expand Down Expand Up @@ -94,6 +106,7 @@ export function createDrainPipeline<T = unknown>(options?: DrainPipelineOptions<
timer = null
if (!activeFlush) startFlush()
}, intervalMs)
unrefTimer(timer)
}

function getRetryDelay(attempt: number): number {
Expand Down
20 changes: 10 additions & 10 deletions packages/evlog/src/redact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export interface RedactPathMatchers {
exactPaths: Set<string>
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<string>
}

Expand Down Expand Up @@ -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())
}
}

Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -272,6 +272,7 @@ export function resolveRedactConfig(input: boolean | RedactConfig | undefined):
return {
...input,
_maskers: maskers,
_pathMatchers: compileRedactPathMatchers(input.paths),
}
}

Expand Down Expand Up @@ -329,7 +330,8 @@ export function redactEvent(event: Record<string, unknown>, 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)
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
39 changes: 37 additions & 2 deletions packages/evlog/src/runtime/server/routes/_evlog/ingest.post.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -32,6 +32,34 @@ function validateOrigin(event: Parameters<typeof defineEventHandler>[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<typeof defineEventHandler>[0] extends (e: infer E) => unknown ? E : never): Promise<unknown> {
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$/

Expand Down Expand Up @@ -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()
Expand Down
25 changes: 25 additions & 0 deletions packages/evlog/src/shared/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,31 @@ export async function resolveAdapterConfig<T>(
return config as Partial<T>
}

const warnedDeprecatedAliases = new Set<string>()

/**
* Copy a deprecated config field onto its replacement when the replacement is
* unset, warning once per adapter/field pair.
*/
export function applyDeprecatedAlias<T extends object>(
config: T,
opts: { adapter: string, from: keyof T & string, to: keyof T & string, envHint?: string },
): T {
const record = config as Record<string, unknown>
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.
Expand Down
7 changes: 7 additions & 0 deletions packages/evlog/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>
pathGlobs: RegExp[]
keyGlobs: RegExp[]
caseInsensitiveLeaves: Set<string>
}
}

/**
Expand Down
Loading
Loading