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:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user