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
41 changes: 41 additions & 0 deletions apps/docs/content/1.getting-started/2.installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,47 @@ export default defineNitroPlugin((nitroApp) => {
})
```

#### Client Identity

Attach user identity to all client logs with `setIdentity()`. Identity fields are automatically included in every log and transported to the server, where all drains (Axiom, PostHog, Sentry, etc.) receive them.

```typescript
// After login
setIdentity({ userId: 'usr_123', orgId: 'org_456' })

log.info({ action: 'checkout' })
// → { userId: 'usr_123', orgId: 'org_456', action: 'checkout', ... }

// After logout
clearIdentity()
```

Both `setIdentity` and `clearIdentity` are auto-imported by the Nuxt module.

Per-event fields override identity fields, so you can always pass explicit values:

```typescript
setIdentity({ userId: 'usr_123' })
log.info({ userId: 'usr_admin_override' })
// → { userId: 'usr_admin_override', ... }
```

##### Syncing identity with auth

Use a global route middleware to automatically sync identity with your auth state. It runs on every navigation, handling login and logout naturally:

```typescript [middleware/identity.global.ts]
export default defineNuxtRouteMiddleware(() => {
const { user } = useAuth() // better-auth, supabase, clerk, etc.

if (user.value) {
setIdentity({ userId: user.value.id, email: user.value.email })
} else {
clearIdentity()
}
})
```

::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
32 changes: 22 additions & 10 deletions apps/docs/content/3.adapters/4.posthog.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ evlog maps wide events to PostHog events:

| evlog Field | PostHog Field |
|-------------|---------------|
| `service` | `distinct_id` (default) |
| `config.distinctId` or `userId` or `service` | `distinct_id` (fallback chain) |
| `timestamp` | `timestamp` |
| `level` | `properties.level` |
| `service` | `properties.service` |
Expand All @@ -160,18 +160,30 @@ export default defineNitroPlugin((nitroApp) => {

### Custom Distinct ID

By default, `distinct_id` is set to the event's `service` name. Override it to correlate with your users:
The `distinct_id` follows a fallback chain:

1. **`config.distinctId`** — explicit override in `createPostHogDrain()`
2. **`event.userId`** — automatically picked up if present as a string
3. **`event.service`** — final fallback

This means if you use `setIdentity({ userId: 'usr_123' })` on the client, the `userId` flows through client transport → server ingest → PostHog drain, and is automatically used as `distinct_id`. No additional configuration needed.

```typescript
// Client-side — identity is set once (e.g. after login)
setIdentity({ userId: 'usr_123' })

