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
36 changes: 35 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ export default defineNuxtConfig({
| `pretty` | `boolean` | `true` in dev | Pretty print logs with tree formatting |
| `sampling.rates` | `object` | `undefined` | Head sampling rates per log level (0-100%). Error defaults to 100% |
| `sampling.keep` | `array` | `undefined` | Tail sampling conditions to force-keep logs (see below) |
| `transport.enabled` | `boolean` | `false` | Enable sending client logs to the server |
| `transport.endpoint` | `string` | `'/api/_evlog/ingest'` | API endpoint for client log ingestion |

#### Sampling Configuration

Expand Down Expand Up @@ -349,7 +351,39 @@ log.info('checkout', 'User initiated checkout')
log.error({ action: 'payment', error: 'validation_failed' })
```

Client logs output to the browser console with colored tags in development. Use for debugging and development - for production analytics, use dedicated services.
Client logs output to the browser console with colored tags in development.

#### Client Transport

To send client logs to your server for centralized logging, enable the transport:

```typescript
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['evlog/nuxt'],
evlog: {
transport: {
enabled: true, // Send client logs to server
},
},
})
```

When enabled:
1. Client logs are sent to `/api/_evlog/ingest` via POST
2. Server enriches with environment context (service, version, etc.)
3. `evlog:drain` hook is called with `source: 'client'`
4. External services receive the log

Identify client logs in your drain hook:

```typescript
nitroApp.hooks.hook('evlog:drain', async (ctx) => {
if (ctx.event.source === 'client') {
// Handle client logs specifically
}
})
```

## Publishing

Expand Down
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ export default defineNuxtConfig({
},
// Optional: only log specific routes (supports glob patterns)
include: ['/api/**'],
// Optional: send client logs to server (default: false)
transport: {
enabled: true,
},
},
})
```
Expand Down Expand Up @@ -519,6 +523,40 @@ Review my logging code
Help me set up logging for this service
```

## Client Transport

Send browser logs to your server for centralized logging. When enabled, client-side `log.info()`, `log.error()`, etc. are automatically sent to the server.

```typescript
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['evlog/nuxt'],
evlog: {
transport: {
enabled: true, // Enable client log transport
},
},
})
```

**How it works:**

1. Client calls `log.info({ action: 'click', button: 'submit' })`
2. Log is sent to `/api/_evlog/ingest` via POST
3. Server enriches with environment context (service, version, etc.)
4. `evlog:drain` hook is called with `source: 'client'`
5. External services receive the log

In your drain hook, identify client logs by `source: 'client'`:

```typescript
nitroApp.hooks.hook('evlog:drain', async (ctx) => {
if (ctx.event.source === 'client') {
// Handle client logs
}
})
```

## Philosophy

Inspired by [Logging Sucks](https://loggingsucks.com/) by [Boris Tane](https://x.com/boristane).
Expand Down
44 changes: 44 additions & 0 deletions apps/docs/content/1.getting-started/2.installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export default defineNuxtConfig({
| `pretty` | `boolean` | `true` in dev | Pretty print with tree formatting |
| `sampling.rates` | `object` | `undefined` | Head sampling rates per log level (0-100%). See [Sampling](#sampling) |
| `sampling.keep` | `array` | `undefined` | Tail sampling conditions to force-keep logs. See [Sampling](#sampling) |
| `transport.enabled` | `boolean` | `false` | Enable sending client logs to the server. See [Client Transport](#client-transport) |
| `transport.endpoint` | `string` | `'/api/_evlog/ingest'` | API endpoint for client log ingestion |

### Route Filtering

Expand Down Expand Up @@ -165,6 +167,48 @@ The hook receives a `DrainContext` with:
- `event`: The complete `WideEvent` (timestamp, level, service, and all accumulated context)
- `request`: Optional request metadata (`method`, `path`, `requestId`)

### Client Transport

Send browser logs to your server for centralized logging. When enabled, client-side `log.info()`, `log.error()`, etc. calls are automatically sent to the server via the `/api/_evlog/ingest` endpoint.

```typescript [nuxt.config.ts]
export default defineNuxtConfig({
modules: ['evlog/nuxt'],
evlog: {
transport: {
enabled: true, // Enable client log transport
endpoint: '/api/_evlog/ingest', // default
},
},
})
```

#### How it works

1. Client calls `log.info({ action: 'click', button: 'submit' })`
2. Log is sent to `/api/_evlog/ingest` via POST
3. Server enriches with environment context (service, version, region, etc.)
4. `evlog:drain` hook is called with `source: 'client'`
5. External services receive the log (Axiom, Loki, etc.)

::callout{icon="i-lucide-info" color="info"}
Client logs are automatically enriched with the server's environment context. You don't need to send `service`, `environment`, or `version` from the client.
::

In your drain hook, you can identify client logs by the `source: 'client'` field:

```typescript [server/plugins/evlog-drain.ts]
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('evlog:drain', async (ctx) => {
if (ctx.event.source === 'client') {
// Handle client logs specifically
console.log('[CLIENT]', ctx.event)
}
// Send to external service...
})
})
```

::callout{icon="i-lucide-lightbulb" color="info"}
**Tip:** Use Nuxt's `$production` override to sample only in production while keeping full visibility in development:

Expand Down
3 changes: 3 additions & 0 deletions apps/playground/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ export default defineNuxtConfig({
env: {
service: 'playground',
},
transport: {
enabled: true,
},
sampling: {
// Head sampling: only 10% of info logs
rates: {
Expand Down
2 changes: 2 additions & 0 deletions packages/evlog/build.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export default defineBuildConfig({
{ input: 'src/runtime/client/log', name: 'runtime/client/log' },
{ input: 'src/runtime/client/plugin', name: 'runtime/client/plugin' },
{ input: 'src/runtime/server/useLogger', name: 'runtime/server/useLogger' },
{ input: 'src/runtime/server/routes/_evlog/ingest.post', name: 'runtime/server/routes/_evlog/ingest.post' },
{ input: 'src/runtime/utils/parseError', name: 'runtime/utils/parseError' },
{ input: 'src/error', name: 'error' },
{ input: 'src/logger', name: 'logger' },
Expand All @@ -32,5 +33,6 @@ export default defineBuildConfig({
'nitropack',
'nitropack/runtime',
'ofetch',
'h3',
],
})
2 changes: 2 additions & 0 deletions packages/evlog/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type {
EnvironmentContext,
ErrorOptions,
H3EventContext,
IngestPayload,
Log,
LoggerConfig,
LogLevel,
Expand All @@ -20,5 +21,6 @@ export type {
ServerEvent,
TailSamplingCondition,
TailSamplingContext,
TransportConfig,
WideEvent,
} from './types'
31 changes: 30 additions & 1 deletion packages/evlog/src/nuxt/module.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import {
addImports,
addPlugin,
addServerHandler,
addServerImports,
addServerPlugin,
createResolver,
defineNuxtModule,
} from '@nuxt/kit'
import type { EnvironmentContext, SamplingConfig } from '../types'
import type { EnvironmentContext, SamplingConfig, TransportConfig } from '../types'

export interface ModuleOptions {
/**
Expand Down Expand Up @@ -53,6 +54,19 @@ export interface ModuleOptions {
* ```
*/
sampling?: SamplingConfig

