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
68 changes: 68 additions & 0 deletions apps/docs/content/3.adapters/4.posthog.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,74 @@ await sendBatchToPostHog(events, {
const posthogEvent = toPostHogEvent(event, { apiKey: 'phc_xxx' })
```

## PostHog Logs (OTLP)

PostHog has a dedicated [Logs product](https://posthog.com/docs/logs) that accepts logs via the standard OTLP format. Instead of sending events to the PostHog Events pipeline, `createPostHogLogsDrain()` sends structured OTLP logs directly to PostHog Logs.

### Why use PostHog Logs?

- **Purpose-built UI** — PostHog Logs provides a dedicated log viewer with filtering, search, and tail mode
- **OTLP standard** — Uses the OpenTelemetry log format, so your logs include severity levels, trace context, and structured attributes
- **Same API key** — Authenticates with your existing PostHog project API key

### Quick Start

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

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

### Configuration

`createPostHogLogsDrain()` uses the same configuration resolution chain as `createPostHogDrain()`:

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `apiKey` | `string` | - | Project API key (required) |
| `host` | `string` | `https://us.i.posthog.com` | PostHog host URL |
| `timeout` | `number` | `5000` | Request timeout in milliseconds |

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

export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('evlog:drain', createPostHogLogsDrain({
apiKey: 'phc_...',
host: 'https://eu.i.posthog.com', // EU region
}))
})
```

### How It Works

Under the hood, `createPostHogLogsDrain()` 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

### Events vs Logs

| | `createPostHogDrain()` | `createPostHogLogsDrain()` |
|---|---|---|
| **Format** | PostHog Events (`/batch/`) | OTLP Logs (`/i/v1/logs`) |
| **PostHog UI** | Events explorer | Logs viewer |
| **Best for** | Product analytics, cohorts, funnels | Debugging, log search, observability |

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

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

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

## Next Steps

- [Axiom Adapter](/adapters/axiom) - Send logs to Axiom
Expand Down
4 changes: 2 additions & 2 deletions apps/playground/server/plugins/evlog-drain.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// import { createAxiomDrain } from 'evlog/axiom'
// import { createPostHogDrain } from 'evlog/posthog'
// import { createPostHogLogsDrain } from 'evlog/posthog'
// import { createSentryDrain } from 'evlog/sentry'
// import { createBetterStackDrain } from 'evlog/better-stack'

Expand All @@ -16,7 +16,7 @@ export default defineNitroPlugin((nitroApp) => {
// })
// axiomDrain(ctx)

// const posthogDrain = createPostHogDrain()
// const posthogDrain = createPostHogLogsDrain()
// posthogDrain(ctx)
Comment thread
HugoRCD marked this conversation as resolved.

// const sentryDrain = createSentryDrain()
Expand Down
71 changes: 71 additions & 0 deletions packages/evlog/src/adapters/posthog.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { DrainContext, WideEvent } from '../types'
import { sendBatchToOTLP } from './otlp'
import type { OTLPConfig } from './otlp'
import { getRuntimeConfig } from './_utils'

export interface PostHogConfig {
Expand Down Expand Up @@ -153,3 +155,72 @@ export async function sendBatchToPostHog(events: WideEvent[], config: PostHogCon
clearTimeout(timeoutId)
}
}

export interface PostHogLogsConfig {
/** PostHog project API key */
apiKey: string
/** PostHog host URL. Default: https://us.i.posthog.com */
host?: string
/** Request timeout in milliseconds. Default: 5000 */
timeout?: number
}

/**
* Create a drain function for sending logs to PostHog Logs via OTLP.
*
* PostHog Logs uses the standard OTLP log format. This drain wraps
* `sendBatchToOTLP()` with PostHog-specific defaults (endpoint, auth).
*
* Configuration priority (highest to lowest):
* 1. Overrides passed to createPostHogLogsDrain()
* 2. runtimeConfig.evlog.posthog
* 3. runtimeConfig.posthog
* 4. Environment variables: NUXT_POSTHOG_*, POSTHOG_*
*
* @example
* ```ts
* // Zero config - just set NUXT_POSTHOG_API_KEY env var
* nitroApp.hooks.hook('evlog:drain', createPostHogLogsDrain())
*
* // With overrides
* nitroApp.hooks.hook('evlog:drain', createPostHogLogsDrain({
* apiKey: 'phc_...',
* host: 'https://eu.i.posthog.com',
* }))
* ```
*/
export function createPostHogLogsDrain(overrides?: Partial<PostHogLogsConfig>): (ctx: DrainContext | DrainContext[]) => Promise<void> {
return async (ctx: DrainContext | DrainContext[]) => {
const contexts = Array.isArray(ctx) ? ctx : [ctx]
if (contexts.length === 0) return

const runtimeConfig = getRuntimeConfig()
const evlogPostHog = runtimeConfig?.evlog?.posthog
const rootPostHog = runtimeConfig?.posthog

const config: Partial<PostHogLogsConfig> = {
apiKey: overrides?.apiKey ?? evlogPostHog?.apiKey ?? rootPostHog?.apiKey ?? process.env.NUXT_POSTHOG_API_KEY ?? process.env.POSTHOG_API_KEY,
host: overrides?.host ?? evlogPostHog?.host ?? rootPostHog?.host ?? process.env.NUXT_POSTHOG_HOST ?? process.env.POSTHOG_HOST,
timeout: overrides?.timeout ?? evlogPostHog?.timeout ?? rootPostHog?.timeout,
}

if (!config.apiKey) {
console.error('[evlog/posthog] Missing apiKey. Set NUXT_POSTHOG_API_KEY/POSTHOG_API_KEY env var or pass to createPostHogLogsDrain()')
return
}

const host = (config.host ?? 'https://us.i.posthog.com').replace(/\/$/, '')

const otlpConfig: OTLPConfig = {
endpoint: `${host}/i`,
headers: { Authorization: `Bearer ${config.apiKey}` },
timeout: config.timeout,
}

try {
await sendBatchToOTLP(contexts.map(c => c.event), otlpConfig)
} catch (error) {
console.error('[evlog/posthog] Failed to send logs to PostHog Logs:', error)
}
}
}
197 changes: 196 additions & 1 deletion packages/evlog/test/adapters/posthog.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { WideEvent } from '../../src/types'
import { sendBatchToPostHog, sendToPostHog, toPostHogEvent } from '../../src/adapters/posthog'
import { createPostHogLogsDrain, sendBatchToPostHog, sendToPostHog, toPostHogEvent } from '../../src/adapters/posthog'

describe('posthog adapter', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>
Expand Down Expand Up @@ -270,4 +270,199 @@ describe('posthog adapter', () => {
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 10000)
})
})

describe('createPostHogLogsDrain', () => {
const createDrainContext = (overrides?: Partial<WideEvent>) => ({
event: createTestEvent(overrides),
})

afterEach(() => {
delete process.env.NUXT_POSTHOG_API_KEY
delete process.env.POSTHOG_API_KEY
delete process.env.NUXT_POSTHOG_HOST
delete process.env.POSTHOG_HOST
})

it('sends to correct OTLP endpoint', async () => {
const drain = createPostHogLogsDrain({ apiKey: 'phc_test' })
await drain(createDrainContext())

expect(fetchSpy).toHaveBeenCalledTimes(1)
const [url] = fetchSpy.mock.calls[0] as [string, RequestInit]
expect(url).toBe('https://us.i.posthog.com/i/v1/logs')
})

it('sets Authorization header with Bearer token', async () => {
const drain = createPostHogLogsDrain({ apiKey: 'phc_my_key' })
await drain(createDrainContext())

const [, options] = fetchSpy.mock.calls[0] as [string, RequestInit]
expect(options.headers).toEqual(expect.objectContaining({
Authorization: 'Bearer phc_my_key',
}))
})

it('sends OTLP log record format in payload', async () => {
const drain = createPostHogLogsDrain({ apiKey: 'phc_test' })
await drain(createDrainContext({ action: 'checkout' }))

const [, options] = fetchSpy.mock.calls[0] as [string, RequestInit]
const payload = JSON.parse(options.body as string)

expect(payload).toHaveProperty('resourceLogs')
expect(payload.resourceLogs).toHaveLength(1)
expect(payload.resourceLogs[0]).toHaveProperty('resource')
expect(payload.resourceLogs[0]).toHaveProperty('scopeLogs')
const [{ logRecords }] = payload.resourceLogs[0].scopeLogs
expect(logRecords).toHaveLength(1)
expect(logRecords[0]).toHaveProperty('timeUnixNano')
expect(logRecords[0]).toHaveProperty('severityNumber')
expect(logRecords[0]).toHaveProperty('severityText')
expect(logRecords[0]).toHaveProperty('body')
})

it('supports batch of events', async () => {
const drain = createPostHogLogsDrain({ apiKey: 'phc_test' })
await drain([
createDrainContext({ requestId: '1' }),
createDrainContext({ requestId: '2' }),
createDrainContext({ requestId: '3' }),
])

expect(fetchSpy).toHaveBeenCalledTimes(1)
const [, options] = fetchSpy.mock.calls[0] as [string, RequestInit]
const payload = JSON.parse(options.body as string)
expect(payload.resourceLogs[0].scopeLogs[0].logRecords).toHaveLength(3)
})

it('handles single context (non-array)', async () => {
const drain = createPostHogLogsDrain({ apiKey: 'phc_test' })
await drain(createDrainContext())

expect(fetchSpy).toHaveBeenCalledTimes(1)
})

it('skips empty array', async () => {
const drain = createPostHogLogsDrain({ apiKey: 'phc_test' })
await drain([])

expect(fetchSpy).not.toHaveBeenCalled()
})

it('resolves apiKey from env var NUXT_POSTHOG_API_KEY', async () => {
process.env.NUXT_POSTHOG_API_KEY = 'phc_from_env'
const drain = createPostHogLogsDrain()
await drain(createDrainContext())

const [, options] = fetchSpy.mock.calls[0] as [string, RequestInit]
expect(options.headers).toEqual(expect.objectContaining({
Authorization: 'Bearer phc_from_env',
}))
})

it('resolves apiKey from env var POSTHOG_API_KEY as fallback', async () => {
process.env.POSTHOG_API_KEY = 'phc_fallback'
const drain = createPostHogLogsDrain()
await drain(createDrainContext())

const [, options] = fetchSpy.mock.calls[0] as [string, RequestInit]
expect(options.headers).toEqual(expect.objectContaining({
Authorization: 'Bearer phc_fallback',
}))
})

it('overrides take priority over env vars', async () => {
process.env.NUXT_POSTHOG_API_KEY = 'phc_from_env'
const drain = createPostHogLogsDrain({ apiKey: 'phc_override' })
await drain(createDrainContext())

const [, options] = fetchSpy.mock.calls[0] as [string, RequestInit]
expect(options.headers).toEqual(expect.objectContaining({
Authorization: 'Bearer phc_override',
}))
})

it('logs error when apiKey is missing', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const drain = createPostHogLogsDrain()
await drain(createDrainContext())

expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('[evlog/posthog] Missing apiKey'),
)
expect(fetchSpy).not.toHaveBeenCalled()
})

it('uses custom host for EU region', async () => {
const drain = createPostHogLogsDrain({
apiKey: 'phc_test',
host: 'https://eu.i.posthog.com',
})
await drain(createDrainContext())

const [url] = fetchSpy.mock.calls[0] as [string, RequestInit]
expect(url).toBe('https://eu.i.posthog.com/i/v1/logs')
})

it('uses custom host for self-hosted', async () => {
const drain = createPostHogLogsDrain({
apiKey: 'phc_test',
host: 'https://posthog.mycompany.com',
})
await drain(createDrainContext())

const [url] = fetchSpy.mock.calls[0] as [string, RequestInit]
expect(url).toBe('https://posthog.mycompany.com/i/v1/logs')
})

it('handles host with trailing slash', async () => {
const drain = createPostHogLogsDrain({
apiKey: 'phc_test',
host: 'https://eu.i.posthog.com/',
})
await drain(createDrainContext())

const [url] = fetchSpy.mock.calls[0] as [string, RequestInit]
expect(url).toBe('https://eu.i.posthog.com/i/v1/logs')
})

it('resolves host from env var', async () => {
process.env.NUXT_POSTHOG_HOST = 'https://eu.i.posthog.com'
const drain = createPostHogLogsDrain({ apiKey: 'phc_test' })
await drain(createDrainContext())

const [url] = fetchSpy.mock.calls[0] as [string, RequestInit]
expect(url).toBe('https://eu.i.posthog.com/i/v1/logs')
})

it('uses custom timeout', async () => {
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout')
const drain = createPostHogLogsDrain({ apiKey: 'phc_test', timeout: 10000 })
await drain(createDrainContext())

expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 10000)
})

it('uses default timeout of 5000ms', async () => {
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout')
const drain = createPostHogLogsDrain({ apiKey: 'phc_test' })
await drain(createDrainContext())

expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 5000)
})

it('catches and logs errors from sendBatchToOTLP', async () => {
fetchSpy.mockResolvedValueOnce(
new Response('Internal Server Error', { status: 500, statusText: 'Internal Server Error' }),
)
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const drain = createPostHogLogsDrain({ apiKey: 'phc_test' })
await drain(createDrainContext())

expect(consoleSpy).toHaveBeenCalledWith(
'[evlog/posthog] Failed to send logs to PostHog Logs:',
expect.any(Error),
)
})
})
})
Loading