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:
2026-03-01 01:42:49 +00:00
parent 2fe0ed856e
commit 03bfe931fc
9 changed files with 2950 additions and 85 deletions

View 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();
}
}