/**
* Transport configuration for sending client logs to the server.
*
* @example
* ```ts
* transport: {
* enabled: true, // Send logs to server API
* endpoint: '/api/_evlog/ingest' // Custom endpoint
* }
* ```
*/
transport?: TransportConfig
}

export default defineNuxtModule<ModuleOptions>({
Expand All @@ -65,9 +79,24 @@ export default defineNuxtModule<ModuleOptions>({
setup(options, nuxt) {
const resolver = createResolver(import.meta.url)

const transportEnabled = options.transport?.enabled ?? false
const transportEndpoint = options.transport?.endpoint ?? '/api/_evlog/ingest'

nuxt.options.runtimeConfig.evlog = options
nuxt.options.runtimeConfig.public.evlog = {
pretty: options.pretty,
transport: {
enabled: transportEnabled,
endpoint: transportEndpoint,
},
}

if (transportEnabled) {
addServerHandler({
route: transportEndpoint,
method: 'post',
handler: resolver.resolve('../runtime/server/routes/_evlog/ingest.post'),
})
}
Comment thread
HugoRCD marked this conversation as resolved.

addServerPlugin(resolver.resolve('../nitro/plugin'))
Expand Down
33 changes: 31 additions & 2 deletions packages/evlog/src/runtime/client/log.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type { Log, LogLevel } from '../../types'
import type { Log, LogLevel, TransportConfig } from '../../types'
import { getConsoleMethod } from '../../utils'

const isClient = typeof window !== 'undefined'

let clientPretty = true
let clientService = 'client'
let transportEnabled = false
let transportEndpoint = '/api/_evlog/ingest'

const LEVEL_COLORS: Record<string, string> = {
error: 'color: #ef4444; font-weight: bold',
Expand All @@ -13,9 +15,27 @@ const LEVEL_COLORS: Record<string, string> = {
debug: 'color: #6b7280; font-weight: bold',
}

export function initLog(options: { pretty?: boolean, service?: string } = {}): void {
export function initLog(options: { pretty?: boolean, service?: string, transport?: TransportConfig } = {}): void {
clientPretty = options.pretty ?? true
clientService = options.service ?? 'client'
transportEnabled = options.transport?.enabled ?? false
transportEndpoint = options.transport?.endpoint ?? '/api/_evlog/ingest'
}
Comment thread
HugoRCD marked this conversation as resolved.

async function sendToServer(event: Record<string, unknown>): Promise<void> {
if (!transportEnabled) return

try {
await fetch(transportEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(event),
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fetch request doesn't specify credentials mode. For authenticated applications where cookies are needed for authentication or CSRF protection, the fetch should include credentials: 'same-origin' to ensure cookies are sent with the request. Consider adding this option or documenting the authentication requirements.

Suggested change
body: JSON.stringify(event),
body: JSON.stringify(event),
credentials: 'same-origin',

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Feb 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fetch call doesn't include the keepalive flag. When a page is being unloaded or navigated away from, logs sent at that moment might be cancelled before completion. Adding keepalive: true to the fetch options would ensure that the request continues even if the page is closed, improving reliability of log delivery during page transitions.

Suggested change
body: JSON.stringify(event),
body: JSON.stringify(event),
keepalive: true,

Copilot uses AI. Check for mistakes.
keepalive: true,
credentials: 'same-origin',
})
} catch {
// Silently fail - don't break the app
}
}
Comment thread
HugoRCD marked this conversation as resolved.
Comment thread
HugoRCD marked this conversation as resolved.

function emitLog(level: LogLevel, event: Record<string, unknown>): void {
Expand All @@ -34,11 +54,20 @@ function emitLog(level: LogLevel, event: Record<string, unknown>): void {
} else {
console[method](JSON.stringify(formatted))
}

sendToServer(formatted)
Comment thread
HugoRCD marked this conversation as resolved.
}

function emitTaggedLog(level: LogLevel, tag: string, message: string): void {
if (clientPretty) {
console[getConsoleMethod(level)](`%c[${tag}]%c ${message}`, LEVEL_COLORS[level] || '', 'color: inherit')
sendToServer({
timestamp: new Date().toISOString(),
level,
service: clientService,
tag,
message,
})
} else {
emitLog(level, { tag, message })
}
Expand Down
3 changes: 3 additions & 0 deletions packages/evlog/src/runtime/client/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { TransportConfig } from '../../types'
import { initLog } from './log'
import { defineNuxtPlugin, useRuntimeConfig } from '#app'

interface EvlogPublicConfig {
pretty?: boolean
transport?: TransportConfig
}

export default defineNuxtPlugin(() => {
Expand All @@ -12,5 +14,6 @@ export default defineNuxtPlugin(() => {
initLog({
pretty: evlogConfig?.pretty ?? import.meta.dev,
service: 'client',
transport: evlogConfig?.transport,
})
})
Loading
Loading