Skip to content

Commit 3ade790

Browse files
HugoRCDCopilot
andauthored
feat: add client log transport (#23)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent f44e87d commit 3ade790

12 files changed

Lines changed: 358 additions & 5 deletions

File tree

AGENTS.md

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,8 @@ export default defineNuxtConfig({
165165
| `pretty` | `boolean` | `true` in dev | Pretty print logs with tree formatting |
166166
| `sampling.rates` | `object` | `undefined` | Head sampling rates per log level (0-100%). Error defaults to 100% |
167167
| `sampling.keep` | `array` | `undefined` | Tail sampling conditions to force-keep logs (see below) |
168+
| `transport.enabled` | `boolean` | `false` | Enable sending client logs to the server |
169+
| `transport.endpoint` | `string` | `'/api/_evlog/ingest'` | API endpoint for client log ingestion |
168170

169171
#### Sampling Configuration
170172

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

352-
Client logs output to the browser console with colored tags in development. Use for debugging and development - for production analytics, use dedicated services.
354+
Client logs output to the browser console with colored tags in development.
355+
356+
#### Client Transport
357+
358+
To send client logs to your server for centralized logging, enable the transport:
359+
360+
```typescript
361+
// nuxt.config.ts
362+
export default defineNuxtConfig({
363+
modules: ['evlog/nuxt'],
364+
evlog: {
365+
transport: {
366+
enabled: true, // Send client logs to server
367+
},
368+
},
369+
})
370+
```
371+
372+
When enabled:
373+
1. Client logs are sent to `/api/_evlog/ingest` via POST
374+
2. Server enriches with environment context (service, version, etc.)
375+
3. `evlog:drain` hook is called with `source: 'client'`
376+
4. External services receive the log
377+
378+
Identify client logs in your drain hook:
379+
380+
```typescript
381+
nitroApp.hooks.hook('evlog:drain', async (ctx) => {
382+
if (ctx.event.source === 'client') {
383+
// Handle client logs specifically
384+
}
385+
})
386+
```
353387

354388
## Publishing
355389

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,10 @@ export default defineNuxtConfig({
9797
},
9898
// Optional: only log specific routes (supports glob patterns)
9999
include: ['/api/**'],
100+
// Optional: send client logs to server (default: false)
101+
transport: {
102+
enabled: true,
103+
},
100104
},
101105
})
102106
```
@@ -519,6 +523,40 @@ Review my logging code
519523
Help me set up logging for this service
520524
```
521525

526+
## Client Transport
527+
528+
Send browser logs to your server for centralized logging. When enabled, client-side `log.info()`, `log.error()`, etc. are automatically sent to the server.
529+
530+
```typescript
531+
// nuxt.config.ts
532+
export default defineNuxtConfig({
533+
modules: ['evlog/nuxt'],
534+
evlog: {
535+
transport: {
536+
enabled: true, // Enable client log transport
537+
},
538+
},
539+
})
540+
```
541+
542+
**How it works:**
543+
544+
1. Client calls `log.info({ action: 'click', button: 'submit' })`
545+
2. Log is sent to `/api/_evlog/ingest` via POST
546+
3. Server enriches with environment context (service, version, etc.)
547+
4. `evlog:drain` hook is called with `source: 'client'`
548+
5. External services receive the log
549+
550+
In your drain hook, identify client logs by `source: 'client'`:
551+
552+
```typescript
553+
nitroApp.hooks.hook('evlog:drain', async (ctx) => {
554+
if (ctx.event.source === 'client') {
555+
// Handle client logs
556+
}
557+
})
558+
```
559+
522560
## Philosophy
523561

524562
Inspired by [Logging Sucks](https://loggingsucks.com/) by [Boris Tane](https://x.com/boristane).

apps/docs/content/1.getting-started/2.installation.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ export default defineNuxtConfig({
5252
| `pretty` | `boolean` | `true` in dev | Pretty print with tree formatting |
5353
| `sampling.rates` | `object` | `undefined` | Head sampling rates per log level (0-100%). See [Sampling](#sampling) |
5454
| `sampling.keep` | `array` | `undefined` | Tail sampling conditions to force-keep logs. See [Sampling](#sampling) |
55+
| `transport.enabled` | `boolean` | `false` | Enable sending client logs to the server. See [Client Transport](#client-transport) |
56+
| `transport.endpoint` | `string` | `'/api/_evlog/ingest'` | API endpoint for client log ingestion |
5557

5658
### Route Filtering
5759

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

170+
### Client Transport
171+
172+
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.
173+
174+
```typescript [nuxt.config.ts]
175+
export default defineNuxtConfig({
176+
modules: ['evlog/nuxt'],
177+
evlog: {
178+
transport: {
179+
enabled: true, // Enable client log transport
180+
endpoint: '/api/_evlog/ingest', // default
181+
},
182+
},
183+
})
184+
```
185+
186+
#### How it works
187+
188+
1. Client calls `log.info({ action: 'click', button: 'submit' })`
189+
2. Log is sent to `/api/_evlog/ingest` via POST
190+
3. Server enriches with environment context (service, version, region, etc.)
191+
4. `evlog:drain` hook is called with `source: 'client'`
192+
5. External services receive the log (Axiom, Loki, etc.)
193+
194+
::callout{icon="i-lucide-info" color="info"}
195+
Client logs are automatically enriched with the server's environment context. You don't need to send `service`, `environment`, or `version` from the client.
196+
::
197+
198+
In your drain hook, you can identify client logs by the `source: 'client'` field:
199+
200+
```typescript [server/plugins/evlog-drain.ts]
201+
export default defineNitroPlugin((nitroApp) => {
202+
nitroApp.hooks.hook('evlog:drain', async (ctx) => {
203+
if (ctx.event.source === 'client') {
204+
// Handle client logs specifically
205+
console.log('[CLIENT]', ctx.event)
206+
}
207+
// Send to external service...
208+
})
209+
})
210+
```
211+
168212
::callout{icon="i-lucide-lightbulb" color="info"}
169213
**Tip:** Use Nuxt's `$production` override to sample only in production while keeping full visibility in development:
170214

apps/playground/nuxt.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ export default defineNuxtConfig({
1111
env: {
1212
service: 'playground',
1313
},
14+
transport: {
15+
enabled: true,
16+
},
1417
sampling: {
1518
// Head sampling: only 10% of info logs
1619
rates: {

packages/evlog/build.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export default defineBuildConfig({
88
{ input: 'src/runtime/client/log', name: 'runtime/client/log' },
99
{ input: 'src/runtime/client/plugin', name: 'runtime/client/plugin' },
1010
{ input: 'src/runtime/server/useLogger', name: 'runtime/server/useLogger' },
11+
{ input: 'src/runtime/server/routes/_evlog/ingest.post', name: 'runtime/server/routes/_evlog/ingest.post' },
1112
{ input: 'src/runtime/utils/parseError', name: 'runtime/utils/parseError' },
1213
{ input: 'src/error', name: 'error' },
1314
{ input: 'src/logger', name: 'logger' },
@@ -32,5 +33,6 @@ export default defineBuildConfig({
3233
'nitropack',
3334
'nitropack/runtime',
3435
'ofetch',
36+
'h3',
3537
],
3638
})

packages/evlog/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export type {
99
EnvironmentContext,
1010
ErrorOptions,
1111
H3EventContext,
12+
IngestPayload,
1213
Log,
1314
LoggerConfig,
1415
LogLevel,
@@ -20,5 +21,6 @@ export type {
2021
ServerEvent,
2122
TailSamplingCondition,
2223
TailSamplingContext,
24+
TransportConfig,
2325
WideEvent,
2426
} from './types'

packages/evlog/src/nuxt/module.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import {
22
addImports,
33
addPlugin,
4+
addServerHandler,
45
addServerImports,
56
addServerPlugin,
67
createResolver,
78
defineNuxtModule,
89
} from '@nuxt/kit'
9-
import type { EnvironmentContext, SamplingConfig } from '../types'
10+
import type { EnvironmentContext, SamplingConfig, TransportConfig } from '../types'
1011

1112
export interface ModuleOptions {
1213
/**
@@ -53,6 +54,19 @@ export interface ModuleOptions {
5354
* ```
5455
*/
5556
sampling?: SamplingConfig
57+
58+
/**
59+
* Transport configuration for sending client logs to the server.
60+
*
61+
* @example
62+
* ```ts
63+
* transport: {
64+
* enabled: true, // Send logs to server API
65+
* endpoint: '/api/_evlog/ingest' // Custom endpoint
66+
* }
67+
* ```
68+
*/
69+
transport?: TransportConfig
5670
}
5771

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

82+
const transportEnabled = options.transport?.enabled ?? false
83+
const transportEndpoint = options.transport?.endpoint ?? '/api/_evlog/ingest'
84+
6885
nuxt.options.runtimeConfig.evlog = options
6986
nuxt.options.runtimeConfig.public.evlog = {
7087
pretty: options.pretty,
88+
transport: {
89+
enabled: transportEnabled,
90+
endpoint: transportEndpoint,
91+
},
92+
}
93+
94+
if (transportEnabled) {
95+
addServerHandler({
96+
route: transportEndpoint,
97+
method: 'post',
98+
handler: resolver.resolve('../runtime/server/routes/_evlog/ingest.post'),
99+
})
71100
}
72101

73102
addServerPlugin(resolver.resolve('../nitro/plugin'))

packages/evlog/src/runtime/client/log.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
import type { Log, LogLevel } from '../../types'
1+
import type { Log, LogLevel, TransportConfig } from '../../types'
22
import { getConsoleMethod } from '../../utils'
33

44
const isClient = typeof window !== 'undefined'
55

66
let clientPretty = true
77
let clientService = 'client'
8+
let transportEnabled = false
9+
let transportEndpoint = '/api/_evlog/ingest'
810

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

16-
export function initLog(options: { pretty?: boolean, service?: string } = {}): void {
18+
export function initLog(options: { pretty?: boolean, service?: string, transport?: TransportConfig } = {}): void {
1719
clientPretty = options.pretty ?? true
1820
clientService = options.service ?? 'client'
21+
transportEnabled = options.transport?.enabled ?? false
22+
transportEndpoint = options.transport?.endpoint ?? '/api/_evlog/ingest'
23+
}
24+
25+
async function sendToServer(event: Record<string, unknown>): Promise<void> {
26+
if (!transportEnabled) return
27+
28+
try {
29+
await fetch(transportEndpoint, {
30+
method: 'POST',
31+
headers: { 'Content-Type': 'application/json' },
32+
body: JSON.stringify(event),
33+
keepalive: true,
34+
credentials: 'same-origin',
35+
})
36+
} catch {
37+
// Silently fail - don't break the app
38+
}
1939
}
2040

2141
function emitLog(level: LogLevel, event: Record<string, unknown>): void {
@@ -34,11 +54,20 @@ function emitLog(level: LogLevel, event: Record<string, unknown>): void {
3454
} else {
3555
console[method](JSON.stringify(formatted))
3656
}
57+
58+
sendToServer(formatted)
3759
}
3860

3961
function emitTaggedLog(level: LogLevel, tag: string, message: string): void {
4062
if (clientPretty) {
4163
console[getConsoleMethod(level)](`%c[${tag}]%c ${message}`, LEVEL_COLORS[level] || '', 'color: inherit')
64+
sendToServer({
65+
timestamp: new Date().toISOString(),
66+
level,
67+
service: clientService,
68+
tag,
69+
message,
70+
})
4271
} else {
4372
emitLog(level, { tag, message })
4473
}

packages/evlog/src/runtime/client/plugin.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import type { TransportConfig } from '../../types'
12
import { initLog } from './log'
23
import { defineNuxtPlugin, useRuntimeConfig } from '#app'
34

45
interface EvlogPublicConfig {
56
pretty?: boolean
7+
transport?: TransportConfig
68
}
79

810
export default defineNuxtPlugin(() => {
@@ -12,5 +14,6 @@ export default defineNuxtPlugin(() => {
1214
initLog({
1315
pretty: evlogConfig?.pretty ?? import.meta.dev,
1416
service: 'client',
17+
transport: evlogConfig?.transport,
1518
})
1619
})

0 commit comments

Comments
 (0)