Implement review remediation + PLG analytics SDK
- All 6 test architectures patched with Section 11 addendums - P5 (cost) fully rewritten from 232 to ~600 lines - PLG brainstorm + party mode advisory board results - Analytics SDK v2 (PostHog Cloud, Zod strict, Lambda-safe) - Analytics tests v2 (safeParse, no , no timestamp, no PII) - Addresses all Gemini review findings across P1-P6
This commit is contained in:
138
products/01-llm-cost-router/src/analytics/index.ts
Normal file
138
products/01-llm-cost-router/src/analytics/index.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { PostHog } from 'posthog-node';
|
||||
import { z } from 'zod';
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 1. Unified Event Taxonomy (Zod Enforced, Strictly Typed)
|
||||
// ---------------------------------------------------------
|
||||
|
||||
export enum EventName {
|
||||
SignupCompleted = 'account.signup.completed',
|
||||
FirstDollarSaved = 'routing.savings.first_dollar',
|
||||
UpgradeCompleted = 'billing.upgrade.completed',
|
||||
}
|
||||
|
||||
// Per-event property schemas — no z.any() PII loophole
|
||||
const SignupProperties = z.object({
|
||||
method: z.enum(['github_sso', 'google_sso', 'email']),
|
||||
}).strict();
|
||||
|
||||
const ActivationProperties = z.object({
|
||||
savings_amount: z.number().nonnegative(),
|
||||
}).strict();
|
||||
|
||||
const UpgradeProperties = z.object({
|
||||
plan: z.enum(['pro', 'business']),
|
||||
mrr_increase: z.number().nonnegative(),
|
||||
}).strict();
|
||||
|
||||
const PropertiesMap = {
|
||||
[EventName.SignupCompleted]: SignupProperties,
|
||||
[EventName.FirstDollarSaved]: ActivationProperties,
|
||||
[EventName.UpgradeCompleted]: UpgradeProperties,
|
||||
} as const;
|
||||
|
||||
export const EventSchema = z.object({
|
||||
name: z.nativeEnum(EventName),
|
||||
tenant_id: z.string().min(1, 'tenant_id is required'),
|
||||
product: z.literal('route'),
|
||||
properties: z.record(z.unknown()).optional().default({}),
|
||||
});
|
||||
|
||||
export type AnalyticsEvent = z.infer<typeof EventSchema>;
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 2. NoOp Client for local/test environments
|
||||
// ---------------------------------------------------------
|
||||
|
||||
class NoOpPostHog {
|
||||
capture() {}
|
||||
identify() {}
|
||||
async flushAsync() {}
|
||||
async shutdown() {}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 3. Analytics SDK (PostHog Cloud, Lambda-Safe)
|
||||
// ---------------------------------------------------------
|
||||
|
||||
export class Analytics {
|
||||
private client: PostHog | NoOpPostHog;
|
||||
public readonly isSessionReplayEnabled = false;
|
||||
|
||||
constructor(client?: PostHog) {
|
||||
if (client) {
|
||||
this.client = client;
|
||||
} else {
|
||||
const apiKey = process.env.POSTHOG_API_KEY;
|
||||
if (!apiKey) {
|
||||
// No key = NoOp. Never silently send to a mock key.
|
||||
console.warn('[Analytics] POSTHOG_API_KEY not set — using NoOp client');
|
||||
this.client = new NoOpPostHog();
|
||||
} else {
|
||||
this.client = new PostHog(apiKey, {
|
||||
host: 'https://us.i.posthog.com',
|
||||
flushAt: 20, // Batch up to 20 events
|
||||
flushInterval: 5000, // Or flush every 5s
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify a tenant once (on signup). Sets $set properties.
|
||||
* Call this instead of embedding $set in every track() call.
|
||||
*/
|
||||
public identify(tenantId: string, properties?: Record<string, unknown>): void {
|
||||
this.client.identify({
|
||||
distinctId: tenantId,
|
||||
properties: { tenant_id: tenantId, ...properties },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Track an event. Uses safeParse — never crashes the caller.
|
||||
* Does NOT flush. Call flush() at Lambda teardown.
|
||||
*/
|
||||
public track(event: AnalyticsEvent): boolean {
|
||||
// 1. Base schema validation
|
||||
const baseResult = EventSchema.safeParse(event);
|
||||
if (!baseResult.success) {
|
||||
console.error('[Analytics] Invalid event (base):', baseResult.error.format());
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. Per-event property validation (strict, no PII loophole)
|
||||
const propSchema = PropertiesMap[baseResult.data.name];
|
||||
if (propSchema) {
|
||||
const propResult = propSchema.safeParse(baseResult.data.properties);
|
||||
if (!propResult.success) {
|
||||
console.error('[Analytics] Invalid properties:', propResult.error.format());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Capture — let PostHog assign the timestamp (avoids clock skew)
|
||||
this.client.capture({
|
||||
distinctId: baseResult.data.tenant_id,
|
||||
event: baseResult.data.name,
|
||||
properties: {
|
||||
product: baseResult.data.product,
|
||||
...baseResult.data.properties,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush all queued events. Call once at Lambda teardown
|
||||
* (e.g., in a Middy middleware or handler's finally block).
|
||||
*/
|
||||
public async flush(): Promise<void> {
|
||||
await this.client.flushAsync();
|
||||
}
|
||||
|
||||
public async shutdown(): Promise<void> {
|
||||
await this.client.shutdown();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user