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
9 changes: 9 additions & 0 deletions .changeset/fix-middleware-uselogger.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"evlog": patch
---

fix(nitro): always create logger in request hook so `useLogger()` works in server middleware

Previously, calling `useLogger(event)` inside a Nuxt server middleware would throw `"Logger not initialized"` because the Nitro plugin skipped logger creation for routes not matching `include` patterns. Since middleware runs for every request, this made it impossible to use `useLogger` there.

The `shouldLog` filtering is now evaluated at emit time instead of creation time — the logger is always available on `event.context.log`, but events for non-matching routes are silently discarded.
20 changes: 10 additions & 10 deletions packages/evlog/src/nitro-v3/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,14 +153,13 @@ export default definePlugin((nitroApp) => {

hooks.hook('request', (event) => {
const { pathname } = parseURL(event.req.url)

// Skip logging for routes not matching include/exclude patterns
if (!shouldLog(pathname, evlogConfig?.include, evlogConfig?.exclude)) {
return
}

const ctx = getContext(event)

// Evaluate route filtering but always create the logger so that server
// middleware (which runs for every request) can call useLogger(event)
// without throwing. Filtering is enforced at emit time instead.
ctx._evlogShouldEmit = shouldLog(pathname, evlogConfig?.include, evlogConfig?.exclude)

// Store start time for duration calculation in tail sampling
ctx._evlogStartTime = Date.now()

Expand All @@ -187,8 +186,8 @@ export default definePlugin((nitroApp) => {

hooks.hook('response', async (res, event) => {
const ctx = event.req.context
// Skip if already emitted by error hook
if (ctx?._evlogEmitted) return
// Skip if already emitted by error hook or route was filtered out
if (ctx?._evlogEmitted || !ctx?._evlogShouldEmit) return

const log = ctx?.log as RequestLogger | undefined
if (!log || !ctx) return
Expand Down Expand Up @@ -221,8 +220,9 @@ export default definePlugin((nitroApp) => {
const e = event as HTTPEvent

const ctx = e.req.context
const log = ctx?.log as RequestLogger | undefined
if (!log || !ctx) return
if (!ctx?._evlogShouldEmit) return
const log = ctx.log as RequestLogger | undefined
if (!log) return

// Check if error.cause is an EvlogError (thrown errors get wrapped in HTTPError by nitro)
const actualError = (error.cause as Error)?.name === 'EvlogError'
Expand Down
13 changes: 7 additions & 6 deletions packages/evlog/src/nitro/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,10 @@ export default defineNitroPlugin(async (nitroApp) => {
nitroApp.hooks.hook('request', (event) => {
const e = event as ServerEvent

// Skip logging for routes not matching include/exclude patterns
if (!shouldLog(e.path, evlogConfig?.include, evlogConfig?.exclude)) {
return
}
// Evaluate route filtering but always create the logger so that server
// middleware (which runs for every request) can call useLogger(event)
// without throwing. Filtering is enforced at emit time instead.
e.context._evlogShouldEmit = shouldLog(e.path, evlogConfig?.include, evlogConfig?.exclude)

// Store start time for duration calculation in tail sampling
e.context._evlogStartTime = Date.now()
Expand Down Expand Up @@ -168,6 +168,7 @@ export default defineNitroPlugin(async (nitroApp) => {
nitroApp.hooks.hook('error', async (error, { event }) => {
const e = event as ServerEvent | undefined
if (!e) return
if (!e.context._evlogShouldEmit) return

const requestLog = e.context.log as RequestLogger | undefined
if (requestLog) {
Expand Down Expand Up @@ -201,8 +202,8 @@ export default defineNitroPlugin(async (nitroApp) => {

nitroApp.hooks.hook('afterResponse', async (event) => {
const e = event as ServerEvent
// Skip if already emitted by error hook
if (e.context._evlogEmitted) return
// Skip if already emitted by error hook or route was filtered out
if (e.context._evlogEmitted || !e.context._evlogShouldEmit) return

const requestLog = e.context.log as RequestLogger | undefined
if (requestLog) {
Expand Down
2 changes: 2 additions & 0 deletions packages/evlog/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,8 @@ export interface H3EventContext {
_evlogStartTime?: number
/** Internal: flag to prevent double emission on errors */
_evlogEmitted?: boolean
/** Internal: whether the route matched shouldLog filtering (emit-time guard) */
_evlogShouldEmit?: boolean
[key: string]: unknown
}

Expand Down
155 changes: 153 additions & 2 deletions packages/evlog/test/nitro-plugin.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getHeaders } from 'h3'
import type { DrainContext, EnrichContext, RouteConfig, ServerEvent, WideEvent } from '../src/types'
import type { DrainContext, EnrichContext, RequestLogger, RouteConfig, ServerEvent, WideEvent } from '../src/types'
import { filterSafeHeaders, matchesPattern } from '../src/utils'
import { shouldLog } from '../src/shared/routes'
import { createRequestLogger, initLogger } from '../src/logger'

vi.mock('h3', () => ({
getHeaders: vi.fn(),
Expand Down Expand Up @@ -1037,3 +1039,152 @@ describe('nitro plugin - enrichment pipeline (T7)', () => {
expect(drainHeaders).toEqual(mockHeaders)
})
})

describe('nitro plugin - middleware compatibility (#210)', () => {
/**
* Replicates the plugin's `request` hook logic so we can test it directly.
* The real plugin registers this via nitroApp.hooks.hook('request', ...).
*/
function simulateRequestHook(
event: ServerEvent,
config?: { include?: string[]; exclude?: string[] },
): void {
event.context._evlogShouldEmit = shouldLog(event.path, config?.include, config?.exclude)
event.context._evlogStartTime = Date.now()
event.context.log = createRequestLogger(
{ method: event.method, path: event.path, requestId: crypto.randomUUID() },
{ _deferDrain: true },
)
}

/**
* Replicates the plugin's `afterResponse` hook logic.
*/
function simulateAfterResponseHook(event: ServerEvent): { emitted: boolean } {
if (event.context._evlogEmitted || !event.context._evlogShouldEmit) {
return { emitted: false }
}
const log = event.context.log as RequestLogger | undefined
if (!log) return { emitted: false }
log.set({ status: 200 })
const result = log.emit()
return { emitted: result !== null }
}

/**
* Replicates the plugin's `error` hook logic.
*/
function simulateErrorHook(event: ServerEvent, error: Error): { emitted: boolean } {
if (!event.context._evlogShouldEmit) return { emitted: false }
const log = event.context.log as RequestLogger | undefined
if (!log) return { emitted: false }
log.error(error)
log.set({ status: 500 })
event.context._evlogEmitted = true
const result = log.emit()
return { emitted: result !== null }
}

beforeEach(() => {
initLogger({ env: { service: 'test-app' }, pretty: false })
})

afterEach(() => {
vi.restoreAllMocks()
})

it('request hook creates logger even when route is filtered out by include', () => {
const event: ServerEvent = { method: 'GET', path: '/dashboard', context: {} }

simulateRequestHook(event, { include: ['/api/**'] })

expect(event.context.log).toBeDefined()
expect(event.context._evlogShouldEmit).toBe(false)
expect(event.context._evlogStartTime).toBeTypeOf('number')
})

it('request hook sets _evlogShouldEmit true for matching routes', () => {
const event: ServerEvent = { method: 'GET', path: '/api/users', context: {} }

simulateRequestHook(event, { include: ['/api/**'] })

expect(event.context.log).toBeDefined()
expect(event.context._evlogShouldEmit).toBe(true)
})

it('middleware can call set() on logger from a filtered route', () => {
const event: ServerEvent = { method: 'GET', path: '/dashboard', context: {} }

simulateRequestHook(event, { include: ['/api/**'] })

expect(event.context._evlogShouldEmit).toBe(false)
event.context.log!.set({ user: { id: 'usr_123', plan: 'enterprise' } })

const ctx = event.context.log!.getContext()
expect(ctx.user).toEqual({ id: 'usr_123', plan: 'enterprise' })
})

it('afterResponse does not emit for filtered routes', () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const event: ServerEvent = { method: 'GET', path: '/dashboard', context: {} }

simulateRequestHook(event, { include: ['/api/**'] })
event.context.log!.set({ user: { id: 'test' } })

const { emitted } = simulateAfterResponseHook(event)

expect(emitted).toBe(false)
consoleSpy.mockRestore()
})

it('afterResponse emits for matching routes', () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const event: ServerEvent = { method: 'GET', path: '/api/users', context: {} }

simulateRequestHook(event, { include: ['/api/**'] })

const { emitted } = simulateAfterResponseHook(event)

expect(emitted).toBe(true)
consoleSpy.mockRestore()
})

it('error hook does not emit for filtered routes', () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const event: ServerEvent = { method: 'POST', path: '/dashboard', context: {} }

simulateRequestHook(event, { include: ['/api/**'] })

const { emitted } = simulateErrorHook(event, new Error('boom'))

expect(emitted).toBe(false)
expect(event.context._evlogEmitted).toBeUndefined()
consoleSpy.mockRestore()
})

it('error hook emits for matching routes', () => {
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const event: ServerEvent = { method: 'POST', path: '/api/checkout', context: {} }

simulateRequestHook(event, { include: ['/api/**'] })

const { emitted } = simulateErrorHook(event, new Error('payment failed'))

expect(emitted).toBe(true)
expect(event.context._evlogEmitted).toBe(true)
consoleSpy.mockRestore()
})

it('request hook creates logger for all routes when no include is set', () => {
const pageEvent: ServerEvent = { method: 'GET', path: '/', context: {} }
const apiEvent: ServerEvent = { method: 'GET', path: '/api/users', context: {} }

simulateRequestHook(pageEvent)
simulateRequestHook(apiEvent)

expect(pageEvent.context.log).toBeDefined()
expect(pageEvent.context._evlogShouldEmit).toBe(true)
expect(apiEvent.context.log).toBeDefined()
expect(apiEvent.context._evlogShouldEmit).toBe(true)
})
})
Loading