Skip to content

Commit 64032ad

Browse files
authored
feat: add client identity tracking and PostHog userId mapping (#78)
1 parent 8656047 commit 64032ad

9 files changed

Lines changed: 264 additions & 12 deletions

File tree

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,47 @@ export default defineNitroPlugin((nitroApp) => {
331331
})
332332
```
333333

334+
#### Client Identity
335+
336+
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.
337+
338+
```typescript
339+
// After login
340+
setIdentity({ userId: 'usr_123', orgId: 'org_456' })
341+
342+
log.info({ action: 'checkout' })
343+
// → { userId: 'usr_123', orgId: 'org_456', action: 'checkout', ... }
344+
345+
// After logout
346+
clearIdentity()
347+
```
348+
349+
Both `setIdentity` and `clearIdentity` are auto-imported by the Nuxt module.
350+
351+
Per-event fields override identity fields, so you can always pass explicit values:
352+
353+
```typescript
354+
setIdentity({ userId: 'usr_123' })
355+
log.info({ userId: 'usr_admin_override' })
356+
// → { userId: 'usr_admin_override', ... }
357+
```
358+
359+
##### Syncing identity with auth
360+
361+
Use a global route middleware to automatically sync identity with your auth state. It runs on every navigation, handling login and logout naturally:
362+
363+
```typescript [middleware/identity.global.ts]
364+
export default defineNuxtRouteMiddleware(() => {
365+
const { user } = useAuth() // better-auth, supabase, clerk, etc.
366+
367+
if (user.value) {
368+
setIdentity({ userId: user.value.id, email: user.value.email })
369+
} else {
370+
clearIdentity()
371+
}
372+
})
373+
```
374+
334375
::callout{icon="i-lucide-lightbulb" color="info"}
335376
**Tip:** Use Nuxt's `$production` override to sample only in production while keeping full visibility in development:
336377

apps/docs/content/3.adapters/4.posthog.md

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ evlog maps wide events to PostHog events:
135135

136136
| evlog Field | PostHog Field |
137137
|-------------|---------------|
138-
| `service` | `distinct_id` (default) |
138+
| `config.distinctId` or `userId` or `service` | `distinct_id` (fallback chain) |
139139
| `timestamp` | `timestamp` |
140140
| `level` | `properties.level` |
141141
| `service` | `properties.service` |
@@ -160,18 +160,30 @@ export default defineNitroPlugin((nitroApp) => {
160160

161161
### Custom Distinct ID
162162

163-
By default, `distinct_id` is set to the event's `service` name. Override it to correlate with your users:
163+
The `distinct_id` follows a fallback chain:
164+
165+
1. **`config.distinctId`** — explicit override in `createPostHogDrain()`
166+
2. **`event.userId`** — automatically picked up if present as a string
167+
3. **`event.service`** — final fallback
168+
169+
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.
170+
171+
```typescript
172+
// Client-side — identity is set once (e.g. after login)
173+
setIdentity({ userId: 'usr_123' })
174+
175+
// Every log now includes userId
176+
log.info({ action: 'checkout' })
177+
// → PostHog event with distinct_id: 'usr_123'
178+
```
179+
180+
To override `distinct_id` for all events regardless of `userId`, pass `distinctId` to the drain:
164181

165182
```typescript [server/plugins/evlog-drain.ts]
166183
export default defineNitroPlugin((nitroApp) => {
167-
nitroApp.hooks.hook('evlog:drain', async (ctx) => {
168-
const posthogDrain = createPostHogDrain({
169-
// Use the user ID from the wide event if available
170-
distinctId: ctx.event.userId as string ?? ctx.event.service,
171-
})
172-
173-
await posthogDrain(ctx)
174-
})
184+
nitroApp.hooks.hook('evlog:drain', createPostHogDrain({
185+
distinctId: 'my-backend-service', // Always uses this value
186+
}))
175187
})
176188
```
177189

apps/playground/app/config/tests.config.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,55 @@ export const testConfig = {
9999
},
100100
],
101101
} as TestSection,
102+
{
103+
id: 'identity',
104+
label: 'Identity',
105+
icon: 'i-lucide-user',
106+
title: 'Client Identity',
107+
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.',
108+
layout: 'cards',
109+
tests: [
110+
{
111+
id: 'identity-set',
112+
label: 'setIdentity()',
113+
description: 'Sets userId and orgId on all future client logs. Open the console and check the transport payload.',
114+
color: 'primary',
115+
badge: {
116+
label: 'setIdentity',
117+
color: 'blue',
118+
},
119+
},
120+
{
121+
id: 'identity-log',
122+
label: 'log.info() with identity',
123+
description: 'Emits a log — identity fields (userId, orgId) are automatically included.',
124+
badge: {
125+
label: 'Auto-enriched',
126+
color: 'green',
127+
},
128+
},
129+
{
130+
id: 'identity-override',
131+
label: 'Override userId',
132+
description: 'Per-event fields take priority over identity. This log overrides userId.',
133+
color: 'warning',
134+
badge: {
135+
label: 'Event > Identity',
136+
color: 'warning',
137+
},
138+
},
139+
{
140+
id: 'identity-clear',
141+
label: 'clearIdentity()',
142+
description: 'Clears identity context. Future logs will no longer include userId/orgId.',
143+
color: 'error',
144+
badge: {
145+
label: 'clearIdentity',
146+
color: 'red',
147+
},
148+
},
149+
],
150+
} as TestSection,
102151
{
103152
id: 'wide-events',
104153
label: 'Wide Events',

apps/playground/app/pages/index.vue

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,25 @@ function handleBrowserDrainBeacon() {
9494
}
9595
}
9696
97+
// Identity tests
98+
function handleIdentitySet() {
99+
setIdentity({ userId: 'usr_123', orgId: 'org_456' })
100+
log.info({ action: 'identity_set', message: 'Identity set to usr_123 / org_456' })
101+
}
102+
103+
function handleIdentityLog() {
104+
log.info({ action: 'checkout', item: 'pro_plan' })
105+
}
106+
107+
function handleIdentityOverride() {
108+
log.info({ action: 'impersonate', userId: 'usr_admin_override' })
109+
}
110+
111+
function handleIdentityClear() {
112+
clearIdentity()
113+
log.info({ action: 'identity_cleared', message: 'Identity context cleared' })
114+
}
115+
97116
// Get custom onClick for specific tests
98117
function getOnClick(testId: string) {
99118
if (testId === 'structured-error-toast') {
@@ -114,6 +133,18 @@ function getOnClick(testId: string) {
114133
if (testId === 'browser-drain-beacon') {
115134
return handleBrowserDrainBeacon
116135
}
136+
if (testId === 'identity-set') {
137+
return handleIdentitySet
138+
}
139+
if (testId === 'identity-log') {
140+
return handleIdentityLog
141+
}
142+
if (testId === 'identity-override') {
143+
return handleIdentityOverride
144+
}
145+
if (testId === 'identity-clear') {
146+
return handleIdentityClear
147+
}
117148
return undefined
118149
}
119150
</script>

packages/evlog/src/adapters/posthog.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export function toPostHogEvent(event: WideEvent, config: PostHogConfig): PostHog
3030

3131
return {
3232
event: config.eventName ?? 'evlog_wide_event',
33-
distinct_id: config.distinctId ?? service,
33+
distinct_id: config.distinctId ?? (typeof event.userId === 'string' ? event.userId : undefined) ?? service,
3434
timestamp,
3535
properties: {
3636
level,

packages/evlog/src/nuxt/module.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,14 @@ export default defineNuxtModule<ModuleOptions>({
242242
name: 'log',
243243
from: resolver.resolve('../runtime/client/log'),
244244
},
245+
{
246+
name: 'setIdentity',
247+
from: resolver.resolve('../runtime/client/log'),
248+
},
249+
{
250+
name: 'clearIdentity',
251+
from: resolver.resolve('../runtime/client/log'),
252+
},
245253
{
246254
name: 'createEvlogError',
247255
from: resolver.resolve('../error'),

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ let clientPretty = true
88
let clientService = 'client'
99
let transportEnabled = false
1010
let transportEndpoint = '/api/_evlog/ingest'
11+
let identityContext: Record<string, unknown> = {}
12+
13+
export function setIdentity(identity: Record<string, unknown>): void {
14+
identityContext = { ...identity }
15+
}
16+
17+
export function clearIdentity(): void {
18+
identityContext = {}
19+
}
1120

1221
const LEVEL_COLORS: Record<string, string> = {
1322
error: 'color: #ef4444; font-weight: bold',
@@ -47,6 +56,7 @@ function emitLog(level: LogLevel, event: Record<string, unknown>): void {
4756
timestamp: new Date().toISOString(),
4857
level,
4958
service: clientService,
59+
...identityContext,
5060
...event,
5161
}
5262

@@ -70,6 +80,7 @@ function emitTaggedLog(level: LogLevel, tag: string, message: string): void {
7080
timestamp: new Date().toISOString(),
7181
level,
7282
service: clientService,
83+
...identityContext,
7384
tag,
7485
message,
7586
})

packages/evlog/test/adapters/posthog.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,27 @@ describe('posthog adapter', () => {
8282

8383
expect(result.properties.environment).toBe('production')
8484
})
85+
86+
it('uses userId as distinct_id when no config distinctId', () => {
87+
const event = createTestEvent({ userId: 'usr_123' })
88+
const result = toPostHogEvent(event, { apiKey: 'phc_test' })
89+
90+
expect(result.distinct_id).toBe('usr_123')
91+
})
92+
93+
it('config distinctId takes priority over event userId', () => {
94+
const event = createTestEvent({ userId: 'usr_123' })
95+
const result = toPostHogEvent(event, { apiKey: 'phc_test', distinctId: 'config-id' })
96+
97+
expect(result.distinct_id).toBe('config-id')
98+
})
99+
100+
it('falls back to service when userId is not a string', () => {
101+
const event = createTestEvent({ userId: 42 })
102+
const result = toPostHogEvent(event, { apiKey: 'phc_test' })
103+
104+
expect(result.distinct_id).toBe('test-service')
105+
})
85106
})
86107

87108
describe('sendToPostHog', () => {
@@ -169,7 +190,7 @@ describe('posthog adapter', () => {
169190
const body = JSON.parse(options.body as string)
170191
expect(body.batch).toHaveLength(1)
171192
expect(body.batch[0].event).toBe('evlog_wide_event')
172-
expect(body.batch[0].distinct_id).toBe('test-service')
193+
expect(body.batch[0].distinct_id).toBe('123')
173194
expect(body.batch[0].properties.action).toBe('test-action')
174195
})
175196

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// @vitest-environment happy-dom
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3+
import { clearIdentity, initLog, log, setIdentity } from '../src/runtime/client/log'
4+
5+
describe('client identity', () => {
6+
let fetchSpy: ReturnType<typeof vi.spyOn>
7+
8+
beforeEach(() => {
9+
fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 200 }))
10+
initLog({ enabled: true, transport: { enabled: true, endpoint: '/api/_evlog/ingest' } })
11+
clearIdentity()
12+
})
13+
14+
afterEach(() => {
15+
vi.restoreAllMocks()
16+
clearIdentity()
17+
})
18+
19+
function getLastSentEvent(): Record<string, unknown> {
20+
const [, options] = fetchSpy.mock.calls[fetchSpy.mock.calls.length - 1] as [string, RequestInit]
21+
return JSON.parse(options.body as string)
22+
}
23+
24+
it('includes identity fields in emitted events', () => {
25+
setIdentity({ userId: 'usr_123' })
26+
log.info({ action: 'click' })
27+
28+
const event = getLastSentEvent()
29+
expect(event.userId).toBe('usr_123')
30+
expect(event.action).toBe('click')
31+
})
32+
33+
it('clearIdentity removes identity fields', () => {
34+
setIdentity({ userId: 'usr_123' })
35+
clearIdentity()
36+
log.info({ action: 'click' })
37+
38+
const event = getLastSentEvent()
39+
expect(event.userId).toBeUndefined()
40+
})
41+
42+
it('per-event fields override identity fields', () => {
43+
setIdentity({ userId: 'usr_123' })
44+
log.info({ userId: 'usr_override' })
45+
46+
const event = getLastSentEvent()
47+
expect(event.userId).toBe('usr_override')
48+
})
49+
50+
it('identity works with tagged logs', () => {
51+
setIdentity({ userId: 'usr_123' })
52+
log.info('auth', 'user logged in')
53+
54+
const event = getLastSentEvent()
55+
expect(event.userId).toBe('usr_123')
56+
expect(event.tag).toBe('auth')
57+
expect(event.message).toBe('user logged in')
58+
})
59+
60+
it('supports multiple identity fields', () => {
61+
setIdentity({ userId: 'usr_123', orgId: 'org_456', role: 'admin' })
62+
log.info({ action: 'click' })
63+
64+
const event = getLastSentEvent()
65+
expect(event.userId).toBe('usr_123')
66+
expect(event.orgId).toBe('org_456')
67+
expect(event.role).toBe('admin')
68+
})
69+
70+
it('setIdentity replaces previous identity', () => {
71+
setIdentity({ userId: 'usr_123', orgId: 'org_456' })
72+
setIdentity({ userId: 'usr_789' })
73+
log.info({ action: 'click' })
74+
75+
const event = getLastSentEvent()
76+
expect(event.userId).toBe('usr_789')
77+
expect(event.orgId).toBeUndefined()
78+
})
79+
})

0 commit comments

Comments
 (0)