refactor(analytics): replace PostHog with OpenPanel (#2480)
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
import { createServer } from 'node:http'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { createOpenpanelSink } from './openpanel'
|
||||
|
||||
describe('openPanel sink', () => {
|
||||
it('isolates concurrent user identities and preserves checkout device attribution', async () => {
|
||||
const bodies: string[] = []
|
||||
const server = createServer(async (request, response) => {
|
||||
let body = ''
|
||||
for await (const chunk of request)
|
||||
body += chunk
|
||||
bodies.push(body)
|
||||
response.writeHead(200).end('{}')
|
||||
})
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string')
|
||||
throw new Error('Expected a TCP server address')
|
||||
try {
|
||||
const sink = createOpenpanelSink({ apiUrl: `http://127.0.0.1:${address.port}`, clientId: 'test-client', clientSecret: 'test-secret' })
|
||||
await Promise.all([
|
||||
sink.capture({ userId: 'alice', event: 'payment_completed', deviceId: 'alice-device', properties: { event_id: 'cs_alice' } }),
|
||||
sink.capture({ userId: 'bob', event: 'signup_completed', properties: {} }),
|
||||
])
|
||||
expect(bodies.map(body => JSON.parse(body))).toEqual(expect.arrayContaining([
|
||||
{ type: 'track', payload: { name: 'payment_completed', profileId: 'alice', properties: { event_id: 'cs_alice', __deviceId: 'alice-device' } } },
|
||||
{ type: 'track', payload: { name: 'signup_completed', profileId: 'bob', properties: {} } },
|
||||
]))
|
||||
}
|
||||
finally {
|
||||
server.closeAllConnections()
|
||||
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
|
||||
}
|
||||
})
|
||||
|
||||
it('does not retry a rejected conversion request', async () => {
|
||||
let requests = 0
|
||||
const server = createServer((_request, response) => {
|
||||
requests++
|
||||
response.writeHead(503).end('Unavailable')
|
||||
})
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string')
|
||||
throw new Error('Expected a TCP server address')
|
||||
try {
|
||||
const sink = createOpenpanelSink({ apiUrl: `http://127.0.0.1:${address.port}`, clientId: 'test-client', clientSecret: 'test-secret' })
|
||||
await expect(sink.capture({ userId: 'alice', event: 'payment_completed', properties: {} })).rejects.toThrow('503')
|
||||
expect(requests).toBe(1)
|
||||
}
|
||||
finally {
|
||||
server.closeAllConnections()
|
||||
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { TrackHandlerPayload } from '@openpanel/sdk'
|
||||
|
||||
/** One confirmed product fact, with an optional browser device for attribution. */
|
||||
export interface ProductCaptureInput {
|
||||
userId: string
|
||||
event: string
|
||||
properties: Record<string, unknown>
|
||||
deviceId?: string
|
||||
}
|
||||
|
||||
/** External delivery boundary for confirmed product facts. */
|
||||
export interface ProductAnalyticsSink {
|
||||
capture: (input: ProductCaptureInput) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends each fact once. Callers own replay suppression through business state.
|
||||
* Each request supplies its user id, so concurrent users never share SDK identity.
|
||||
*/
|
||||
export function createOpenpanelSink(options: { clientId: string, clientSecret: string, apiUrl: string }): ProductAnalyticsSink {
|
||||
const endpoint = `${options.apiUrl.replace(/\/$/, '')}/track`
|
||||
|
||||
return {
|
||||
async capture(input) {
|
||||
const payload: TrackHandlerPayload = {
|
||||
type: 'track',
|
||||
payload: {
|
||||
name: input.event,
|
||||
profileId: input.userId,
|
||||
properties: {
|
||||
...input.properties,
|
||||
...(input.deviceId && { __deviceId: input.deviceId }),
|
||||
},
|
||||
},
|
||||
}
|
||||
// OpenPanel does not deduplicate events by a caller-supplied UUID. Retrying an
|
||||
// ambiguous response can count a paid conversion twice.
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'openpanel-client-id': options.clientId,
|
||||
'openpanel-client-secret': options.clientSecret,
|
||||
'openpanel-sdk-name': 'node',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
if (!response.ok)
|
||||
throw new Error(`OpenPanel rejected the product event (${response.status})`)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { useLogger } from '@guiiai/logg'
|
||||
import { PostHog } from 'posthog-node'
|
||||
|
||||
const logger = useLogger('posthog')
|
||||
|
||||
/**
|
||||
* One product event forwarded to PostHog, keyed by the Better Auth user id
|
||||
* so it merges with the browser person identified via `posthog.identify()`.
|
||||
*/
|
||||
export interface PosthogCaptureInput {
|
||||
distinctId: string
|
||||
event: string
|
||||
properties: Record<string, unknown>
|
||||
/** Stable event UUID used by PostHog ingestion for replay deduplication. */
|
||||
uuid?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal capture boundary the product-events service depends on. Kept as
|
||||
* an interface so tests inject a fake instead of mocking the SDK.
|
||||
*/
|
||||
export interface PosthogSink {
|
||||
/**
|
||||
* Queue a high-volume analytics event without waiting for a network
|
||||
* roundtrip. Use on request hot paths where occasional process-exit loss is
|
||||
* preferable to user-visible latency.
|
||||
*/
|
||||
captureQueued?: (input: PosthogCaptureInput) => void
|
||||
capture: (input: PosthogCaptureInput) => Promise<void>
|
||||
/** Flush and close the underlying client. Call on server shutdown. */
|
||||
shutdown: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* PostHog sink for server-side product events.
|
||||
*
|
||||
* Low-frequency conversion facts use `captureImmediate` (one HTTP roundtrip
|
||||
* per event) because they terminate money/auth funnels. High-frequency AI
|
||||
* generation facts use `captureQueued`, which is buffered by the SDK and
|
||||
* flushed on shutdown, so chat completion requests don't wait on PostHog.
|
||||
*
|
||||
* Capture failures are logged and swallowed. Analytics forwarding never
|
||||
* fails the Stripe webhook or auth flow that produced the business fact.
|
||||
*/
|
||||
export function createPosthogSink(options: { projectKey: string, host: string }): PosthogSink {
|
||||
const client = new PostHog(options.projectKey, { host: options.host })
|
||||
|
||||
return {
|
||||
captureQueued(input: PosthogCaptureInput): void {
|
||||
try {
|
||||
client.capture({
|
||||
distinctId: input.distinctId,
|
||||
event: input.event,
|
||||
properties: input.properties,
|
||||
...(input.uuid && { uuid: input.uuid }),
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
logger.withError(err).withFields({ event: input.event }).warn('Failed to enqueue product event to PostHog')
|
||||
}
|
||||
},
|
||||
|
||||
async capture(input: PosthogCaptureInput): Promise<void> {
|
||||
try {
|
||||
await client.captureImmediate({
|
||||
distinctId: input.distinctId,
|
||||
event: input.event,
|
||||
properties: input.properties,
|
||||
...(input.uuid && { uuid: input.uuid }),
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
logger.withError(err).withFields({ event: input.event }).warn('Failed to forward product event to PostHog')
|
||||
}
|
||||
},
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
await client.shutdown()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,181 +1,67 @@
|
||||
import type { ProductCaptureInput } from '../adapters/openpanel'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createProductEventService } from './product-events'
|
||||
|
||||
describe('productEventService', () => {
|
||||
it('captures only the server-side funnel facts shared with the Go service', async () => {
|
||||
const capture = vi.fn(async () => {})
|
||||
const service = createProductEventService({ capture, shutdown: vi.fn(async () => {}) })
|
||||
|
||||
await service.track({
|
||||
userId: 'user-1',
|
||||
feature: 'auth',
|
||||
action: 'user_signed_up',
|
||||
status: 'succeeded',
|
||||
})
|
||||
await service.track({
|
||||
userId: 'user-1',
|
||||
feature: 'billing',
|
||||
action: 'checkout_started',
|
||||
status: 'succeeded',
|
||||
source: 'stripe.checkout',
|
||||
})
|
||||
await service.track({
|
||||
userId: 'user-1',
|
||||
feature: 'billing',
|
||||
action: 'payment_completed',
|
||||
status: 'succeeded',
|
||||
source: 'stripe.webhook',
|
||||
metadata: { amount_minor_unit: 990, currency: 'usd' },
|
||||
})
|
||||
it('routes confirmed product facts to OpenPanel with stable AIRI user ids', async () => {
|
||||
const capture = vi.fn<(input: ProductCaptureInput) => Promise<void>>().mockResolvedValue(undefined)
|
||||
const service = createProductEventService({ capture })
|
||||
await service.track({ userId: 'user-1', feature: 'auth', action: 'user_signed_up', status: 'succeeded' })
|
||||
await service.track({ userId: 'user-2', feature: 'billing', action: 'checkout_started', status: 'succeeded' })
|
||||
|
||||
expect(capture).toHaveBeenNthCalledWith(1, {
|
||||
distinctId: 'user-1',
|
||||
userId: 'user-1',
|
||||
event: 'signup_completed',
|
||||
properties: {
|
||||
app_surface: 'server',
|
||||
airi_user_id: 'user-1',
|
||||
feature: 'auth',
|
||||
status: 'succeeded',
|
||||
},
|
||||
})
|
||||
expect(capture).toHaveBeenNthCalledWith(2, {
|
||||
distinctId: 'user-1',
|
||||
event: 'checkout_created',
|
||||
properties: {
|
||||
app_surface: 'server',
|
||||
airi_user_id: 'user-1',
|
||||
feature: 'billing',
|
||||
status: 'succeeded',
|
||||
source: 'stripe.checkout',
|
||||
},
|
||||
})
|
||||
expect(capture).toHaveBeenNthCalledWith(3, {
|
||||
distinctId: 'user-1',
|
||||
event: 'payment_completed',
|
||||
properties: {
|
||||
app_surface: 'server',
|
||||
airi_user_id: 'user-1',
|
||||
feature: 'billing',
|
||||
status: 'succeeded',
|
||||
source: 'stripe.webhook',
|
||||
amount_minor_unit: 990,
|
||||
currency: 'usd',
|
||||
},
|
||||
properties: { app_surface: 'server', airi_user_id: 'user-1', feature: 'auth', status: 'succeeded' },
|
||||
})
|
||||
expect(capture).toHaveBeenNthCalledWith(2, expect.objectContaining({ userId: 'user-2', event: 'checkout_created' }))
|
||||
})
|
||||
|
||||
it('merges a Stripe conversion with its browser PostHog person', async () => {
|
||||
const capture = vi.fn(async () => {})
|
||||
const service = createProductEventService({ capture, shutdown: vi.fn(async () => {}) })
|
||||
|
||||
it('passes the checkout device id and source event id without an extra identify event', async () => {
|
||||
const capture = vi.fn<(input: ProductCaptureInput) => Promise<void>>().mockResolvedValue(undefined)
|
||||
const service = createProductEventService({ capture })
|
||||
await service.track({
|
||||
userId: 'user-1',
|
||||
feature: 'billing',
|
||||
action: 'payment_completed',
|
||||
status: 'succeeded',
|
||||
eventId: 'cs_123',
|
||||
metadata: {
|
||||
posthog_distinct_id: 'anon-browser-1',
|
||||
posthog_session_id: 'ph-session-1',
|
||||
},
|
||||
metadata: { openpanel_device_id: 'browser-1', amount_total: 990, currency: 'usd' },
|
||||
})
|
||||
|
||||
expect(capture).toHaveBeenNthCalledWith(1, {
|
||||
distinctId: 'user-1',
|
||||
event: '$identify',
|
||||
uuid: expect.stringMatching(/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/),
|
||||
properties: {
|
||||
$insert_id: 'cs_123',
|
||||
$anon_distinct_id: 'anon-browser-1',
|
||||
$session_id: 'ph-session-1',
|
||||
airi_user_id: 'user-1',
|
||||
},
|
||||
})
|
||||
expect(capture).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||
distinctId: 'user-1',
|
||||
event: 'payment_completed',
|
||||
uuid: expect.stringMatching(/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/),
|
||||
properties: expect.objectContaining({ $insert_id: 'cs_123' }),
|
||||
}))
|
||||
})
|
||||
|
||||
it('uses a stable PostHog UUID for replayed conversion captures', async () => {
|
||||
const capture = vi.fn(async () => {})
|
||||
const service = createProductEventService({ capture, shutdown: vi.fn(async () => {}) })
|
||||
const input = {
|
||||
userId: 'user-1' as const,
|
||||
feature: 'billing' as const,
|
||||
action: 'payment_completed' as const,
|
||||
status: 'succeeded' as const,
|
||||
eventId: 'cs_replayed',
|
||||
}
|
||||
|
||||
await service.track(input)
|
||||
await service.track(input)
|
||||
|
||||
expect(capture).toHaveBeenCalledTimes(2)
|
||||
const captures = capture.mock.calls as unknown as Array<[
|
||||
{
|
||||
uuid?: string
|
||||
properties: Record<string, unknown>
|
||||
},
|
||||
]>
|
||||
const first = captures[0]![0]
|
||||
const replay = captures[1]![0]
|
||||
expect(first.uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
|
||||
expect(replay.uuid).toBe(first.uuid)
|
||||
expect(first.properties.$insert_id).toBe('cs_replayed')
|
||||
})
|
||||
|
||||
it('rejects metadata that can overwrite service-controlled PostHog properties', async () => {
|
||||
const capture = vi.fn(async () => {})
|
||||
const service = createProductEventService({ capture, shutdown: vi.fn(async () => {}) })
|
||||
|
||||
await expect(service.track({
|
||||
expect(capture).toHaveBeenCalledTimes(1)
|
||||
expect(capture).toHaveBeenCalledWith({
|
||||
userId: 'user-1',
|
||||
feature: 'billing',
|
||||
action: 'payment_completed',
|
||||
status: 'succeeded',
|
||||
metadata: { $insert_id: 'spoofed' },
|
||||
})).resolves.toBeUndefined()
|
||||
deviceId: 'browser-1',
|
||||
event: 'payment_completed',
|
||||
properties: {
|
||||
openpanel_device_id: 'browser-1',
|
||||
amount_total: 990,
|
||||
currency: 'usd',
|
||||
event_id: 'cs_123',
|
||||
app_surface: 'server',
|
||||
airi_user_id: 'user-1',
|
||||
feature: 'billing',
|
||||
status: 'succeeded',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects metadata that can forge provider identity or the source event id', async () => {
|
||||
const capture = vi.fn(async () => {})
|
||||
const service = createProductEventService({ capture })
|
||||
for (const key of ['event_id', '__deviceId', 'profileId']) {
|
||||
await service.track({ userId: 'user-1', feature: 'billing', action: 'payment_completed', status: 'succeeded', metadata: { [key]: 'forged' } })
|
||||
}
|
||||
expect(capture).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still captures the funnel event when identity merging fails', async () => {
|
||||
const capture = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('identify failed'))
|
||||
.mockResolvedValueOnce(undefined)
|
||||
const service = createProductEventService({ capture, shutdown: vi.fn(async () => {}) })
|
||||
|
||||
await expect(service.track({
|
||||
userId: 'user-1',
|
||||
feature: 'billing',
|
||||
action: 'payment_completed',
|
||||
status: 'succeeded',
|
||||
eventId: 'cs_456',
|
||||
metadata: { posthog_distinct_id: 'anon-browser-1' },
|
||||
})).resolves.toBeUndefined()
|
||||
|
||||
expect(capture).toHaveBeenCalledTimes(2)
|
||||
expect(capture).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||
event: 'payment_completed',
|
||||
properties: expect.objectContaining({ $insert_id: 'cs_456' }),
|
||||
}))
|
||||
})
|
||||
|
||||
it('does not fail a business path when capture throws', async () => {
|
||||
it('does not fail the business path when OpenPanel is unavailable', async () => {
|
||||
const capture = vi.fn(async () => {
|
||||
throw new Error('posthog exploded')
|
||||
throw new Error('unavailable')
|
||||
})
|
||||
const service = createProductEventService({ capture, shutdown: vi.fn(async () => {}) })
|
||||
|
||||
await expect(service.track({
|
||||
userId: 'user-1',
|
||||
feature: 'billing',
|
||||
action: 'payment_completed',
|
||||
status: 'succeeded',
|
||||
})).resolves.toBeUndefined()
|
||||
const service = createProductEventService({ capture })
|
||||
await expect(service.track({ userId: 'user-1', feature: 'billing', action: 'payment_completed', status: 'succeeded' })).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import type { PosthogSink } from '../adapters/posthog'
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { ProductAnalyticsSink } from '../adapters/openpanel'
|
||||
|
||||
import { useLogger } from '@guiiai/logg'
|
||||
|
||||
const logger = useLogger('product-events')
|
||||
|
||||
const RESERVED_POSTHOG_METADATA_KEYS = new Set([
|
||||
'$insert_id',
|
||||
const RESERVED_PRODUCT_METADATA_KEYS = new Set([
|
||||
'event_id',
|
||||
'__deviceId',
|
||||
'__identify',
|
||||
'profileId',
|
||||
'$session_id',
|
||||
'airi_user_id',
|
||||
'app_surface',
|
||||
@@ -27,9 +28,9 @@ export type ProductAction
|
||||
| 'checkout_started'
|
||||
| 'payment_completed'
|
||||
|
||||
/** Product funnel fact forwarded to PostHog from the server. */
|
||||
/** Product funnel fact forwarded to OpenPanel from the server. */
|
||||
export interface ProductEventInput {
|
||||
/** Better Auth user id. Kept in Postgres only; never emitted as a Prometheus label. */
|
||||
/** Authenticated user id used for product attribution. Never emitted as a Prometheus label. */
|
||||
userId: string
|
||||
/** Bounded product area used for product dashboards and funnels. */
|
||||
feature: ProductFeature
|
||||
@@ -41,58 +42,19 @@ export interface ProductEventInput {
|
||||
source?: string
|
||||
/** Optional primitive metadata for product analysis. Avoid PII and raw prompts. */
|
||||
metadata?: ProductEventMetadata
|
||||
/** Stable source event id used by PostHog for replay-safe deduplication. */
|
||||
/** Stable source event id for reconciliation. Callers suppress replays before capture. */
|
||||
eventId?: string
|
||||
}
|
||||
|
||||
/** Product runtime where the user initiated the AI generation. */
|
||||
export type AiGenerationAppSurface = 'web' | 'mobile' | 'electron'
|
||||
|
||||
/** Runtime that captured the `$ai_generation` fact. */
|
||||
export type AiGenerationCaptureSurface = 'server' | 'client'
|
||||
|
||||
/** Explains whether `conversation_id` is an app conversation or a server fallback. */
|
||||
export type AiGenerationConversationIdSource = 'client_header' | 'server_request'
|
||||
|
||||
/** Explains whether AIRI supplied a trustworthy USD cost for this generation. */
|
||||
export type AiGenerationCostUsdSource = 'reported' | 'estimated' | 'unavailable'
|
||||
|
||||
/** Content-free PostHog AI generation fact keyed to the authenticated user. */
|
||||
export interface AiGenerationEventInput {
|
||||
userId: string
|
||||
traceId: string
|
||||
generationId: string
|
||||
model: string
|
||||
provider: string
|
||||
providerType: 'official' | 'custom' | 'unknown'
|
||||
usageSource: 'reported' | 'estimated' | 'unavailable'
|
||||
inputTokens?: number
|
||||
outputTokens?: number
|
||||
totalTokens?: number
|
||||
totalCostUsd?: number
|
||||
costUsdSource?: AiGenerationCostUsdSource
|
||||
/** Always present for joins; `conversationIdSource` tells whether it is request-level fallback. */
|
||||
conversationId: string
|
||||
/** Distinguishes real client conversation ids from server-generated request fallbacks. */
|
||||
conversationIdSource: AiGenerationConversationIdSource
|
||||
roundId?: string
|
||||
/** Omitted when the server cannot determine the user's product runtime. */
|
||||
appSurface?: AiGenerationAppSurface
|
||||
/** Defaults to `server` because this service runs in the API process. */
|
||||
captureSurface?: AiGenerationCaptureSurface
|
||||
latencySeconds?: number
|
||||
stream?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side actions that anchor a PostHog product funnel. Per-request LLM
|
||||
* Server-side actions that anchor the product funnel. Per-request LLM
|
||||
* and TTS telemetry stays in operational systems and does not enter this path.
|
||||
*
|
||||
* `user_signed_up` maps to `signup_completed` because the identified server
|
||||
* hook is the canonical registration fact for every signup method. Anonymous
|
||||
* auth UI progress uses `signup_form_completed` and never reuses this name.
|
||||
*/
|
||||
const POSTHOG_FORWARDED_ACTIONS: Partial<Record<ProductAction, string>> = {
|
||||
const FORWARDED_ACTIONS: Partial<Record<ProductAction, string>> = {
|
||||
user_signed_up: 'signup_completed',
|
||||
checkout_started: 'checkout_created',
|
||||
payment_completed: 'payment_completed',
|
||||
@@ -103,20 +65,12 @@ function stringMetadata(input: ProductEventInput, key: string): string | undefin
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function posthogEventUuid(event: string, eventId: string): string {
|
||||
const digest = createHash('sha256').update(`airi:posthog:${event}:${eventId}`, 'utf8').digest()
|
||||
digest[6] = (digest[6] & 0x0F) | 0x50
|
||||
digest[8] = (digest[8] & 0x3F) | 0x80
|
||||
const hex = digest.subarray(0, 16).toString('hex')
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
||||
}
|
||||
|
||||
function hasReservedMetadataKey(metadata: ProductEventMetadata | undefined): boolean {
|
||||
return metadata != null && Object.keys(metadata).some(key => RESERVED_POSTHOG_METADATA_KEYS.has(key))
|
||||
return metadata != null && Object.keys(metadata).some(key => RESERVED_PRODUCT_METADATA_KEYS.has(key))
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates AIRI's server-side PostHog product analytics writer.
|
||||
* Sends confirmed product facts to OpenPanel.
|
||||
*
|
||||
* Use when:
|
||||
* - A server has an authenticated user id and confirms a funnel fact.
|
||||
@@ -128,102 +82,38 @@ function hasReservedMetadataKey(metadata: ProductEventMetadata | undefined): boo
|
||||
* Returns:
|
||||
* - A best-effort event writer. Capture errors never change the business flow.
|
||||
*/
|
||||
export function createProductEventService(posthog?: PosthogSink | null) {
|
||||
export function createProductEventService(sink?: ProductAnalyticsSink | null) {
|
||||
return {
|
||||
trackGeneration(input: AiGenerationEventInput): void {
|
||||
if (!posthog)
|
||||
return
|
||||
|
||||
const event = {
|
||||
distinctId: input.userId,
|
||||
event: '$ai_generation',
|
||||
properties: {
|
||||
$ai_trace_id: input.traceId,
|
||||
$ai_session_id: input.conversationId,
|
||||
$ai_span_id: input.generationId,
|
||||
$ai_model: input.model,
|
||||
$ai_provider: input.provider,
|
||||
...(input.inputTokens != null && { $ai_input_tokens: input.inputTokens }),
|
||||
...(input.outputTokens != null && { $ai_output_tokens: input.outputTokens }),
|
||||
...(input.totalTokens != null && { $ai_total_tokens: input.totalTokens }),
|
||||
...(input.totalCostUsd != null && { $ai_total_cost_usd: input.totalCostUsd }),
|
||||
...(input.latencySeconds != null && { $ai_latency: input.latencySeconds }),
|
||||
...(input.stream != null && { $ai_stream: input.stream }),
|
||||
$insert_id: `ai-generation:${input.generationId}`,
|
||||
airi_user_id: input.userId,
|
||||
provider_type: input.providerType,
|
||||
usage_source: input.usageSource,
|
||||
token_usage_available: input.usageSource !== 'unavailable',
|
||||
cost_usd_source: input.costUsdSource ?? 'unavailable',
|
||||
cost_usd_known: input.totalCostUsd != null,
|
||||
conversation_id: input.conversationId,
|
||||
conversation_id_source: input.conversationIdSource,
|
||||
...(input.roundId && { round_id: input.roundId }),
|
||||
...(input.appSurface && { app_surface: input.appSurface }),
|
||||
capture_surface: input.captureSurface ?? 'server',
|
||||
},
|
||||
}
|
||||
|
||||
if (posthog.captureQueued) {
|
||||
posthog.captureQueued(event)
|
||||
return
|
||||
}
|
||||
|
||||
void posthog.capture(event)
|
||||
.catch(err => logger.withError(err).withFields({ generationId: input.generationId }).warn('Failed to capture PostHog AI generation'))
|
||||
},
|
||||
|
||||
async track(input: ProductEventInput): Promise<void> {
|
||||
const forwardedEvent = POSTHOG_FORWARDED_ACTIONS[input.action]
|
||||
if (!posthog || !forwardedEvent)
|
||||
const forwardedEvent = FORWARDED_ACTIONS[input.action]
|
||||
if (!sink || !forwardedEvent)
|
||||
return
|
||||
|
||||
if (hasReservedMetadataKey(input.metadata)) {
|
||||
logger.withFields({ action: input.action }).warn('Rejected reserved PostHog product event metadata')
|
||||
logger.withFields({ action: input.action }).warn('Rejected reserved product event metadata')
|
||||
return
|
||||
}
|
||||
|
||||
const posthogDistinctId = stringMetadata(input, 'posthog_distinct_id')
|
||||
const posthogSessionId = stringMetadata(input, 'posthog_session_id')
|
||||
if (posthogDistinctId && posthogDistinctId !== input.userId) {
|
||||
try {
|
||||
await posthog.capture({
|
||||
distinctId: input.userId,
|
||||
event: '$identify',
|
||||
properties: {
|
||||
...(input.eventId && { $insert_id: input.eventId }),
|
||||
$anon_distinct_id: posthogDistinctId,
|
||||
airi_user_id: input.userId,
|
||||
...(posthogSessionId && { $session_id: posthogSessionId }),
|
||||
},
|
||||
...(input.eventId && { uuid: posthogEventUuid('$identify', input.eventId) }),
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
logger.withError(err).withFields({ action: input.action }).warn('PostHog anonymous identity capture failed')
|
||||
}
|
||||
}
|
||||
const deviceId = stringMetadata(input, 'openpanel_device_id')
|
||||
|
||||
try {
|
||||
await posthog.capture({
|
||||
distinctId: input.userId,
|
||||
await sink.capture({
|
||||
userId: input.userId,
|
||||
...(deviceId && { deviceId }),
|
||||
event: forwardedEvent,
|
||||
properties: {
|
||||
...input.metadata,
|
||||
...(input.eventId && { $insert_id: input.eventId }),
|
||||
...(input.eventId && { event_id: input.eventId }),
|
||||
app_surface: 'server',
|
||||
airi_user_id: input.userId,
|
||||
...(posthogDistinctId && { posthog_distinct_id: posthogDistinctId }),
|
||||
...(posthogSessionId && { $session_id: posthogSessionId }),
|
||||
feature: input.feature,
|
||||
status: input.status,
|
||||
...(input.source && { source: input.source }),
|
||||
},
|
||||
...(input.eventId && { uuid: posthogEventUuid(forwardedEvent, input.eventId) }),
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
logger.withError(err).withFields({ action: input.action }).warn('PostHog product analytics capture failed')
|
||||
logger.withError(err).withFields({ action: input.action }).warn('OpenPanel product analytics capture failed')
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user