// Every log now includes userId
log.info({ action: 'checkout' })
// → PostHog event with distinct_id: 'usr_123'
```

To override `distinct_id` for all events regardless of `userId`, pass `distinctId` to the drain:

```typescript [server/plugins/evlog-drain.ts]
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('evlog:drain', async (ctx) => {
const posthogDrain = createPostHogDrain({
// Use the user ID from the wide event if available
distinctId: ctx.event.userId as string ?? ctx.event.service,
})

await posthogDrain(ctx)
})
nitroApp.hooks.hook('evlog:drain', createPostHogDrain({
distinctId: 'my-backend-service', // Always uses this value
}))
})
```

Expand Down
49 changes: 49 additions & 0 deletions apps/playground/app/config/tests.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,55 @@ export const testConfig = {
},
],
} as TestSection,
{
id: 'identity',
label: 'Identity',
icon: 'i-lucide-user',
title: 'Client Identity',
description: 'Attach user identity to all client logs via setIdentity(). Identity fields are included in every log and transported to the server. PostHog auto-maps userId → distinct_id.',
layout: 'cards',
tests: [
{
id: 'identity-set',
label: 'setIdentity()',
description: 'Sets userId and orgId on all future client logs. Open the console and check the transport payload.',
color: 'primary',
badge: {
label: 'setIdentity',
color: 'blue',
},
},
{
id: 'identity-log',
label: 'log.info() with identity',
description: 'Emits a log — identity fields (userId, orgId) are automatically included.',
badge: {
label: 'Auto-enriched',
color: 'green',
},
},
{
id: 'identity-override',
label: 'Override userId',
description: 'Per-event fields take priority over identity. This log overrides userId.',
color: 'warning',
badge: {
label: 'Event > Identity',
color: 'warning',
},
},
{
id: 'identity-clear',
label: 'clearIdentity()',
description: 'Clears identity context. Future logs will no longer include userId/orgId.',
color: 'error',
badge: {
label: 'clearIdentity',
color: 'red',
},
},
],
} as TestSection,
{
id: 'wide-events',
label: 'Wide Events',
Expand Down
31 changes: 31 additions & 0 deletions apps/playground/app/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,25 @@ function handleBrowserDrainBeacon() {
}
}

// Identity tests
function handleIdentitySet() {
setIdentity({ userId: 'usr_123', orgId: 'org_456' })
log.info({ action: 'identity_set', message: 'Identity set to usr_123 / org_456' })
}

function handleIdentityLog() {
log.info({ action: 'checkout', item: 'pro_plan' })
}

function handleIdentityOverride() {
log.info({ action: 'impersonate', userId: 'usr_admin_override' })
}

function handleIdentityClear() {
clearIdentity()
log.info({ action: 'identity_cleared', message: 'Identity context cleared' })
}

// Get custom onClick for specific tests
function getOnClick(testId: string) {
if (testId === 'structured-error-toast') {
Expand All @@ -114,6 +133,18 @@ function getOnClick(testId: string) {
if (testId === 'browser-drain-beacon') {
return handleBrowserDrainBeacon
}
if (testId === 'identity-set') {
return handleIdentitySet
}
if (testId === 'identity-log') {
return handleIdentityLog
}
if (testId === 'identity-override') {
return handleIdentityOverride
}
if (testId === 'identity-clear') {
return handleIdentityClear
}
return undefined
}
</script>
Expand Down
2 changes: 1 addition & 1 deletion packages/evlog/src/adapters/posthog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export function toPostHogEvent(event: WideEvent, config: PostHogConfig): PostHog

return {
event: config.eventName ?? 'evlog_wide_event',
distinct_id: config.distinctId ?? service,
distinct_id: config.distinctId ?? (typeof event.userId === 'string' ? event.userId : undefined) ?? service,
timestamp,
properties: {
level,
Expand Down
8 changes: 8 additions & 0 deletions packages/evlog/src/nuxt/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,14 @@ export default defineNuxtModule<ModuleOptions>({
name: 'log',
from: resolver.resolve('../runtime/client/log'),
},
{
name: 'setIdentity',
from: resolver.resolve('../runtime/client/log'),
},
{
name: 'clearIdentity',
from: resolver.resolve('../runtime/client/log'),
},
{
name: 'createEvlogError',
from: resolver.resolve('../error'),
Expand Down
11 changes: 11 additions & 0 deletions packages/evlog/src/runtime/client/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ let clientPretty = true
let clientService = 'client'
let transportEnabled = false
let transportEndpoint = '/api/_evlog/ingest'
let identityContext: Record<string, unknown> = {}

export function setIdentity(identity: Record<string, unknown>): void {
identityContext = { ...identity }
}

export function clearIdentity(): void {
identityContext = {}
}

const LEVEL_COLORS: Record<string, string> = {
error: 'color: #ef4444; font-weight: bold',
Expand Down Expand Up @@ -47,6 +56,7 @@ function emitLog(level: LogLevel, event: Record<string, unknown>): void {
timestamp: new Date().toISOString(),
level,
service: clientService,
...identityContext,
...event,
}

Expand All @@ -70,6 +80,7 @@ function emitTaggedLog(level: LogLevel, tag: string, message: string): void {
timestamp: new Date().toISOString(),
level,
service: clientService,
...identityContext,
tag,
message,
})
Expand Down
23 changes: 22 additions & 1 deletion packages/evlog/test/adapters/posthog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,27 @@ describe('posthog adapter', () => {

expect(result.properties.environment).toBe('production')
})

it('uses userId as distinct_id when no config distinctId', () => {
const event = createTestEvent({ userId: 'usr_123' })
const result = toPostHogEvent(event, { apiKey: 'phc_test' })

expect(result.distinct_id).toBe('usr_123')
})

it('config distinctId takes priority over event userId', () => {
const event = createTestEvent({ userId: 'usr_123' })
const result = toPostHogEvent(event, { apiKey: 'phc_test', distinctId: 'config-id' })

expect(result.distinct_id).toBe('config-id')
})

it('falls back to service when userId is not a string', () => {
const event = createTestEvent({ userId: 42 })
const result = toPostHogEvent(event, { apiKey: 'phc_test' })

expect(result.distinct_id).toBe('test-service')
})
})

describe('sendToPostHog', () => {
Expand Down Expand Up @@ -169,7 +190,7 @@ describe('posthog adapter', () => {
const body = JSON.parse(options.body as string)
expect(body.batch).toHaveLength(1)
expect(body.batch[0].event).toBe('evlog_wide_event')
expect(body.batch[0].distinct_id).toBe('test-service')
expect(body.batch[0].distinct_id).toBe('123')
expect(body.batch[0].properties.action).toBe('test-action')
})

Expand Down
79 changes: 79 additions & 0 deletions packages/evlog/test/identity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { clearIdentity, initLog, log, setIdentity } from '../src/runtime/client/log'

describe('client identity', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>

beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 200 }))
initLog({ enabled: true, transport: { enabled: true, endpoint: '/api/_evlog/ingest' } })
clearIdentity()
})

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

function getLastSentEvent(): Record<string, unknown> {
const [, options] = fetchSpy.mock.calls[fetchSpy.mock.calls.length - 1] as [string, RequestInit]
return JSON.parse(options.body as string)
}

it('includes identity fields in emitted events', () => {
setIdentity({ userId: 'usr_123' })
log.info({ action: 'click' })

const event = getLastSentEvent()
expect(event.userId).toBe('usr_123')
expect(event.action).toBe('click')
})

it('clearIdentity removes identity fields', () => {
setIdentity({ userId: 'usr_123' })
clearIdentity()
log.info({ action: 'click' })

const event = getLastSentEvent()
expect(event.userId).toBeUndefined()
})

it('per-event fields override identity fields', () => {
setIdentity({ userId: 'usr_123' })
log.info({ userId: 'usr_override' })

const event = getLastSentEvent()
expect(event.userId).toBe('usr_override')
})

it('identity works with tagged logs', () => {
setIdentity({ userId: 'usr_123' })
log.info('auth', 'user logged in')

const event = getLastSentEvent()
expect(event.userId).toBe('usr_123')
expect(event.tag).toBe('auth')
expect(event.message).toBe('user logged in')
})

it('supports multiple identity fields', () => {
setIdentity({ userId: 'usr_123', orgId: 'org_456', role: 'admin' })
log.info({ action: 'click' })

const event = getLastSentEvent()
expect(event.userId).toBe('usr_123')
expect(event.orgId).toBe('org_456')
expect(event.role).toBe('admin')
})

it('setIdentity replaces previous identity', () => {
setIdentity({ userId: 'usr_123', orgId: 'org_456' })
setIdentity({ userId: 'usr_789' })
log.info({ action: 'click' })

const event = getLastSentEvent()
expect(event.userId).toBe('usr_789')
expect(event.orgId).toBeUndefined()
})
})
Loading