Skip to content
Closed
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
6 changes: 6 additions & 0 deletions .changeset/hip-planets-visit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@posthog/core': patch
'posthog-node': patch
---

Add an internal event-channel mechanism so `$ai_*` events can be routed to a dedicated capture endpoint in their own batch, independent of analytics events. Gated behind the unstable, internal-only `_internal_dedicatedAiEndpoint` option on `posthog-node` — not ready for general use.
64 changes: 55 additions & 9 deletions packages/core/src/posthog-core-stateless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,16 @@ export enum QuotaLimitedFeature {
Recordings = 'recordings',
}

export type EventChannel = {
queueKey: PostHogPersistedProperty
path: string
}

export const DEFAULT_EVENT_CHANNEL: EventChannel = {
queueKey: PostHogPersistedProperty.Queue,
path: '/batch/',
}

export abstract class PostHogCoreStateless {
// options
readonly apiKey: string
Expand Down Expand Up @@ -959,6 +969,23 @@ export abstract class PostHogCoreStateless {
// Default: no-op for sync storage implementations
}

/**
* The set of channels this client flushes. Override to add destinations; the
* returned list must include every channel {@link channelForEvent} can return,
* otherwise events routed to a missing channel would never flush.
*/
protected eventChannels(): readonly EventChannel[] {
return [DEFAULT_EVENT_CHANNEL]
}

/**
* Routes an event to its ingestion channel by name. Default sends everything
* to the main analytics channel; override to partition (e.g. `$ai_*`).
*/
protected channelForEvent(_event: unknown): EventChannel {
return DEFAULT_EVENT_CHANNEL
}

protected enqueue(type: string, _message: any, options?: PostHogCaptureOptions): void {
this.wrap(() => {
if (this.optedOut) {
Expand All @@ -974,15 +1001,16 @@ export abstract class PostHogCoreStateless {
return
}

const queue = this.getPersistedProperty<PostHogQueueItem[]>(PostHogPersistedProperty.Queue) || []
const queueKey = this.channelForEvent(message.event).queueKey
const queue = this.getPersistedProperty<PostHogQueueItem[]>(queueKey) || []

if (queue.length >= this.maxQueueSize) {
queue.shift()
this._logger.info('Queue is full, the oldest event is dropped.')
}

queue.push({ message })
this.setPersistedProperty<PostHogQueueItem[]>(PostHogPersistedProperty.Queue, queue)
this.setPersistedProperty<PostHogQueueItem[]>(queueKey, queue)

this._events.emit(type, message)

Expand Down Expand Up @@ -1032,7 +1060,7 @@ export abstract class PostHogCoreStateless {

const payload = JSON.stringify(data)

const url = `${this.host}/batch/`
const url = this.host + this.channelForEvent(message.event).path

const gzippedPayload = !this.disableCompression ? await gzipCompress(payload, this.isDebug) : null
const fetchOptions: PostHogFetchOptions = {
Expand Down Expand Up @@ -1170,7 +1198,23 @@ export abstract class PostHogCoreStateless {
this.clearFlushTimer()
await this._initPromise

let queue = this.getPersistedProperty<PostHogQueueItem[]>(PostHogPersistedProperty.Queue) || []
// Guarantee all flushes run but errors are still thrown at the end
let firstError: unknown
for (const channel of this.eventChannels()) {
try {
await this._flushChannel(channel)
} catch (err) {
firstError ??= err
}
}

if (firstError !== undefined) {
throw firstError
}
}

Comment on lines 1198 to +1215

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Second-channel error silently dropped when both channels fail

firstError ??= err captures only the first error encountered. If DEFAULT_EVENT_CHANNEL flushes successfully but AI_EVENT_CHANNEL throws, the error is rethrown correctly. However, if both channels fail (the analytics flush throws first), the AI channel's error is silently discarded. Callers see the analytics error and have no indication that AI events were also undelivered. Using AggregateError (or a simple array-join) would preserve both failure reasons without changing the throw semantics.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/core/src/posthog-core-stateless.ts
Line: 1198-1215

Comment:
**Second-channel error silently dropped when both channels fail**

`firstError ??= err` captures only the first error encountered. If `DEFAULT_EVENT_CHANNEL` flushes successfully but `AI_EVENT_CHANNEL` throws, the error is rethrown correctly. However, if both channels fail (the analytics flush throws first), the AI channel's error is silently discarded. Callers see the analytics error and have no indication that AI events were also undelivered. Using `AggregateError` (or a simple array-join) would preserve both failure reasons without changing the throw semantics.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah good point with AggregateError

@carlos-marchal-ph carlos-marchal-ph Jun 10, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AggregateError was my first intuition too but there are many things that depend on the error being a specific PostHog error class downstream, such as:

  • The public contract of flush guarantees it throws @throws PostHogFetchHttpError / PostHogFetchNetworkError, so it'd need a major bump for this change
  • Many flow control utilities check against the PostHog error classes specifically
  • Some other utils such as loggers and tests also check against class identity

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair points — the AggregateError suggestion would break the documented throws contract and the downstream class-identity checks. The current firstError ??= err behavior is a reasonable tradeoff, especially behind an @internal flag.

One lightweight option that preserves the existing contract while keeping the second error visible: log the dropped error rather than silently discarding it:

Suggested change
this.clearFlushTimer()
await this._initPromise
let queue = this.getPersistedProperty<PostHogQueueItem[]>(PostHogPersistedProperty.Queue) || []
// Guarantee all flushes run but errors are still thrown at the end
let firstError: unknown
for (const channel of this.eventChannels()) {
try {
await this._flushChannel(channel)
} catch (err) {
firstError ??= err
}
}
if (firstError !== undefined) {
throw firstError
}
}
// Guarantee all flushes run but errors are still thrown at the end
let firstError: unknown
for (const channel of this.eventChannels()) {
try {
await this._flushChannel(channel)
} catch (err) {
if (firstError !== undefined) {
this._events.emit('error', err)
}
firstError ??= err
}
}

This way callers still get the original PostHog error class from the first failure, but the second failure isn't completely invisible — it surfaces through the existing error event that the SDK already uses for non-fatal issues. No contract change, no class-identity breakage.

That said, if you're comfortable accepting the silent drop given the @internal gating, the current code is also fine as-is.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, lets add a TODO about this behavior then at least, so we can consider a refactor in the next major

private async _flushChannel(channel: EventChannel): Promise<void> {
let queue = this.getPersistedProperty<PostHogQueueItem[]>(channel.queueKey) || []

if (!queue.length) {
return
Expand All @@ -1184,9 +1228,9 @@ export abstract class PostHogCoreStateless {
const batchMessages = batchItems.map((item) => item.message)

const persistQueueChange = async (): Promise<void> => {
const refreshedQueue = this.getPersistedProperty<PostHogQueueItem[]>(PostHogPersistedProperty.Queue) || []
const refreshedQueue = this.getPersistedProperty<PostHogQueueItem[]>(channel.queueKey) || []
const newQueue = refreshedQueue.slice(batchItems.length)
this.setPersistedProperty<PostHogQueueItem[]>(PostHogPersistedProperty.Queue, newQueue)
this.setPersistedProperty<PostHogQueueItem[]>(channel.queueKey, newQueue)
queue = newQueue
// Wait for storage to complete to prevent duplicate events on app crash
await this.flushStorage()
Expand All @@ -1204,7 +1248,7 @@ export abstract class PostHogCoreStateless {

const payload = JSON.stringify(data)

const url = `${this.host}/batch/`
const url = this.host + channel.path

const gzippedPayload = !this.disableCompression ? await gzipCompress(payload, this.isDebug) : null
const fetchOptions: PostHogFetchOptions = {
Expand Down Expand Up @@ -1383,9 +1427,11 @@ export abstract class PostHogCoreStateless {
await this.promiseQueue.join()

while (true) {
const queue = this.getPersistedProperty<PostHogQueueItem[]>(PostHogPersistedProperty.Queue) || []
const pending = this.eventChannels().some(
(channel) => (this.getPersistedProperty<PostHogQueueItem[]>(channel.queueKey) || []).length > 0
)

if (queue.length === 0) {
if (!pending) {
break
}

Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,9 @@ export enum PostHogPersistedProperty {
BootstrapFeatureFlagPayloads = 'bootstrap_feature_flag_payloads',
OverrideFeatureFlags = 'override_feature_flags',
Queue = 'queue',
// AI events queue. `$ai_*` events are routed here so they flush to a dedicated
// ingestion path independently of the main analytics queue. Only used by posthog-node.
AiQueue = 'ai_queue',
// Logs queue. Individual SDKs may route this key to an isolated storage
// instance if they want to separate logs write volume from main state.
LogsQueue = 'logs_queue',
Expand Down
79 changes: 79 additions & 0 deletions packages/node/src/__tests__/dedicated-ai-endpoint.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { PostHog, PostHogOptions } from '@/entrypoints/index.node'

const mockedFetch = jest.spyOn(globalThis, 'fetch').mockImplementation()

const HOST = 'http://example.com'
const ANALYTICS_URL = `${HOST}/batch/`
const AI_URL = `${HOST}/i/v0/ai/batch/`

const okResponse = {
status: 200,
text: () => Promise.resolve('ok'),
json: () => Promise.resolve({ status: 'ok' }),
} as any

// Exact-match — AI_URL contains "/batch/" as a substring, so substring matching would conflate them.
const callsTo = (url: string): any[] => mockedFetch.mock.calls.filter((c) => c[0] === url)

const batchSentTo = (url: string): any[] | undefined => {
const call = callsTo(url).at(-1)
return call ? JSON.parse((call[1] as any).body).batch : undefined
}

const newClient = (options: Partial<PostHogOptions> = {}): PostHog =>
new PostHog('TEST_API_KEY', { host: HOST, fetchRetryCount: 0, disableCompression: true, ...options })

describe('PostHog Node.js — dedicated AI endpoint (_internal_dedicatedAiEndpoint)', () => {
let errorSpy: jest.SpyInstance

beforeEach(() => {
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
mockedFetch.mockResolvedValue(okResponse)
})

afterEach(() => {
mockedFetch.mockReset()
errorSpy.mockRestore()
})

describe('when enabled', () => {
it('routes batched $ai_* events to the dedicated AI path', async () => {
const ph = newClient({ _internal_dedicatedAiEndpoint: true })
ph.capture({ distinctId: 'u1', event: '$ai_generation', properties: { $ai_model: 'gpt-4' } })
await ph.shutdown()

expect(batchSentTo(AI_URL)?.map((e: any) => e.event)).toEqual(['$ai_generation'])
expect(callsTo(ANALYTICS_URL)).toHaveLength(0)
})

it('keeps non-AI events on the normal path, in a separate batch from AI events', async () => {
const ph = newClient({ _internal_dedicatedAiEndpoint: true })
ph.capture({ distinctId: 'u1', event: '$ai_generation', properties: {} })
ph.capture({ distinctId: 'u1', event: 'button_clicked', properties: {} })
await ph.shutdown()

expect(batchSentTo(AI_URL)?.map((e: any) => e.event)).toEqual(['$ai_generation'])
expect(batchSentTo(ANALYTICS_URL)?.map((e: any) => e.event)).toEqual(['button_clicked'])
})

it('routes immediate $ai_* captures to the dedicated AI path', async () => {
const ph = newClient({ _internal_dedicatedAiEndpoint: true })
await ph.captureImmediate({ distinctId: 'u1', event: '$ai_embedding', properties: {} })

expect(batchSentTo(AI_URL)?.[0].event).toBe('$ai_embedding')
expect(callsTo(ANALYTICS_URL)).toHaveLength(0)
await ph.shutdown()
})
})

describe('when disabled (default)', () => {
it('routes $ai_* events to the normal batch path', async () => {
const ph = newClient()
ph.capture({ distinctId: 'u1', event: '$ai_generation', properties: {} })
await ph.shutdown()

expect(batchSentTo(ANALYTICS_URL)?.map((e: any) => e.event)).toEqual(['$ai_generation'])
expect(callsTo(AI_URL)).toHaveLength(0)
})
Comment on lines +40 to +77

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Prefer parameterised tests for the routing cases

The first test ("routes batched $ai_* events to the dedicated AI path") and the last test ("routes $ai_* events to the normal batch path when disabled") cover the exact same scenario — one $ai_generation event captured via capture() / shutdown() — differing only in the _internal_dedicatedAiEndpoint flag and the expected URL. Per the team's style, these should be consolidated into a single it.each table. Similarly, a disabled-path counterpart for the captureImmediate case is absent; adding it to an it.each would close that coverage gap at the same time.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/node/src/__tests__/dedicated-ai-endpoint.spec.ts
Line: 40-77

Comment:
**Prefer parameterised tests for the routing cases**

The first test ("routes batched `$ai_*` events to the dedicated AI path") and the last test ("routes `$ai_*` events to the normal batch path when disabled") cover the exact same scenario — one `$ai_generation` event captured via `capture()` / `shutdown()` — differing only in the `_internal_dedicatedAiEndpoint` flag and the expected URL. Per the team's style, these should be consolidated into a single `it.each` table. Similarly, a disabled-path counterpart for the `captureImmediate` case is absent; adding it to an `it.each` would close that coverage gap at the same time.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

})
})
20 changes: 20 additions & 0 deletions packages/node/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { version } from './version'

import {
EventChannel,
FeatureFlagValue,
isBlockedUA,
isPlainObject,
Expand Down Expand Up @@ -56,6 +57,11 @@ const WAITUNTIL_DEBOUNCE_MS = 50
const WAITUNTIL_MAX_WAIT_MS = 500
const DEFAULT_NODE_HOST = 'https://us.i.posthog.com'

const AI_EVENT_CHANNEL: EventChannel = {
queueKey: PostHogPersistedProperty.AiQueue,
path: '/i/v0/ai/batch/',
}

// Process-wide dedup for deprecation warnings — without this, calling a deprecated
// method in a loop would spam logs. Matches Python's `warnings.warn` default-dedup behavior.
const _emittedDeprecations = new Set<string>()
Expand Down Expand Up @@ -134,6 +140,8 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen
timer: ReturnType<typeof setTimeout> | undefined
}

private readonly _useDedicatedAiEndpoint: boolean

/**
* Initialize a new PostHog client instance.
*
Expand Down Expand Up @@ -174,6 +182,7 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen
super(normalizedApiKey, normalizedOptions)

this.options = normalizedOptions
this._useDedicatedAiEndpoint = this.options._internal_dedicatedAiEndpoint === true
this.context = this.initializeContext()

this.options.featureFlagsPollingInterval =
Expand Down Expand Up @@ -224,6 +233,17 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen
this.maxCacheSize = normalizedOptions.maxCacheSize || MAX_CACHE_SIZE
}

protected override eventChannels(): readonly EventChannel[] {
return this._useDedicatedAiEndpoint ? [...super.eventChannels(), AI_EVENT_CHANNEL] : super.eventChannels()
}

protected override channelForEvent(event: unknown): EventChannel {
if (this._useDedicatedAiEndpoint && typeof event === 'string' && event.startsWith('$ai_')) {
return AI_EVENT_CHANNEL
}
return super.channelForEvent(event)
}

protected override enqueue(type: string, message: any, options?: PostHogCaptureOptions): void {
super.enqueue(type, message, options)
this.scheduleDebouncedFlush()
Expand Down
7 changes: 7 additions & 0 deletions packages/node/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,13 @@ export type PostHogOptions = Omit<PostHogCoreOptions, 'before_send'> & {
persistence?: 'memory'
personalApiKey?: string
privacyMode?: boolean
/**
* Routes `$ai_*` events to a dedicated capture-ai endpoint in their own batch.
*
* @internal Not ready for use — the backend endpoint and ingress routing are still being
* rolled out. Do not enable; behaviour and naming may change or be removed without notice.
*/
_internal_dedicatedAiEndpoint?: boolean
Comment on lines +138 to +144

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How will we roll this out? asking users to enable _internal_dedicatedAiEndpoint?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea was to test this with test projects or team 2 in prod, and once ready just remove the guard and ship a new minor.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is the goal to test the SDK or the backend?
if just the backend, we can do this with the python SDK first and here we dont need that, but a full swap
if the goal is to test also the SDK, that means we will need something similar for every SDK
if the HTTP contract is the same, i dont think we need that tbh, its just important that the backend is battletested

enableExceptionAutocapture?: boolean
// The interval in milliseconds between polls for refreshing feature flag definitions. Defaults to 30 seconds.
featureFlagsPollingInterval?: number
Expand Down
Loading