feat(phase6a-v1.2): dual-mode custom-event enrichment (v1.0 + v2.0 aliases)

- Prisma: posthog_person_cache (24h TTL), posthog_group_cache, session_custom_events
- SessionMeta: +posthogDistinctId, +groupKey, +customEventCount
- PostHog client: listSessionEvents, getPerson, getGroup
- posthog-cache.ts: cache-with-stale-refresh for person + group properties
- event-taxonomy.ts: dual-mode alias map (vin_decoded↔vin_decode_initiated,
  vin_decode_success↔vin_decode_succeeded, vin_decode_error↔vin_decode_failed,
  payment_success↔payment_succeeded). 56 tracked event names total.
- enrich.ts: per-session custom events fetch, persist to session_custom_events,
  attach user_properties + group_properties via cache.
- tagger.ts: 14 new custom-event tags
  (vin_decode_fail_pattern, provider_reliability_issue, payment_friction,
   payment_ui_silent_failure (P0), payment_failed_session, checkout_abandonment,
   downgrade_pending, compatibility_quality_gap, parts_export_abandoned,
   search_validation_friction, onboarding_stuck, upgrade_hesitation,
   power_user_path, at_risk_active_session, provider_mismatch,
   api_key_friction, webhook_setup_struggle); group-context priority bump.
- heuristic.ts: customEventPromoteReasons() — 10 custom-event promote signals.
- compress.ts: merge_rrweb_and_custom_events interleaving with ⤷ EVENT: prefix,
  ±1.5s grouping, standalone flush for events with no nearby rrweb. Adds
  company_context header line; key_events lists top-5 custom event counts.
- /insights page: +Events column. /insights/sessions/[id]: collapsible custom
  events table + group key display.

Graceful: when Sase.tr emits 0 custom events the pipeline falls back to
rrweb-only tagging from Phase 6a v1.1.

PRD: behavioral-insight-pipeline-prd-1.md (v1.2),
     sase-posthog-events-prd.md (Sase.tr-side, separate codebase).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Semih
2026-05-13 22:17:18 +00:00
parent 93499faeaa
commit fc6a3f7a39
16 changed files with 5059 additions and 48 deletions

View File

@@ -117,8 +117,10 @@ model SessionMeta {
id String @id
projectKey String
userIdHash String?
posthogDistinctId String?
isAuthenticated Boolean @default(false)
subscriptionTier String?
groupKey String?
startedAt DateTime
durationMs Int
pageviewCount Int @default(0)
@@ -133,6 +135,7 @@ model SessionMeta {
tags String[]
severity String?
score Int?
customEventCount Int @default(0)
rawMetadataUrl String?
status String @default("pending_signal")
fingerprint String?
@@ -176,3 +179,41 @@ model IngestionWatermark {
@@map("ingestion_watermarks")
}
// PostHog identify() person properties cache (24h TTL).
model PosthogPersonCache {
distinctId String @id
properties Json
groups Json?
refreshedAt DateTime
ttlAt DateTime
@@index([ttlAt])
@@map("posthog_person_cache")
}
// PostHog group analytics (e.g. company) cache (24h TTL).
model PosthogGroupCache {
groupType String
groupKey String
properties Json
refreshedAt DateTime
ttlAt DateTime
@@id([groupType, groupKey])
@@index([ttlAt])
@@map("posthog_group_cache")
}
// Per-session custom events fetched from PostHog (joined by $session_id).
// Stored so compression can interleave them with rrweb timeline.
model SessionCustomEvent {
id BigInt @id @default(autoincrement())
sessionId String
eventName String
timestamp DateTime
properties Json
@@index([sessionId, timestamp])
@@map("session_custom_events")
}

View File

@@ -66,13 +66,14 @@ export default async function InsightsPage() {
<TableHead>Tags</TableHead>
<TableHead>Started</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Events</TableHead>
<TableHead>URL</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recent.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center text-xs text-muted-foreground">
<TableCell colSpan={9} className="text-center text-xs text-muted-foreground">
No tagged sessions yet pipeline may still be warming up.
</TableCell>
</TableRow>
@@ -100,6 +101,7 @@ export default async function InsightsPage() {
{s.startedAt.toISOString().slice(0, 19).replace("T", " ")}
</TableCell>
<TableCell className="text-xs">{Math.round(s.durationMs / 1000)}s</TableCell>
<TableCell className="text-xs font-mono">{s.customEventCount || "—"}</TableCell>
<TableCell className="max-w-[200px] truncate font-mono text-xs">
{s.startUrl ?? "—"}
</TableCell>

View File

@@ -42,6 +42,12 @@ export default async function SessionDetailPage({
});
if (!s) notFound();
const customEvents = await prisma.sessionCustomEvent.findMany({
where: { sessionId: id },
orderBy: { timestamp: "asc" },
take: 100,
});
const bucket = process.env.INSIGHT_COMPRESSED_BUCKET ?? "insight-compressed";
const timeline = s.compressed
? await fetchTimeline(bucket, s.compressed.semanticTimelineMinioKey)
@@ -84,6 +90,35 @@ export default async function SessionDetailPage({
</div>
)}
{customEvents.length > 0 && (
<details className="rounded-md border bg-muted/20 p-3 text-xs">
<summary className="cursor-pointer font-medium">
Custom events ({customEvents.length})
</summary>
<table className="mt-2 w-full font-mono">
<tbody>
{customEvents.map((e) => (
<tr key={String(e.id)} className="border-t">
<td className="py-1 pr-3 text-muted-foreground">
{e.timestamp.toISOString().slice(11, 19)}
</td>
<td className="py-1 pr-3">{e.eventName}</td>
<td className="py-1 truncate max-w-[600px]">
{JSON.stringify(e.properties).slice(0, 200)}
</td>
</tr>
))}
</tbody>
</table>
</details>
)}
{s.groupKey && (
<div className="text-xs text-muted-foreground">
company group: <span className="font-mono">{s.groupKey}</span>
</div>
)}
<h3 className="mt-4 text-sm font-medium">Semantic Timeline</h3>
<pre className="rounded-md border bg-muted/30 p-3 text-xs whitespace-pre-wrap font-mono">
{timeline ?? "(no compressed timeline yet)"}

File diff suppressed because one or more lines are too long