Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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": path
"@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**: flush and retry timers are `unref()`'d so a pending batch never holds a Node process open on shutdown — call `flush()` to deliver buffered events before exit (unchanged, documented contract).
- **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 → NUXT_BETTER_STACK_API_KEY.',
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
})
}

/**
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
12 changes: 11 additions & 1 deletion packages/evlog/src/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ export interface PipelineDrainFn<T> {
* nitroApp.hooks.hook('close', () => drain.flush())
* ```
*/
/**
* Unref a timer on runtimes that support it (Node, Bun) so a pending flush or
* retry backoff never holds the process open. Buffered events are delivered on
* shutdown via the documented `flush()` contract, not by keeping timers alive.
*/
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 +103,7 @@ export function createDrainPipeline<T = unknown>(options?: DrainPipelineOptions<
timer = null
if (!activeFlush) startFlush()
}, intervalMs)
unrefTimer(timer)
}

function getRetryDelay(attempt: number): number {
Expand Down Expand Up @@ -122,7 +132,7 @@ export function createDrainPipeline<T = unknown>(options?: DrainPipelineOptions<
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error))
if (attempt < maxAttempts) {
await new Promise<void>(r => setTimeout(r, getRetryDelay(attempt)))
await new Promise<void>(r => unrefTimer(setTimeout(r, getRetryDelay(attempt))))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
}
}
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' })
}
// String length undercounts multi-byte characters, but is a close enough proxy for a size cap.
if (raw.length > MAX_BODY_BYTES) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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
22 changes: 22 additions & 0 deletions packages/evlog/src/shared/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,28 @@ 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] && record[opts.from]) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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] = record[opts.from]
}
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