Cloud

PostHog Adapter

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

PostHog is an open-source product analytics platform. The evlog PostHog adapter sends your wide events to PostHog Logs via the standard OTLP format, giving you a dedicated log viewer with filtering, search, and tail mode using your existing PostHog API key.

Add the PostHog drain adapter

Installation

The PostHog adapter comes bundled with evlog:

src/index.ts
import { createPostHogDrain } from 'evlog/posthog'

Quick Start

1. Get your PostHog project API key

  1. Log in to your PostHog dashboard
  2. Go to Settings > Project > Project API Key
  3. Copy the key (starts with phc_)

2. Set environment variables

.env
POSTHOG_API_KEY=phc_your-project-api-key

3. Wire the drain to your framework

// server/plugins/evlog-drain.ts
import { createPostHogDrain } from 'evlog/posthog'

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

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

VariableDescription
POSTHOG_API_KEYProject API key (starts with phc_)
POSTHOG_HOSTPostHog host URL (for EU or self-hosted)

Runtime Config (Nuxt only)

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

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:

server/plugins/evlog-drain.ts
const drain = createPostHogDrain({
  host: 'https://eu.i.posthog.com',
  timeout: 10000,
})

Full Configuration Reference

OptionTypeDefaultDescription
apiKeystring-Project API key (required)
hoststringhttps://us.i.posthog.comPostHog host URL
distinctIdstring-Static person identifier for every event
distinctIdFieldstringuserIdEvent field holding the person identifier (dot path)
sessionIdFieldstringsessionIdEvent field holding the PostHog session id (dot path)
recordShape'json' | 'compact''json'Log record shape — see below
timeoutnumber5000Request timeout in milliseconds
retriesnumber2Retry attempts on transient failures

How It Works

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

Choosing 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:

server/plugins/evlog-drain.ts
const drain = createPostHogDrain({ recordShape: 'compact' })

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.

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.

Linking Logs to People and Session Replays

A log that carries a person identifier shows up on that person's profile in PostHog, under the Logs tab — 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:

server/api/checkout.post.ts
const log = useLogger(event)

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

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

app/checkout.ts
import posthog from 'posthog-js'

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

Pointing at Your Own Fields

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

server/plugins/evlog-drain.ts
const drain = createPostHogDrain({
  distinctIdField: 'user.id',
  sessionIdField: 'session.id',
})

For an eve agent, the caller principal is the identity eve itself routes on:

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.

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.

Regions

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

RegionHost
US (default)https://us.i.posthog.com
EUhttps://eu.i.posthog.com
Self-hostedYour instance URL
.env
# EU region
POSTHOG_API_KEY=phc_xxx
POSTHOG_HOST=https://eu.i.posthog.com

Querying 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

PostHog Events (Custom Events)

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

server/plugins/evlog-drain.ts
import { createPostHogDrain } from 'evlog/posthog'

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

Then pass drain to your framework the same way as the default logs drain (see Quick Start above).

Custom events count towards your PostHog event quota. PostHog Logs (the default createPostHogDrain()) is significantly cheaper.
Legacy:createPostHogEventsDrain() is deprecated and re-routes to createPostHogDrain({ mode: 'events' }). It will be removed in the next major release.

Events Configuration

OptionTypeDefaultDescription
apiKeystring-Project API key (required)
hoststringhttps://us.i.posthog.comPostHog host URL
eventNamestringevlog_wide_eventPostHog event name
distinctIdstring-Static distinct_id for all events
distinctIdFieldstringuserIdEvent field holding the distinct_id (dot path)
timeoutnumber5000Request timeout in milliseconds

Event Format

evlog maps wide events to PostHog events:

evlog FieldPostHog Field
config.distinctId or userId or servicedistinct_id (fallback chain)
timestamptimestamp
levelproperties.level
serviceproperties.service
environmentproperties.environment
All other fieldsproperties.*

Distinct ID Resolution

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 vs 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.

server/plugins/evlog-drain.ts
// 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' })
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.

Logs vs Events

createPostHogDrain()createPostHogDrain({ mode: 'events' })
FormatOTLP Logs (/i/v1/logs)PostHog Events (/batch/)
PostHog UILogs viewerEvents explorer
CostLower (dedicated logs pipeline)Higher (counts as events)
Best forDebugging, log search, observabilityProduct analytics, cohorts, funnels

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

server/plugins/evlog-drain.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)])
}

Troubleshooting

Missing apiKey error

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:

.env
POSTHOG_HOST=https://eu.i.posthog.com

Direct API Usage

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

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:

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