---
title: "PostHog Adapter"
description: "Send wide events to PostHog Logs via OTLP for structured log querying, debugging, and observability in your PostHog dashboard."
canonical_url: "https://www.evlog.dev/integrate/adapters/cloud/posthog"
---
# PostHog Adapter

> Send wide events to PostHog Logs via OTLP for structured log querying, debugging, and observability in your PostHog dashboard.

[PostHog](https://posthog.com) is an open-source product analytics platform. The evlog PostHog adapter sends your wide events to [PostHog Logs](https://posthog.com/docs/logs) via the standard OTLP format, giving you a dedicated log viewer with filtering, search, and tail mode using your existing PostHog API key.

<prompt :actions="["copy","cursor","claude"]" description="Add the PostHog drain adapter" icon="i-simple-icons-posthog">

Add the PostHog drain adapter to send evlog wide events to PostHog Logs.

1. Identify which framework I'm using and follow its evlog integration pattern
2. Install evlog if not already installed
3. Import createPostHogDrain from 'evlog/posthog'
4. Wire createPostHogDrain() into my framework's drain configuration
5. Set POSTHOG_API_KEY environment variable
6. Optionally set POSTHOG_HOST for EU or self-hosted instances
7. Test by triggering a request and checking PostHog > Logs

Adapter docs: [https://www.evlog.dev/integrate/adapters/cloud/posthog](https://www.evlog.dev/integrate/adapters/cloud/posthog)
Framework setup: [https://www.evlog.dev/integrate/frameworks/overview](https://www.evlog.dev/integrate/frameworks/overview)

</prompt>

## Installation

The PostHog adapter comes bundled with evlog:

```typescript [src/index.ts]
import { createPostHogDrain } from 'evlog/posthog'
```

## Quick Start

### 1. Get your PostHog project API key

1. Log in to your [PostHog dashboard](https://app.posthog.com)
2. Go to **Settings** > **Project** > **Project API Key**
3. Copy the key (starts with `phc_`)

### 2. Set environment variables

```bash [.env]
POSTHOG_API_KEY=phc_your-project-api-key
```

### 3. Wire the drain to your framework

<framework-tabs>

```typescript [Nuxt]server/plugins/evlog-drain.ts
import { createPostHogDrain } from 'evlog/posthog'

export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:drain', createPostHogDrain())
})
```

```typescript [Nitro]server/plugins/evlog-drain.ts
import { definePlugin } from 'nitro'
import { createPostHogDrain } from 'evlog/posthog'

export default definePlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:drain', createPostHogDrain())
})
```

```typescript [Next.js]lib/evlog.ts
import { createEvlog } from 'evlog/next'
import { createPostHogDrain } from 'evlog/posthog'

export const { withEvlog, useLogger, log, createError } = createEvlog({
  service: 'my-app',
  drain: createPostHogDrain(),
})
```

```typescript [SvelteKit]src/hooks.server.ts
import { createEvlogHooks } from 'evlog/sveltekit'
import { createPostHogDrain } from 'evlog/posthog'

export const { handle, handleError } = createEvlogHooks({ drain: createPostHogDrain() })
```

```typescript [TanStack Start]server/plugins/evlog-drain.ts
import { definePlugin } from 'nitro'
import { createPostHogDrain } from 'evlog/posthog'

export default definePlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:drain', createPostHogDrain())
})
```

```typescript [React Router]app/root.tsx
import { evlog } from 'evlog/react-router'
import { createPostHogDrain } from 'evlog/posthog'

export const middleware: Route.MiddlewareFunction[] = [
  evlog({ drain: createPostHogDrain() }),
]
```

```typescript [Hono]src/index.ts
import { createPostHogDrain } from 'evlog/posthog'

app.use(evlog({ drain: createPostHogDrain() }))
```

```typescript [Express]src/index.ts
import { createPostHogDrain } from 'evlog/posthog'

app.use(evlog({ drain: createPostHogDrain() }))
```

```typescript [Fastify]src/index.ts
import { createPostHogDrain } from 'evlog/posthog'

await app.register(evlog, { drain: createPostHogDrain() })
```

```typescript [Elysia]src/index.ts
import { createPostHogDrain } from 'evlog/posthog'

app.use(evlog({ drain: createPostHogDrain() }))
```

```typescript [NestJS]src/app.module.ts
import { createPostHogDrain } from 'evlog/posthog'

EvlogModule.forRoot({ drain: createPostHogDrain() })
```

```typescript [oRPC]server/orpc.ts
import { withEvlog } from 'evlog/orpc'
import { createPostHogDrain } from 'evlog/posthog'

const handler = withEvlog(new RPCHandler(router), { drain: createPostHogDrain() })
```

```typescript [Cloudflare Workers]src/worker.ts
import { withEvlog } from 'evlog/workers'
import { createPostHogDrain } from 'evlog/posthog'

export default withEvlog(handler, { drain: createPostHogDrain() })
```

```typescript [Standalone]
import { createPostHogDrain } from 'evlog/posthog'

initLogger({ drain: createPostHogDrain() })
```

</framework-tabs>

That's it! Your wide events will now appear in PostHog Logs with full OTLP structure including severity levels, trace context, and structured attributes.

## Configuration

The adapter reads configuration from multiple sources (highest priority first):

1. **Overrides** passed to `createPostHogDrain()`
2. **Runtime config** at `runtimeConfig.posthog` (Nuxt/Nitro only)
3. **Environment variables** (`POSTHOG_*`)

### Environment Variables

<table>
<thead>
  <tr>
    <th>
      Variable
    </th>
    
    <th>
      Description
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        POSTHOG_API_KEY
      </code>
    </td>
    
    <td>
      Project API key (starts with <code>
        phc_
      </code>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        POSTHOG_HOST
      </code>
    </td>
    
    <td>
      PostHog host URL (for EU or self-hosted)
    </td>
  </tr>
</tbody>
</table>

### Runtime Config (Nuxt only)

Configure via `nuxt.config.ts` for type-safe configuration:

```typescript [nuxt.config.ts]
export default defineNuxtConfig({
  runtimeConfig: {
    posthog: {
      apiKey: '', // Set via POSTHOG_API_KEY
      host: '', // Set via POSTHOG_HOST
    },
  },
})
```

### Override Options

Pass options directly to override any configuration:

<framework-tabs>

```typescript [Nuxt]server/plugins/evlog-drain.ts
import { createPostHogDrain } from 'evlog/posthog'

export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:drain', createPostHogDrain({
    host: 'https://eu.i.posthog.com',
    timeout: 10000,
  }))
})
```

```typescript [Nitro]server/plugins/evlog-drain.ts
import { definePlugin } from 'nitro'
import { createPostHogDrain } from 'evlog/posthog'

export default definePlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:drain', createPostHogDrain({
    host: 'https://eu.i.posthog.com',
    timeout: 10000,
  }))
})
```

```typescript [Next.js]lib/evlog.ts
import { createEvlog } from 'evlog/next'
import { createPostHogDrain } from 'evlog/posthog'

export const { withEvlog, useLogger, log, createError } = createEvlog({
  service: 'my-app',
  drain: createPostHogDrain({
    host: 'https://eu.i.posthog.com',
    timeout: 10000,
  }),
})
```

```typescript [SvelteKit]src/hooks.server.ts
import { createEvlogHooks } from 'evlog/sveltekit'
import { createPostHogDrain } from 'evlog/posthog'

export const { handle, handleError } = createEvlogHooks({
  drain: createPostHogDrain({
    host: 'https://eu.i.posthog.com',
    timeout: 10000,
  }),
})
```

```typescript [TanStack Start]server/plugins/evlog-drain.ts
import { definePlugin } from 'nitro'
import { createPostHogDrain } from 'evlog/posthog'

export default definePlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:drain', createPostHogDrain({
    host: 'https://eu.i.posthog.com',
    timeout: 10000,
  }))
})
```

```typescript [React Router]app/root.tsx
import { evlog } from 'evlog/react-router'
import { createPostHogDrain } from 'evlog/posthog'

export const middleware: Route.MiddlewareFunction[] = [
  evlog({
      drain: createPostHogDrain({
        host: 'https://eu.i.posthog.com',
        timeout: 10000,
      }),
    }),
]
```

```typescript [Hono]src/index.ts
import { evlog } from 'evlog/hono'
import { createPostHogDrain } from 'evlog/posthog'

app.use(evlog({
  drain: createPostHogDrain({
    host: 'https://eu.i.posthog.com',
    timeout: 10000,
  }),
}))
```

```typescript [Express]src/index.ts
import { evlog } from 'evlog/express'
import { createPostHogDrain } from 'evlog/posthog'

app.use(evlog({
  drain: createPostHogDrain({
    host: 'https://eu.i.posthog.com',
    timeout: 10000,
  }),
}))
```

```typescript [Fastify]src/index.ts
import { evlog } from 'evlog/fastify'
import { createPostHogDrain } from 'evlog/posthog'

await app.register(evlog, {
  drain: createPostHogDrain({
    host: 'https://eu.i.posthog.com',
    timeout: 10000,
  }),
})
```

```typescript [Elysia]src/index.ts
import { evlog } from 'evlog/elysia'
import { createPostHogDrain } from 'evlog/posthog'

app.use(evlog({
  drain: createPostHogDrain({
    host: 'https://eu.i.posthog.com',
    timeout: 10000,
  }),
}))
```

```typescript [NestJS]src/app.module.ts
import { EvlogModule } from 'evlog/nestjs'
import { createPostHogDrain } from 'evlog/posthog'

EvlogModule.forRoot({
  drain: createPostHogDrain({
    host: 'https://eu.i.posthog.com',
    timeout: 10000,
  }),
})
```

```typescript [oRPC]server/orpc.ts
import { withEvlog } from 'evlog/orpc'
import { createPostHogDrain } from 'evlog/posthog'

const handler = withEvlog(new RPCHandler(router), {
  drain: createPostHogDrain({
    host: 'https://eu.i.posthog.com',
    timeout: 10000,
  }),
})
```

```typescript [Cloudflare Workers]src/worker.ts
import { withEvlog } from 'evlog/workers'
import { createPostHogDrain } from 'evlog/posthog'

export default withEvlog(handler, {
  drain: createPostHogDrain({
    host: 'https://eu.i.posthog.com',
    timeout: 10000,
  }),
})
```

```typescript [Standalone]src/index.ts
import { initLogger } from 'evlog'
import { createPostHogDrain } from 'evlog/posthog'

initLogger({
  drain: createPostHogDrain({
    host: 'https://eu.i.posthog.com',
    timeout: 10000,
  }),
})
```

</framework-tabs>

### Full Configuration Reference

<table>
<thead>
  <tr>
    <th>
      Option
    </th>
    
    <th>
      Type
    </th>
    
    <th>
      Default
    </th>
    
    <th>
      Description
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        apiKey
      </code>
    </td>
    
    <td>
      <code>
        string
      </code>
    </td>
    
    <td>
      -
    </td>
    
    <td>
      Project API key (required)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        host
      </code>
    </td>
    
    <td>
      <code>
        string
      </code>
    </td>
    
    <td>
      <code>
        https://us.i.posthog.com
      </code>
    </td>
    
    <td>
      PostHog host URL
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        distinctId
      </code>
    </td>
    
    <td>
      <code>
        string
      </code>
    </td>
    
    <td>
      -
    </td>
    
    <td>
      Static person identifier for every event
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        distinctIdField
      </code>
    </td>
    
    <td>
      <code>
        string
      </code>
    </td>
    
    <td>
      <code>
        userId
      </code>
    </td>
    
    <td>
      Event field holding the person identifier (dot path)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        sessionIdField
      </code>
    </td>
    
    <td>
      <code>
        string
      </code>
    </td>
    
    <td>
      <code>
        sessionId
      </code>
    </td>
    
    <td>
      Event field holding the PostHog session id (dot path)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        recordShape
      </code>
    </td>
    
    <td>
      <code>
        'json' | 'compact'
      </code>
    </td>
    
    <td>
      <code>
        'json'
      </code>
    </td>
    
    <td>
      Log record shape, see <a href="#choose-a-record-shape">
        below
      </a>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        timeout
      </code>
    </td>
    
    <td>
      <code>
        number
      </code>
    </td>
    
    <td>
      <code>
        5000
      </code>
    </td>
    
    <td>
      Request timeout in milliseconds
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        retries
      </code>
    </td>
    
    <td>
      <code>
        number
      </code>
    </td>
    
    <td>
      <code>
        2
      </code>
    </td>
    
    <td>
      Retry attempts on transient failures
    </td>
  </tr>
</tbody>
</table>

## What happens between a log and PostHog

Under the hood, `createPostHogDrain()` wraps the OTLP adapter's `sendBatchToOTLP()` with PostHog-specific defaults:

- **Endpoint**: `{host}/i/v1/logs` (PostHog's OTLP log ingest endpoint)
- **Auth**: `Authorization: Bearer {apiKey}` header
- **Format**: Standard OTLP `ExportLogsServiceRequest` with severity levels, trace context, and structured attributes
- **Identity**: `posthogDistinctId` and `sessionId` attributes, which is how PostHog joins a log to the rest of your project data

## Choose a record shape

PostHog treats log attributes as facets: you filter, break down, and alert on them. With the default `json` shape a nested field arrives as one serialized attribute (`ai` = `{"calls":2,"costUsd":0.0012}`), which PostHog can display but not chart.

`compact` flattens those into `ai.calls` and `ai.costUsd`, and replaces the body with a one-line summary instead of repeating the whole event:

<framework-tabs>

```typescript [Nuxt]server/plugins/evlog-drain.ts
import { createPostHogDrain } from 'evlog/posthog'

export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:drain', createPostHogDrain({ recordShape: 'compact' }))
})
```

```typescript [Nitro]server/plugins/evlog-drain.ts
import { definePlugin } from 'nitro'
import { createPostHogDrain } from 'evlog/posthog'

export default definePlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:drain', createPostHogDrain({ recordShape: 'compact' }))
})
```

```typescript [Next.js]lib/evlog.ts
import { createEvlog } from 'evlog/next'
import { createPostHogDrain } from 'evlog/posthog'

export const { withEvlog, useLogger, log, createError } = createEvlog({
  service: 'my-app',
  drain: createPostHogDrain({ recordShape: 'compact' }),
})
```

```typescript [SvelteKit]src/hooks.server.ts
import { createEvlogHooks } from 'evlog/sveltekit'
import { createPostHogDrain } from 'evlog/posthog'

export const { handle, handleError } = createEvlogHooks({ drain: createPostHogDrain({ recordShape: 'compact' }) })
```

```typescript [TanStack Start]server/plugins/evlog-drain.ts
import { definePlugin } from 'nitro'
import { createPostHogDrain } from 'evlog/posthog'

export default definePlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:drain', createPostHogDrain({ recordShape: 'compact' }))
})
```

```typescript [React Router]app/root.tsx
import { evlog } from 'evlog/react-router'
import { createPostHogDrain } from 'evlog/posthog'

export const middleware: Route.MiddlewareFunction[] = [
  evlog({ drain: createPostHogDrain({ recordShape: 'compact' }) }),
]
```

```typescript [Hono]src/index.ts
import { evlog } from 'evlog/hono'
import { createPostHogDrain } from 'evlog/posthog'

app.use(evlog({ drain: createPostHogDrain({ recordShape: 'compact' }) }))
```

```typescript [Express]src/index.ts
import { evlog } from 'evlog/express'
import { createPostHogDrain } from 'evlog/posthog'

app.use(evlog({ drain: createPostHogDrain({ recordShape: 'compact' }) }))
```

```typescript [Fastify]src/index.ts
import { evlog } from 'evlog/fastify'
import { createPostHogDrain } from 'evlog/posthog'

await app.register(evlog, { drain: createPostHogDrain({ recordShape: 'compact' }) })
```

```typescript [Elysia]src/index.ts
import { evlog } from 'evlog/elysia'
import { createPostHogDrain } from 'evlog/posthog'

app.use(evlog({ drain: createPostHogDrain({ recordShape: 'compact' }) }))
```

```typescript [NestJS]src/app.module.ts
import { EvlogModule } from 'evlog/nestjs'
import { createPostHogDrain } from 'evlog/posthog'

EvlogModule.forRoot({ drain: createPostHogDrain({ recordShape: 'compact' }) })
```

```typescript [oRPC]server/orpc.ts
import { withEvlog } from 'evlog/orpc'
import { createPostHogDrain } from 'evlog/posthog'

const handler = withEvlog(new RPCHandler(router), { drain: createPostHogDrain({ recordShape: 'compact' }) })
```

```typescript [Cloudflare Workers]src/worker.ts
import { withEvlog } from 'evlog/workers'
import { createPostHogDrain } from 'evlog/posthog'

export default withEvlog(handler, { drain: createPostHogDrain({ recordShape: 'compact' }) })
```

```typescript [Standalone]src/index.ts
import { initLogger } from 'evlog'
import { createPostHogDrain } from 'evlog/posthog'

initLogger({ drain: createPostHogDrain({ recordShape: 'compact' }) })
```

</framework-tabs>

This is the recommended shape for PostHog. It also cuts what you send: Logs is billed per GB ingested, and the default shape transmits every field twice: once in the body, once in the attributes.

<callout color="info" icon="i-lucide-info">

`compact` becomes the default in the next major. Set it on a new project now; switching later means rewriting the saved views and alerts built on the `json` shape.

</callout>

## Link a log to a person and a session replay

A log that carries a person identifier shows up on that person's profile in PostHog, under the **Logs** tab, with no service-name guessing to find what a specific user hit. Carry a session id too and the log links to their session replay.

PostHog reads both from log attributes: `posthogDistinctId` for the person, `sessionId` for the replay. The adapter fills them from your wide event, so this works as soon as your event carries the values:

<framework-tabs>

```typescript [Nuxt]server/api/checkout.post.ts
import { useLogger } from 'evlog'

const log = useLogger(event)

log.set({
  userId: user.id, // → posthogDistinctId, links to the person
  sessionId: body.sessionId, // → sessionId, links to the session replay
})
```

```typescript [Next.js]app/api/checkout/route.ts
import { useLogger } from '@/lib/evlog'

const log = useLogger()

log.set({
  userId: user.id, // → posthogDistinctId, links to the person
  sessionId: body.sessionId, // → sessionId, links to the session replay
})
```

```typescript [Hono]src/index.ts
const log = c.get('log')

log.set({
  userId: user.id, // → posthogDistinctId, links to the person
  sessionId: body.sessionId, // → sessionId, links to the session replay
})
```

```typescript [Express]src/index.ts
const log = req.log

log.set({
  userId: user.id, // → posthogDistinctId, links to the person
  sessionId: body.sessionId, // → sessionId, links to the session replay
})
```

</framework-tabs>

The session id comes from the frontend. Read it with `posthog.get_session_id()` and send it along with the request:

```typescript [app/checkout.ts]
import posthog from 'posthog-js'

await $fetch('/api/checkout', {
  method: 'POST',
  body: { ...payload, sessionId: posthog.get_session_id() },
})
```

### Point at your own fields

When identity lives somewhere else on your events, point the adapter at it. Both options take a dot path:

<framework-tabs>

```typescript [Nuxt]server/plugins/evlog-drain.ts
import { createPostHogDrain } from 'evlog/posthog'

export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:drain', createPostHogDrain({
    distinctIdField: 'user.id',
    sessionIdField: 'session.id',
  }))
})
```

```typescript [Nitro]server/plugins/evlog-drain.ts
import { definePlugin } from 'nitro'
import { createPostHogDrain } from 'evlog/posthog'

export default definePlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:drain', createPostHogDrain({
    distinctIdField: 'user.id',
    sessionIdField: 'session.id',
  }))
})
```

```typescript [Next.js]lib/evlog.ts
import { createEvlog } from 'evlog/next'
import { createPostHogDrain } from 'evlog/posthog'

export const { withEvlog, useLogger, log, createError } = createEvlog({
  service: 'my-app',
  drain: createPostHogDrain({
    distinctIdField: 'user.id',
    sessionIdField: 'session.id',
  }),
})
```

```typescript [SvelteKit]src/hooks.server.ts
import { createEvlogHooks } from 'evlog/sveltekit'
import { createPostHogDrain } from 'evlog/posthog'

export const { handle, handleError } = createEvlogHooks({
  drain: createPostHogDrain({
    distinctIdField: 'user.id',
    sessionIdField: 'session.id',
  }),
})
```

```typescript [TanStack Start]server/plugins/evlog-drain.ts
import { definePlugin } from 'nitro'
import { createPostHogDrain } from 'evlog/posthog'

export default definePlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:drain', createPostHogDrain({
    distinctIdField: 'user.id',
    sessionIdField: 'session.id',
  }))
})
```

```typescript [React Router]app/root.tsx
import { evlog } from 'evlog/react-router'
import { createPostHogDrain } from 'evlog/posthog'

export const middleware: Route.MiddlewareFunction[] = [
  evlog({
      drain: createPostHogDrain({
        distinctIdField: 'user.id',
        sessionIdField: 'session.id',
      }),
    }),
]
```

```typescript [Hono]src/index.ts
import { evlog } from 'evlog/hono'
import { createPostHogDrain } from 'evlog/posthog'

app.use(evlog({
  drain: createPostHogDrain({
    distinctIdField: 'user.id',
    sessionIdField: 'session.id',
  }),
}))
```

```typescript [Express]src/index.ts
import { evlog } from 'evlog/express'
import { createPostHogDrain } from 'evlog/posthog'

app.use(evlog({
  drain: createPostHogDrain({
    distinctIdField: 'user.id',
    sessionIdField: 'session.id',
  }),
}))
```

```typescript [Fastify]src/index.ts
import { evlog } from 'evlog/fastify'
import { createPostHogDrain } from 'evlog/posthog'

await app.register(evlog, {
  drain: createPostHogDrain({
    distinctIdField: 'user.id',
    sessionIdField: 'session.id',
  }),
})
```

```typescript [Elysia]src/index.ts
import { evlog } from 'evlog/elysia'
import { createPostHogDrain } from 'evlog/posthog'

app.use(evlog({
  drain: createPostHogDrain({
    distinctIdField: 'user.id',
    sessionIdField: 'session.id',
  }),
}))
```

```typescript [NestJS]src/app.module.ts
import { EvlogModule } from 'evlog/nestjs'
import { createPostHogDrain } from 'evlog/posthog'

EvlogModule.forRoot({
  drain: createPostHogDrain({
    distinctIdField: 'user.id',
    sessionIdField: 'session.id',
  }),
})
```

```typescript [oRPC]server/orpc.ts
import { withEvlog } from 'evlog/orpc'
import { createPostHogDrain } from 'evlog/posthog'

const handler = withEvlog(new RPCHandler(router), {
  drain: createPostHogDrain({
    distinctIdField: 'user.id',
    sessionIdField: 'session.id',
  }),
})
```

```typescript [Cloudflare Workers]src/worker.ts
import { withEvlog } from 'evlog/workers'
import { createPostHogDrain } from 'evlog/posthog'

export default withEvlog(handler, {
  drain: createPostHogDrain({
    distinctIdField: 'user.id',
    sessionIdField: 'session.id',
  }),
})
```

```typescript [Standalone]src/index.ts
import { initLogger } from 'evlog'
import { createPostHogDrain } from 'evlog/posthog'

initLogger({
  drain: createPostHogDrain({
    distinctIdField: 'user.id',
    sessionIdField: 'session.id',
  }),
})
```

</framework-tabs>

For an [eve agent](https://www.evlog.dev/use-cases/eve), the caller principal is the identity eve itself routes on:

```typescript [agent/hooks/evlog.ts]
const drain = createPostHogDrain({ distinctIdField: 'eve.caller.principalId' })
```

A static `distinctId` overrides the field lookup entirely. Use it for a backend that acts as one identity rather than on behalf of users.

<callout color="info" icon="i-lucide-info">

PostHog matches the attribute value against every `distinct_id` it knows for a person, so any one of their identifiers works. The attribute key is configurable per project under **Settings** > **Logs**. Leave it at the default `posthogDistinctId` and this works out of the box.

</callout>

## Regions

PostHog offers US and EU cloud hosting. Set the `host` to match your region:

<table>
<thead>
  <tr>
    <th>
      Region
    </th>
    
    <th>
      Host
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      US (default)
    </td>
    
    <td>
      <code>
        https://us.i.posthog.com
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      EU
    </td>
    
    <td>
      <code>
        https://eu.i.posthog.com
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Self-hosted
    </td>
    
    <td>
      Your instance URL
    </td>
  </tr>
</tbody>
</table>

```bash [.env]
# EU region
POSTHOG_API_KEY=phc_xxx
POSTHOG_HOST=https://eu.i.posthog.com
```

## Query the logs in PostHog

Once your logs are flowing, use the **Logs** tab in PostHog to query them:

1. Go to **Logs** and filter by service, severity, or any structured attribute
2. Use the search bar to find specific log entries
3. Click on a log entry to see all structured attributes

## Send custom events alongside logs

If you prefer sending logs as PostHog custom events (e.g., for product analytics, cohorts, or funnels), use `createPostHogDrain()` with `mode: 'events'`:

<framework-tabs>

```typescript [Nuxt]server/plugins/evlog-drain.ts
import { createPostHogDrain } from 'evlog/posthog'

export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:drain', createPostHogDrain({
    mode: 'events',
    eventName: 'server_request',
    distinctId: 'my-backend-service',
  }))
})
```

```typescript [Nitro]server/plugins/evlog-drain.ts
import { definePlugin } from 'nitro'
import { createPostHogDrain } from 'evlog/posthog'

export default definePlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:drain', createPostHogDrain({
    mode: 'events',
    eventName: 'server_request',
    distinctId: 'my-backend-service',
  }))
})
```

```typescript [Next.js]lib/evlog.ts
import { createEvlog } from 'evlog/next'
import { createPostHogDrain } from 'evlog/posthog'

export const { withEvlog, useLogger, log, createError } = createEvlog({
  service: 'my-app',
  drain: createPostHogDrain({
    mode: 'events',
    eventName: 'server_request',
    distinctId: 'my-backend-service',
  }),
})
```

```typescript [SvelteKit]src/hooks.server.ts
import { createEvlogHooks } from 'evlog/sveltekit'
import { createPostHogDrain } from 'evlog/posthog'

export const { handle, handleError } = createEvlogHooks({
  drain: createPostHogDrain({
    mode: 'events',
    eventName: 'server_request',
    distinctId: 'my-backend-service',
  }),
})
```

```typescript [TanStack Start]server/plugins/evlog-drain.ts
import { definePlugin } from 'nitro'
import { createPostHogDrain } from 'evlog/posthog'

export default definePlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:drain', createPostHogDrain({
    mode: 'events',
    eventName: 'server_request',
    distinctId: 'my-backend-service',
  }))
})
```

```typescript [React Router]app/root.tsx
import { evlog } from 'evlog/react-router'
import { createPostHogDrain } from 'evlog/posthog'

export const middleware: Route.MiddlewareFunction[] = [
  evlog({
      drain: createPostHogDrain({
        mode: 'events',
        eventName: 'server_request',
        distinctId: 'my-backend-service',
      }),
    }),
]
```

```typescript [Hono]src/index.ts
import { evlog } from 'evlog/hono'
import { createPostHogDrain } from 'evlog/posthog'

app.use(evlog({
  drain: createPostHogDrain({
    mode: 'events',
    eventName: 'server_request',
    distinctId: 'my-backend-service',
  }),
}))
```

```typescript [Express]src/index.ts
import { evlog } from 'evlog/express'
import { createPostHogDrain } from 'evlog/posthog'

app.use(evlog({
  drain: createPostHogDrain({
    mode: 'events',
    eventName: 'server_request',
    distinctId: 'my-backend-service',
  }),
}))
```

```typescript [Fastify]src/index.ts
import { evlog } from 'evlog/fastify'
import { createPostHogDrain } from 'evlog/posthog'

await app.register(evlog, {
  drain: createPostHogDrain({
    mode: 'events',
    eventName: 'server_request',
    distinctId: 'my-backend-service',
  }),
})
```

```typescript [Elysia]src/index.ts
import { evlog } from 'evlog/elysia'
import { createPostHogDrain } from 'evlog/posthog'

app.use(evlog({
  drain: createPostHogDrain({
    mode: 'events',
    eventName: 'server_request',
    distinctId: 'my-backend-service',
  }),
}))
```

```typescript [NestJS]src/app.module.ts
import { EvlogModule } from 'evlog/nestjs'
import { createPostHogDrain } from 'evlog/posthog'

EvlogModule.forRoot({
  drain: createPostHogDrain({
    mode: 'events',
    eventName: 'server_request',
    distinctId: 'my-backend-service',
  }),
})
```

```typescript [oRPC]server/orpc.ts
import { withEvlog } from 'evlog/orpc'
import { createPostHogDrain } from 'evlog/posthog'

const handler = withEvlog(new RPCHandler(router), {
  drain: createPostHogDrain({
    mode: 'events',
    eventName: 'server_request',
    distinctId: 'my-backend-service',
  }),
})
```

```typescript [Cloudflare Workers]src/worker.ts
import { withEvlog } from 'evlog/workers'
import { createPostHogDrain } from 'evlog/posthog'

export default withEvlog(handler, {
  drain: createPostHogDrain({
    mode: 'events',
    eventName: 'server_request',
    distinctId: 'my-backend-service',
  }),
})
```

```typescript [Standalone]src/index.ts
import { initLogger } from 'evlog'
import { createPostHogDrain } from 'evlog/posthog'

initLogger({
  drain: createPostHogDrain({
    mode: 'events',
    eventName: 'server_request',
    distinctId: 'my-backend-service',
  }),
})
```

</framework-tabs>

Then pass `drain` to your framework the same way as the default logs drain (see [Quick Start](#quick-start) above).

<callout color="info" icon="i-lucide-info">

Custom events count towards your PostHog event quota. PostHog Logs (the default `createPostHogDrain()`) is significantly cheaper.

</callout>

<callout color="warning" icon="i-lucide-triangle-alert">

**Legacy:** `createPostHogEventsDrain()` is deprecated and re-routes to `createPostHogDrain({ mode: 'events' })`. It will be removed in the next major release.

</callout>

### Configure which events go out

<table>
<thead>
  <tr>
    <th>
      Option
    </th>
    
    <th>
      Type
    </th>
    
    <th>
      Default
    </th>
    
    <th>
      Description
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        apiKey
      </code>
    </td>
    
    <td>
      <code>
        string
      </code>
    </td>
    
    <td>
      -
    </td>
    
    <td>
      Project API key (required)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        host
      </code>
    </td>
    
    <td>
      <code>
        string
      </code>
    </td>
    
    <td>
      <code>
        https://us.i.posthog.com
      </code>
    </td>
    
    <td>
      PostHog host URL
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        eventName
      </code>
    </td>
    
    <td>
      <code>
        string
      </code>
    </td>
    
    <td>
      <code>
        evlog_wide_event
      </code>
    </td>
    
    <td>
      PostHog event name
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        distinctId
      </code>
    </td>
    
    <td>
      <code>
        string
      </code>
    </td>
    
    <td>
      -
    </td>
    
    <td>
      Static <code>
        distinct_id
      </code>
      
       for all events
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        distinctIdField
      </code>
    </td>
    
    <td>
      <code>
        string
      </code>
    </td>
    
    <td>
      <code>
        userId
      </code>
    </td>
    
    <td>
      Event field holding the <code>
        distinct_id
      </code>
      
       (dot path)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        recordShape
      </code>
    </td>
    
    <td>
      <code>
        'json' | 'compact'
      </code>
    </td>
    
    <td>
      <code>
        'json'
      </code>
    </td>
    
    <td>
      Event property shape: <code>
        compact
      </code>
      
       flattens nested fields (see <a href="#event-format">
        below
      </a>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        timeout
      </code>
    </td>
    
    <td>
      <code>
        number
      </code>
    </td>
    
    <td>
      <code>
        5000
      </code>
    </td>
    
    <td>
      Request timeout in milliseconds
    </td>
  </tr>
</tbody>
</table>

### Event Format

evlog maps wide events to PostHog events:

<table>
<thead>
  <tr>
    <th>
      evlog Field
    </th>
    
    <th>
      PostHog Field
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        config.distinctId
      </code>
      
       or <code>
        userId
      </code>
      
       or <code>
        service
      </code>
    </td>
    
    <td>
      <code>
        distinct_id
      </code>
      
       (fallback chain)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        timestamp
      </code>
    </td>
    
    <td>
      <code>
        timestamp
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        level
      </code>
    </td>
    
    <td>
      <code>
        properties.level
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        service
      </code>
    </td>
    
    <td>
      <code>
        properties.service
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        environment
      </code>
    </td>
    
    <td>
      <code>
        properties.environment
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      All other fields
    </td>
    
    <td>
      <code>
        properties.*
      </code>
    </td>
  </tr>
</tbody>
</table>

With `recordShape: 'compact'`, the other fields are flattened into dotted keys the same way as in logs mode: a nested `ai` object arrives as `properties.ai.costUsd` and `properties.ai.calls` instead of one opaque `properties.ai` value. PostHog can filter and break down on those individual properties; the default `json` shape keeps the nested object as a single serialized property. See [Choose a record shape](#choose-a-record-shape) for the trade-off and how it affects your saved views and alerts.

### How the distinct ID is resolved

The `distinct_id` follows a fallback chain:

1. **config.distinctId** - explicit override in `createPostHogDrain({ mode: 'events' })`
2. **event.userId** - or whatever `distinctIdField` points at, when it holds a string or a number
3. **event.service** - final fallback, sent as an anonymous event

### Identified and anonymous events

An event that resolves to a real person is an **identified** event: PostHog creates a person profile for it and attaches person properties.

When no identifier resolves, the event is sent as **anonymous** (`$process_person_profile: false`) rather than piling every request onto one "person" named after your service. PostHog bills anonymous events at a lower rate and keeps them out of person profiles.

```typescript
// Identified: events carrying `userId` create and update a person profile
const drain = createPostHogDrain({ mode: 'events' })

// Identified as one backend identity, whatever the request
const service = createPostHogDrain({ mode: 'events', distinctId: 'checkout-worker' })
```

<callout color="info" icon="i-lucide-info">

Anonymous events can be up to 4× cheaper than identified ones. Only set an identity on the events where you actually need per-person analysis.

</callout>

### When to use a log and when to use an event

<table>
<thead>
  <tr>
    <th>
      
    </th>
    
    <th>
      <code>
        createPostHogDrain()
      </code>
    </th>
    
    <th>
      <code>
        createPostHogDrain({ mode: 'events' })
      </code>
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <strong>
        Format
      </strong>
    </td>
    
    <td>
      OTLP Logs (<code>
        /i/v1/logs
      </code>
      
      )
    </td>
    
    <td>
      PostHog Events (<code>
        /batch/
      </code>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      <strong>
        PostHog UI
      </strong>
    </td>
    
    <td>
      Logs viewer
    </td>
    
    <td>
      Events explorer
    </td>
  </tr>
  
  <tr>
    <td>
      <strong>
        Cost
      </strong>
    </td>
    
    <td>
      Lower (dedicated logs pipeline)
    </td>
    
    <td>
      Higher (counts as events)
    </td>
  </tr>
  
  <tr>
    <td>
      <strong>
        Best for
      </strong>
    </td>
    
    <td>
      Debugging, log search, observability
    </td>
    
    <td>
      Product analytics, cohorts, funnels
    </td>
  </tr>
</tbody>
</table>

You can use both drains simultaneously to get the best of both worlds:

<framework-tabs>

```typescript [Nuxt]server/plugins/evlog.ts
import { createPostHogDrain } from 'evlog/posthog'

const logs = createPostHogDrain()
const events = createPostHogDrain({ mode: 'events' })

const drain = async (ctx) => {
  await Promise.allSettled([logs(ctx), events(ctx)])
}

export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:drain', drain)
})
```

```typescript [Nitro]server/plugins/evlog.ts
import { definePlugin } from 'nitro'
import { createPostHogDrain } from 'evlog/posthog'

const logs = createPostHogDrain()
const events = createPostHogDrain({ mode: 'events' })

const drain = async (ctx) => {
  await Promise.allSettled([logs(ctx), events(ctx)])
}

export default definePlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:drain', drain)
})
```

```typescript [Next.js]lib/evlog.ts
import { createEvlog } from 'evlog/next'
import { createPostHogDrain } from 'evlog/posthog'

const logs = createPostHogDrain()
const events = createPostHogDrain({ mode: 'events' })

const drain = async (ctx) => {
  await Promise.allSettled([logs(ctx), events(ctx)])
}
export const { withEvlog, useLogger, log, createError } = createEvlog({
  service: 'my-app',
  drain: drain,
})
```

```typescript [SvelteKit]src/hooks.server.ts
import { createEvlogHooks } from 'evlog/sveltekit'
import { createPostHogDrain } from 'evlog/posthog'

const logs = createPostHogDrain()
const events = createPostHogDrain({ mode: 'events' })

const drain = async (ctx) => {
  await Promise.allSettled([logs(ctx), events(ctx)])
}
export const { handle, handleError } = createEvlogHooks({ drain: drain })
```

```typescript [TanStack Start]server/plugins/evlog.ts
import { definePlugin } from 'nitro'
import { createPostHogDrain } from 'evlog/posthog'

const logs = createPostHogDrain()
const events = createPostHogDrain({ mode: 'events' })

const drain = async (ctx) => {
  await Promise.allSettled([logs(ctx), events(ctx)])
}

export default definePlugin((nitroApp) => {
  nitroApp.hooks.hook('evlog:drain', drain)
})
```

```typescript [React Router]app/root.tsx
import { evlog } from 'evlog/react-router'
import { createPostHogDrain } from 'evlog/posthog'

const logs = createPostHogDrain()
const events = createPostHogDrain({ mode: 'events' })

const drain = async (ctx) => {
  await Promise.allSettled([logs(ctx), events(ctx)])
}
export const middleware: Route.MiddlewareFunction[] = [
  evlog({ drain: drain }),
]
```

```typescript [Hono]src/index.ts
import { evlog } from 'evlog/hono'
import { createPostHogDrain } from 'evlog/posthog'

const logs = createPostHogDrain()
const events = createPostHogDrain({ mode: 'events' })

const drain = async (ctx) => {
  await Promise.allSettled([logs(ctx), events(ctx)])
}
app.use(evlog({ drain: drain }))
```

```typescript [Express]src/index.ts
import { evlog } from 'evlog/express'
import { createPostHogDrain } from 'evlog/posthog'

const logs = createPostHogDrain()
const events = createPostHogDrain({ mode: 'events' })

const drain = async (ctx) => {
  await Promise.allSettled([logs(ctx), events(ctx)])
}
app.use(evlog({ drain: drain }))
```

```typescript [Fastify]src/index.ts
import { evlog } from 'evlog/fastify'
import { createPostHogDrain } from 'evlog/posthog'

const logs = createPostHogDrain()
const events = createPostHogDrain({ mode: 'events' })

const drain = async (ctx) => {
  await Promise.allSettled([logs(ctx), events(ctx)])
}
await app.register(evlog, { drain: drain })
```

```typescript [Elysia]src/index.ts
import { evlog } from 'evlog/elysia'
import { createPostHogDrain } from 'evlog/posthog'

const logs = createPostHogDrain()
const events = createPostHogDrain({ mode: 'events' })

const drain = async (ctx) => {
  await Promise.allSettled([logs(ctx), events(ctx)])
}
app.use(evlog({ drain: drain }))
```

```typescript [NestJS]src/app.module.ts
import { EvlogModule } from 'evlog/nestjs'
import { createPostHogDrain } from 'evlog/posthog'

const logs = createPostHogDrain()
const events = createPostHogDrain({ mode: 'events' })

const drain = async (ctx) => {
  await Promise.allSettled([logs(ctx), events(ctx)])
}
EvlogModule.forRoot({ drain: drain })
```

```typescript [oRPC]server/orpc.ts
import { withEvlog } from 'evlog/orpc'
import { createPostHogDrain } from 'evlog/posthog'

const logs = createPostHogDrain()
const events = createPostHogDrain({ mode: 'events' })

const drain = async (ctx) => {
  await Promise.allSettled([logs(ctx), events(ctx)])
}
const handler = withEvlog(new RPCHandler(router), { drain: drain })
```

```typescript [Cloudflare Workers]src/worker.ts
import { withEvlog } from 'evlog/workers'
import { createPostHogDrain } from 'evlog/posthog'

const logs = createPostHogDrain()
const events = createPostHogDrain({ mode: 'events' })

const drain = async (ctx) => {
  await Promise.allSettled([logs(ctx), events(ctx)])
}
export default withEvlog(handler, { drain: drain })
```

```typescript [Standalone]src/index.ts
import { initLogger } from 'evlog'
import { createPostHogDrain } from 'evlog/posthog'

const logs = createPostHogDrain()
const events = createPostHogDrain({ mode: 'events' })

const drain = async (ctx) => {
  await Promise.allSettled([logs(ctx), events(ctx)])
}
initLogger({ drain: drain })
```

</framework-tabs>

## Troubleshooting

### Missing apiKey error

```text [Console]
[evlog/posthog] Missing apiKey. Set POSTHOG_API_KEY env var or pass to createPostHogDrain()
```

Make sure your environment variable is set and the server was restarted after adding it.

### Events not appearing

PostHog processes events asynchronously. There may be a short delay (typically under 1 minute) before events appear in the dashboard.

1. Check the server console for `[evlog/posthog]` error messages
2. Verify your API key is correct and starts with `phc_`
3. Confirm your `host` matches your PostHog region (US vs EU)

### Wrong region

If you're on PostHog EU but using the default US host, event delivery will fail and the adapter will log errors (for example under `[evlog/posthog]`) to your server console. Set the correct host:

```bash [.env]
POSTHOG_HOST=https://eu.i.posthog.com
```

## Call the drain without a framework

For advanced use cases, you can use the lower-level functions:

```typescript [server/utils/posthog.ts]
import { sendToPostHog, sendBatchToPostHog } from 'evlog/posthog'

// Send a single event to PostHog Logs (OTLP)
await sendToPostHog(event, {
  apiKey: 'phc_xxx',
})

// Send multiple events in one request
await sendBatchToPostHog(events, {
  apiKey: 'phc_xxx',
})
```

For custom events, use the events-specific functions:

```typescript [server/utils/posthog.ts]
import { sendToPostHogEvents, sendBatchToPostHogEvents, toPostHogEvent } from 'evlog/posthog'

// Send a single custom event
await sendToPostHogEvents(event, {
  apiKey: 'phc_xxx',
})

// Send multiple custom events in one request
await sendBatchToPostHogEvents(events, {
  apiKey: 'phc_xxx',
})

// Convert event to PostHog format (for inspection)
const posthogEvent = toPostHogEvent(event, { apiKey: 'phc_xxx' })
```

## Next Steps

- [Axiom Adapter](https://www.evlog.dev/integrate/adapters/cloud/axiom) - Send logs to Axiom
- [OTLP Adapter](https://www.evlog.dev/integrate/adapters/hybrid/otlp) - Send logs via OpenTelemetry Protocol
- [Custom Adapters](https://www.evlog.dev/extend/custom-drains) - Build your own adapter
- [Best Practices](https://www.evlog.dev/reference/best-practices) - Security and production tips

---

- [PostHog Dashboard](https://app.posthog.com)
- [Axiom Adapter](https://www.evlog.dev/integrate/adapters/cloud/axiom)


## Sitemap

See the full [sitemap](https://www.evlog.dev/sitemap.md) for all pages.
