feat(analytics): integrate PostHog for server-side event tracking

- Added a new PostHog client for capturing server-side business events such as Stripe webhooks and subscription state changes.
- Implemented various tracking functions for pricing funnel steps, character creation, and chat session starts.
- Enhanced the flux meter tests to handle partial charges and report unbilled flux correctly.
- Updated the CharacterDialog and Flux settings pages to track user interactions with analytics events.
- Introduced a mechanism to identify users on PostHog based on authentication state to ensure accurate funnel tracking.
- Added necessary dependencies for PostHog integration in the project.
This commit is contained in:
RainbowBird
2026-05-15 16:20:47 +08:00
parent bc7dda3d5f
commit 3984677b01
21 changed files with 1408 additions and 173 deletions
+44 -1
View File
@@ -1,4 +1,5 @@
import type Redis from 'ioredis'
import type { PostHog } from 'posthog-node'
import type { AuthInstance } from './libs/auth'
import type { Database } from './libs/db'
@@ -59,6 +60,7 @@ import { createConfigKVService } from './services/config-kv'
import { createEmailService } from './services/email'
import { createFluxService } from './services/flux'
import { createFluxTransactionService } from './services/flux-transaction'
import { createPostHogClient } from './services/posthog'
import { createProviderService } from './services/providers'
import { createRequestLogService } from './services/request-log'
import { createStripeService } from './services/stripe'
@@ -85,6 +87,7 @@ interface AppDeps {
env: Env
otel: OtelInstance | null
userDeletionService: UserDeletionService
posthog: PostHog | null
}
export async function buildApp(deps: AppDeps) {
@@ -238,7 +241,7 @@ export async function buildApp(deps: AppDeps) {
/**
* Stripe routes.
*/
.route('/api/v1/stripe', createStripeRoutes(deps.fluxService, deps.stripeService, deps.billingService, deps.configKV, deps.env, deps.redis, deps.otel?.revenue, deps.otel?.rateLimit))
.route('/api/v1/stripe', createStripeRoutes(deps.fluxService, deps.stripeService, deps.billingService, deps.configKV, deps.env, deps.redis, deps.otel?.revenue, deps.otel?.rateLimit, deps.posthog))
/**
* Admin routes — guarded by `ADMIN_EMAILS` allowlist + verified email.
@@ -352,6 +355,44 @@ export async function createApp() {
}, undefined, dependsOn.otel?.email),
})
// Webhook capture path goes through `captureSafe` → `captureImmediate`,
// which awaits the HTTP send inline, so individual events never sit in
// the background queue. `flush()` + `_shutdown()` on SIGTERM is the belt-
// and-suspenders drain for any future call site that uses the regular
// `capture()` (which only enqueues).
//
// NOTICE:
// We use the underscore-prefixed `_shutdown` despite its "internal" naming
// because the public `shutdown(timeoutMs)` returns void (`types.d.ts:580`)
// — there is no way to await its completion. `_shutdown(timeoutMs)` returns
// `Promise<void>` (`client.d.ts:934`) and is the only way to ensure the
// process doesn't exit while PostHog cleanup is still running. Posthog's
// own examples show `await client._shutdown()` as the recommended pattern.
const posthog = injeca.provide('services:posthog', {
dependsOn: { env: parsedEnv, lifecycle },
build: ({ dependsOn }) => {
const client = createPostHogClient(dependsOn.env)
if (client) {
dependsOn.lifecycle.appHooks.onStop(async () => {
try {
await client.flush()
}
catch {
// Flush failures on shutdown are non-fatal; we lose at most a
// few queued events. Fall through to shutdown anyway.
}
try {
await client._shutdown(5000)
}
catch {
// Shutdown errors are also non-fatal during process exit.
}
})
}
return client
},
})
const characterService = injeca.provide('services:characters', {
dependsOn: { db, otel },
build: ({ dependsOn }) => createCharacterService(dependsOn.db, dependsOn.otel?.engagement),
@@ -484,6 +525,7 @@ export async function createApp() {
env: parsedEnv,
otel,
userDeletionService,
posthog,
})
// Register the cluster-wide ObservableGauge for active sessions. Each
// replica polls the same DB (cached 10s, in-flight coalesced) and the
@@ -509,6 +551,7 @@ export async function createApp() {
env: resolved.env,
otel: resolved.otel,
userDeletionService: resolved.userDeletionService,
posthog: resolved.posthog,
})
logger.withFields({ hostname: resolved.env.HOST, port: resolved.env.PORT }).log('Server started')