Skip to content
Open
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
11 changes: 11 additions & 0 deletions .changeset/nitro-bridge-active-runtime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'evlog': patch
---

Fix a runtime crash on Vercel + Bun + Nitro v3 where evlog probed `nitropack/runtime/internal/config` even though only Nitro v3 was installed. Bun's auto-install kicked in for the missing dependency and tried to write `node_modules/.cache`, which crashes on Vercel's read-only function filesystem with `bun is unable to write files: ReadOnlyFileSystem`.

The Nitro plugins now declare their major version once (via the new internal `setActiveNitroRuntime` helper) and the shared config bridge probes only the matching runtime β€” `nitro/runtime-config` for v3, `nitropack/...` for v2. Adapters resolving config through `runtimeConfig.evlog.<adapter>` benefit from the same restriction, so `createPostHogDrain()` (and any adapter using `resolveAdapterConfig`) no longer triggers the cross-version probe.

No public-API change. The `process.env.__EVLOG_CONFIG` fast path remains the highest-priority lookup.

Closes [#312](https://github.com/HugoRCD/evlog/issues/312).
3 changes: 2 additions & 1 deletion packages/evlog/src/nitro-v3/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { parseURL } from 'ufo'
import { createRequestLogger, getGlobalPluginRunner, initLogger, isEnabled } from '../logger'
import { shouldLog, getServiceForPath, extractErrorStatus } from '../nitro'
import { normalizeRedactConfig } from '../redact'
import { resolveEvlogConfigForNitroPlugin } from '../shared/nitroConfigBridge'
import { resolveEvlogConfigForNitroPlugin, setActiveNitroRuntime } from '../shared/nitroConfigBridge'
import type { EnrichContext, RequestLogger, TailSamplingContext, WideEvent } from '../types'
import { filterSafeHeaders } from '../utils'

Expand Down Expand Up @@ -150,6 +150,7 @@ async function callEnrichAndDrain(
* ```
*/
export default definePlugin(async (nitroApp) => {
setActiveNitroRuntime('v3')
const evlogConfig = await resolveEvlogConfigForNitroPlugin()

const redact = normalizeRedactConfig(evlogConfig?.redact as boolean | Record<string, unknown> | undefined)
Expand Down
3 changes: 2 additions & 1 deletion packages/evlog/src/nitro/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { getHeaders } from 'h3'
import { createRequestLogger, getGlobalPluginRunner, initLogger, isEnabled } from '../logger'
import { shouldLog, getServiceForPath, extractErrorStatus } from '../nitro'
import { normalizeRedactConfig } from '../redact'
import { resolveEvlogConfigForNitroPlugin } from '../shared/nitroConfigBridge'
import { resolveEvlogConfigForNitroPlugin, setActiveNitroRuntime } from '../shared/nitroConfigBridge'
import { startStreamServer, type StreamServerOptions } from '../stream'
import type { EnrichContext, RequestLogger, ServerEvent, TailSamplingContext, WideEvent } from '../types'
import { filterSafeHeaders } from '../utils'
Expand Down Expand Up @@ -120,6 +120,7 @@ async function callEnrichAndDrain(
}

export default defineNitroPlugin(async (nitroApp) => {
setActiveNitroRuntime('v2')
const evlogConfig = await resolveEvlogConfigForNitroPlugin()

const redact = normalizeRedactConfig(evlogConfig?.redact as boolean | Record<string, unknown> | undefined)
Expand Down
73 changes: 69 additions & 4 deletions packages/evlog/src/shared/nitroConfigBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@
* modules; preferred in production Workers builds).
* 2. Computed module IDs β€” `['a','b'].join('/')` passed to `import()` so emitted
* JS does not contain a static `import("a/b")`.
* 3. Plugin resolution tries Nitro v3 first, then nitropack internal config (v2).
* 4. Adapter resolution keeps historical order: nitropack runtime barrel, then v3.
* 3. Plugins call {@link setActiveNitroRuntime} so adapters never probe modules
* from the other major version (e.g. Bun on Vercel triggers a package-cache
* write when probing a missing dynamic import β€” see issue #312).
* 4. When the active runtime is unknown (standalone use outside a Nitro
* plugin), the bridge falls back to the historical probe order.
*
* Not exported from `evlog/toolkit` β€” package-internal only.
*/
Expand All @@ -25,6 +28,29 @@ type EvlogConfig = NitroPluginEvlogConfig

const EVLOG_NITRO_ENV = '__EVLOG_CONFIG' as const

type NitroMajor = 'v2' | 'v3'

let activeNitroRuntime: NitroMajor | undefined

/**
* Declare the active Nitro major version so adapters never probe the other
* version's modules at runtime. The evlog Nitro plugins call this in their
* first synchronous statement.
*
* Bun's auto-install behavior writes to `node_modules/.cache` whenever a
* dynamic import targets a missing package, which crashes on Vercel's
* read-only function filesystem. Restricting probes to the runtime that is
* actually installed avoids that path entirely.
*/
export function setActiveNitroRuntime(version: NitroMajor): void {
activeNitroRuntime = version
}

/** @internal Reset the active runtime declaration. Used by tests only. */
export function resetActiveNitroRuntime(): void {
activeNitroRuntime = undefined
}

type NitroRuntimeConfigModule = {
useRuntimeConfig: () => Record<string, any>
}
Expand Down Expand Up @@ -102,12 +128,36 @@ function evlogSlice(config: Record<string, any>): EvlogConfig | undefined {

/**
* Options for evlog Nitro plugins (nitropack v2 and Nitro v3).
* Env bridge first; then Nitro v3 `runtime-config`; then nitropack internal config.
*
* Lookup order:
* 1. `process.env.__EVLOG_CONFIG`
* 2. The active runtime declared by {@link setActiveNitroRuntime} β€” either
* Nitro v3 `runtime-config` or nitropack internal config, never both.
* 3. When no active runtime has been declared (standalone use): probe v3 then
* nitropack v2 as a best-effort fallback.
*/
export async function resolveEvlogConfigForNitroPlugin(): Promise<EvlogConfig | undefined> {
const fromEnv = readEvlogConfigFromNitroEnv()
if (fromEnv !== undefined) return fromEnv

if (activeNitroRuntime === 'v3') {
const v3 = await getNitroV3Runtime()
if (v3) {
const slice = evlogSlice(v3.useRuntimeConfig())
if (slice !== undefined) return slice
}
return undefined
}

if (activeNitroRuntime === 'v2') {
const internal = await getNitropackInternalRuntimeConfig()
if (internal) {
const slice = evlogSlice(internal.useRuntimeConfig())
if (slice !== undefined) return slice
}
return undefined
}

const v3 = await getNitroV3Runtime()
if (v3) {
const slice = evlogSlice(v3.useRuntimeConfig())
Expand All @@ -124,9 +174,24 @@ export async function resolveEvlogConfigForNitroPlugin(): Promise<EvlogConfig |
}

/**
* Full `useRuntimeConfig()` object for drain adapters (nitropack first, then v3).
* Full `useRuntimeConfig()` object for drain adapters.
*
* Honors {@link setActiveNitroRuntime}: when a Nitro plugin has declared its
* version, only that version's runtime module is probed. When no version has
* been declared (standalone use outside Nitro), falls back to the historical
* order: nitropack v2 first, then Nitro v3.
*/
export async function getNitroRuntimeConfigRecord(): Promise<Record<string, any> | undefined> {
if (activeNitroRuntime === 'v3') {
const v3 = await getNitroV3Runtime()
return v3 ? v3.useRuntimeConfig() : undefined
}

if (activeNitroRuntime === 'v2') {
const nitropack = await getNitropackRuntime()
return nitropack ? nitropack.useRuntimeConfig() : undefined
}

const nitropack = await getNitropackRuntime()
if (nitropack) return nitropack.useRuntimeConfig()

Expand Down
86 changes: 86 additions & 0 deletions packages/evlog/test/shared/nitroConfigBridge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

beforeEach(() => {
vi.resetModules()
vi.unstubAllEnvs()
delete process.env.__EVLOG_CONFIG
})

afterEach(() => {
vi.resetModules()
vi.doUnmock(['nitro', 'runtime-config'].join('/'))
vi.doUnmock(['nitropack', 'runtime', 'internal', 'config'].join('/'))
vi.doUnmock(['nitropack', 'runtime'].join('/'))
})

async function loadBridgeWithMocks() {
const importSpy = vi.fn<(specifier: string) => void>()
vi.doMock(['nitro', 'runtime-config'].join('/'), () => {
importSpy('nitro/runtime-config')
return { useRuntimeConfig: () => ({ evlog: { env: { service: 'svc-v3' } }, posthog: { apiKey: 'phc-v3' } }) }
})
vi.doMock(['nitropack', 'runtime', 'internal', 'config'].join('/'), () => {
importSpy('nitropack/runtime/internal/config')
return { useRuntimeConfig: () => ({ evlog: { env: { service: 'svc-v2' } }, posthog: { apiKey: 'phc-v2' } }) }
})
vi.doMock(['nitropack', 'runtime'].join('/'), () => {
importSpy('nitropack/runtime')
return { useRuntimeConfig: () => ({ evlog: { env: { service: 'svc-v2-barrel' } }, posthog: { apiKey: 'phc-v2-barrel' } }) }
})
const bridge = await import('../../src/shared/nitroConfigBridge')
return { bridge, importSpy }
}

describe('nitroConfigBridge β€” active runtime', () => {
it('only probes Nitro v3 modules when v3 is the active runtime', async () => {
const { bridge, importSpy } = await loadBridgeWithMocks()
bridge.setActiveNitroRuntime('v3')

const config = await bridge.resolveEvlogConfigForNitroPlugin()
const record = await bridge.getNitroRuntimeConfigRecord()

expect(config).toEqual({ env: { service: 'svc-v3' } })
expect(record).toEqual({ evlog: { env: { service: 'svc-v3' } }, posthog: { apiKey: 'phc-v3' } })

const probed = importSpy.mock.calls.map(call => call[0])
expect(probed).toContain('nitro/runtime-config')
expect(probed).not.toContain('nitropack/runtime')
expect(probed).not.toContain('nitropack/runtime/internal/config')
})

it('only probes nitropack v2 modules when v2 is the active runtime', async () => {
const { bridge, importSpy } = await loadBridgeWithMocks()
bridge.setActiveNitroRuntime('v2')

const config = await bridge.resolveEvlogConfigForNitroPlugin()
const record = await bridge.getNitroRuntimeConfigRecord()

expect(config).toEqual({ env: { service: 'svc-v2' } })
expect(record).toEqual({ evlog: { env: { service: 'svc-v2-barrel' } }, posthog: { apiKey: 'phc-v2-barrel' } })

const probed = importSpy.mock.calls.map(call => call[0])
expect(probed).not.toContain('nitro/runtime-config')
})

it('reads __EVLOG_CONFIG from env without probing any Nitro module', async () => {
process.env.__EVLOG_CONFIG = JSON.stringify({ env: { service: 'svc-env' } })
const { bridge, importSpy } = await loadBridgeWithMocks()
bridge.setActiveNitroRuntime('v3')

const config = await bridge.resolveEvlogConfigForNitroPlugin()

expect(config).toEqual({ env: { service: 'svc-env' } })
expect(importSpy).not.toHaveBeenCalled()
})

it('falls back to historical probe order when no runtime is declared', async () => {
const { bridge, importSpy } = await loadBridgeWithMocks()
bridge.resetActiveNitroRuntime()

const config = await bridge.resolveEvlogConfigForNitroPlugin()

expect(config).toEqual({ env: { service: 'svc-v3' } })
const probed = importSpy.mock.calls.map(call => call[0])
expect(probed).toContain('nitro/runtime-config')
})
})
Loading