Better Auth Integration
evlog/better-auth turns anonymous wide events into identified ones. Every request automatically includes who made it — no manual log.set({ user }) needed.
Add Better Auth user identification to my app with evlog.
- Import createAuthMiddleware from 'evlog/better-auth'
- Call createAuthMiddleware(auth) to get an identify function
- Call identify(log, headers, path) in your middleware/hook to auto-identify users on every request
- Safe by default — only extracts whitelisted fields, never logs passwords or tokens
- Supports include/exclude route patterns, lifecycle hooks, and Better Auth plugin fields
- Works with all frameworks: Nuxt, Next.js, Express, Hono, Fastify, NestJS, Elysia, standalone
Docs: https://www.evlog.dev/logging/better-auth
Adapters: https://www.evlog.dev/adapters
Quick Start
One middleware, all requests identified:
import { createAuthMiddleware } from 'evlog/better-auth'
const identify = createAuthMiddleware(auth, {
exclude: ['/api/auth/**'],
})
export default defineEventHandler(async (event) => {
if (!event.context.log) return
await identify(event.context.log, event.headers, event.path)
})
import { withEvlog, useLogger } from '@/lib/evlog'
import { createAuthMiddleware } from 'evlog/better-auth'
import { auth } from '@/lib/auth'
const identify = createAuthMiddleware(auth)
export const POST = withEvlog(async (request: Request) => {
const log = useLogger()
await identify(log, request.headers)
log.set({ action: 'checkout' })
return Response.json({ success: true })
})
import { createAuthMiddleware } from 'evlog/better-auth'
const identify = createAuthMiddleware(auth, {
exclude: ['/api/auth/**'],
})
app.use(async (req, res, next) => {
await identify(req.log, req.headers, req.path)
next()
})
import { createAuthMiddleware } from 'evlog/better-auth'
const identify = createAuthMiddleware(auth, {
exclude: ['/api/auth/**'],
})
app.use(async (c, next) => {
await identify(c.get('log'), c.req.raw.headers, c.req.path)
await next()
})
import { createAuthMiddleware } from 'evlog/better-auth'
const identify = createAuthMiddleware(auth, {
exclude: ['/api/auth/**'],
})
app.addHook('onRequest', async (request) => {
await identify(request.log, request.headers, request.url)
})
import { createAuthMiddleware } from 'evlog/better-auth'
const identify = createAuthMiddleware(auth, {
exclude: ['/api/auth/**'],
})
app.derive(async ({ log, request }) => {
await identify(log, request.headers, new URL(request.url).pathname)
return {}
})
import { createAuthMiddleware } from 'evlog/better-auth'
import { useLogger } from 'evlog/nestjs'
const identify = createAuthMiddleware(auth, {
exclude: ['/api/auth/**'],
})
@Injectable()
export class AuthIdentifyMiddleware implements NestMiddleware {
async use(req: Request, res: Response, next: NextFunction) {
await identify(useLogger(), req.headers, req.path)
next()
}
}
import { identifyUser } from 'evlog/better-auth'
import { createLogger } from 'evlog'
const log = createLogger()
const session = await auth.api.getSession({ headers })
if (session) identifyUser(log, session)
log.emit()
Your wide event now includes the user:
{
"level": "info",
"method": "POST",
"path": "/api/checkout",
"status": 200,
"duration": "120ms",
"requestId": "a5669202-7765-4f59-b6f0-b9f40ce71599",
"cart": { "items": 3, "total": 9999 }
}
{
"level": "info",
"method": "POST",
"path": "/api/checkout",
"status": 200,
"duration": "120ms",
"requestId": "a5669202-7765-4f59-b6f0-b9f40ce71599",
"userId": "QBX9tPjJQExWawAbNll75",
"user": {
"id": "QBX9tPjJQExWawAbNll75",
"name": "Hugo Richard",
"email": "hugo@example.com",
"emailVerified": true,
"createdAt": "2024-01-15T10:00:00.000Z"
},
"session": {
"id": "Xhmh6TxKJQrVKFX0Y0II",
"expiresAt": "2024-01-22T10:00:00.000Z",
"ipAddress": "192.168.1.42",
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
"createdAt": "2024-01-15T10:00:00.000Z"
},
"auth": {
"resolvedIn": 12,
"identified": true
},
"cart": { "items": 3, "total": 9999 }
}
How It Works
The integration resolves the Better Auth session from request cookies, extracts safe user and session fields, and sets them on the logger. Auth routes are skipped by default.
Request → Middleware → Better Auth → Logger → Drain
- Incoming request hits your middleware
- Middleware checks
include/excludepatterns — skips if route doesn't match getSession(headers)resolves the session via Better Auth (timing is captured)identifyUser(log, session)setsuserId,user, andsessionon the loggeronIdentifyoronAnonymoushook fires based on session result- At request end, the wide event is emitted with full user context to your drain
| Export | Description |
|---|---|
identifyUser(log, session) | Core helper — extracts safe fields from a session and sets them on the logger. Returns true if identified |
createAuthMiddleware(auth) | Returns an async (log, headers, path?) => Promise<boolean> function with route filtering, timing, and hooks |
createAuthIdentifier(auth) | Nitro request hook factory for standalone Nitro apps |
maskEmail(email) | Mask an email: hugo@example.com → h***@example.com |
identifyUser
The core building block. Takes a RequestLogger and a Better Auth session, extracts safe fields, and calls log.set(). Returns true if the user was identified, false otherwise:
import { identifyUser } from 'evlog/better-auth'
const session = await auth.api.getSession({ headers: event.headers })
if (session) {
const identified = identifyUser(log, session)
if (identified) {
log.set({ subscription: 'premium' })
}
}
Safe by default — only extracts whitelisted fields. Passwords, tokens, and secrets are never included.
Options
| Option | Type | Default | Description |
|---|---|---|---|
maskEmail | boolean | false | Mask emails as h***@example.com |
session | boolean | true | Include session metadata (session.id, session.expiresAt, session.ipAddress, session.userAgent) |
fields | string[] | ['id', 'name', 'email', 'image', 'emailVerified', 'createdAt'] | User fields to extract |
extend | (session) => Record<string, unknown> | undefined | Add custom fields from Better Auth plugins (organizations, roles, etc.) |
identifyUser(log, session, {
maskEmail: true,
fields: ['id', 'name'],
session: false,
})
extend
Use extend to capture fields added by Better Auth plugins (organizations, 2FA, roles, etc.):
import { createAuthMiddleware } from 'evlog/better-auth'
const identify = createAuthMiddleware(auth, {
extend: (session) => ({
organization: session.user.activeOrganization,
role: session.user.role,
}),
})
Wide event with plugin fields:
{
"userId": "QBX9tPjJQExWawAbNll75",
"user": { "id": "QBX9tPjJQExWawAbNll75", "name": "Hugo Richard" },
"organization": { "id": "org_42", "name": "Acme" },
"role": "admin"
}
createAuthMiddleware
Framework-agnostic factory. Call it once at startup, then use the returned function in your middleware. The third argument path enables built-in route filtering:
import { createAuthMiddleware } from 'evlog/better-auth'
const identify = createAuthMiddleware(auth, {
exclude: ['/api/auth/**', '/api/public/**'],
include: ['/api/**'],
maskEmail: true,
})
The function signature is (log, headers, path?) => Promise<boolean>. It resolves the session, calls identifyUser, captures timing, fires lifecycle hooks, and silently catches errors so session resolution never breaks a request.
Options
Inherits all identifyUser options, plus:
| Option | Type | Default | Description |
|---|---|---|---|
exclude | string[] | ['/api/auth/**'] | Route patterns to skip (glob) |
include | string[] | undefined | If set, only matching routes are resolved |
onIdentify | (log, session) => void | undefined | Called after successful identification |
onAnonymous | (log) => void | undefined | Called when no session is found |
Lifecycle Hooks
Use onIdentify to react to user identification — for example, force-keep logs for premium users via tail sampling:
const identify = createAuthMiddleware(auth, {
onIdentify: (log, session) => {
if (session.user.plan === 'enterprise') {
log.set({ _forceKeep: true })
}
},
onAnonymous: (log) => {
log.set({ anonymous: true })
},
})
createAuthIdentifier (Standalone Nitro)
A factory that creates a Nitro request hook. Designed for standalone Nitro apps where the evlog Nitro module handles hook ordering.
createAuthMiddleware in a server middleware instead — Nitro plugin hook ordering can cause the logger to not be available yet in the request hook.import { createAuthIdentifier } from 'evlog/better-auth'
import { auth } from './lib/auth'
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('request', createAuthIdentifier(auth, {
exclude: ['/api/auth/**', '/api/public/**'],
}))
})
Performance
getSession() costs a database query on every request. The auth.resolvedIn field in your wide event tells you exactly how long each resolution takes. For high-traffic apps:
- Enable session caching in Better Auth to avoid hitting the database on every request
- Use
excludeto skip public routes that don't need user context - Use
includeto limit resolution to specific route patterns
{
"auth": { "resolvedIn": 245, "identified": true },
"duration": "312ms"
}
When auth.resolvedIn is high relative to duration, enable session caching in Better Auth.
Client Identity Sync
On the client side, watch the Better Auth session and call setIdentity() to include user context in client-side logs:
import { authClient } from '~/lib/auth-client'
export function useAuthIdentity() {
const session = authClient.useSession()
watch(() => session.value?.data?.user, (user) => {
if (user) {
setIdentity({ userId: user.id, userName: user.name })
} else {
clearIdentity()
}
}, { immediate: true })
}
Call it once in your root layout:
<script setup>
useAuthIdentity()
</script>
Client-side logs now include the user identity:
{
"level": "info",
"tag": "checkout",
"message": "User clicked checkout",
"userId": "QBX9tPjJQExWawAbNll75",
"userName": "Hugo Richard"
}
Wide Event Fields
| Field | Source | Description |
|---|---|---|
userId | session.user.id | Top-level user ID (used by PostHog adapter as distinct_id) |
user.id | session.user.id | User ID |
user.name | session.user.name | Display name |
user.email | session.user.email | Email (maskable with maskEmail: true) |
user.image | session.user.image | Avatar URL |
user.emailVerified | session.user.emailVerified | Email verification status |
user.createdAt | session.user.createdAt | Account creation date (ISO string) |
session.id | session.session.id | Session ID |
session.expiresAt | session.session.expiresAt | Session expiry (ISO string) |
session.ipAddress | session.session.ipAddress | Client IP from the session |
session.userAgent | session.session.userAgent | User agent string from the session |
session.createdAt | session.session.createdAt | Session creation date (ISO string) |
auth.resolvedIn | Measured | Session resolution time in ms |
auth.identified | Computed | Whether the request was identified |
Works With the AI SDK
When combined with evlog/ai, your wide events include both user identity and AI metrics in a single event:
{
"method": "POST",
"path": "/api/chat",
"status": 200,
"duration": "4.5s",
"userId": "QBX9tPjJQExWawAbNll75",
"user": {
"id": "QBX9tPjJQExWawAbNll75",
"name": "Hugo Richard",
"email": "hugo@example.com"
},
"auth": { "resolvedIn": 8, "identified": true },
"ai": {
"calls": 1,
"model": "claude-sonnet-4.6",
"provider": "anthropic",
"inputTokens": 3312,
"outputTokens": 814,
"totalTokens": 4126,
"msToFirstChunk": 234,
"msToFinish": 4500,
"tokensPerSecond": 180
}
}
This is the power of wide events — one event per request, all context in one place: who made the request, what they did, how the AI responded, and how it performed.
AI SDK Integration
Capture token usage, tool calls, model info, and streaming metrics from the Vercel AI SDK into wide events. Wrap your model and get full AI observability.
Lifecycle
Understand the full lifecycle of an evlog event, from creation to drain. Covers all three modes (simple logging, wide events, request logging), sampling, enrichment, and delivery.