fix(insights): tighten session fingerprint so duplicate UX issues dedupe
Same root cause was producing one Insight row per user because URL carried
vehicleId/categoryId UUIDs and the raw URL fed the fingerprint hash. Result:
6 active "new" insights describing the same kategori-bouncing problem with
slightly different LLM phrasing, none deduplicated, only one (cmpclg7b9 →
sase.tr#76) had been triaged.
apps/worker/src/lib/compress.ts:
- new normalizePath() — collapses UUID / ULID / CUID2 / numeric path segments
to ":id", conservative on plain words. Mirrors what the LLM already sees in
the timeline.
- fingerprint inputs now:
tags (sorted) | normalizePath(url) | normalizeError(errors[0]) | normalizePath(failedEndpoints[0])
- removed header.severity from the hash — severity is a property of the
Insight bucket, not its identity; rage-click counts pushing the same root
cause across P1/P2/P3 was forcing extra rows.
Smoke (12/12 pass via tsx /tmp/check_fingerprint.ts):
- 6 historical kategori-bouncing URLs → 1 fingerprint
- severity changes don't move the hash
- distinct tag sets / distinct errors still split
Historical rows are untouched — only new compressed sessions get the new
hash. Old near-duplicate insights can be merged manually via the panel.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -474,12 +474,23 @@ export function compressSnapshots(
|
||||
...(hypotheses.length ? hypotheses.map((h) => `- ${h}`) : ["- (none)"]),
|
||||
].join("\n");
|
||||
|
||||
// Fingerprint inputs are intentionally coarse so that recurring problems
|
||||
// cluster into a single Insight row instead of producing a new row per
|
||||
// user/vehicle/category. Two prior issues this guards against:
|
||||
// 1. URL carries vehicleId/categoryId UUIDs — every user produces a unique
|
||||
// URL, so identical kategori-bouncing sessions never deduplicate.
|
||||
// normalizePath collapses UUIDs/ULIDs/CUIDs/numeric ids to ":id".
|
||||
// 2. header.severity is derived from rage-click count and varies between
|
||||
// P1/P2/P3 for the same root cause — it should be a property of the
|
||||
// Insight, not part of its identity. Dropped from the hash.
|
||||
// Tag set + normalized path + first-error + first-failed-endpoint give
|
||||
// enough discrimination because distinct UX failures already carry distinct
|
||||
// tagger tags (vin_decode_*, payment_*, search_validation_*, etc.).
|
||||
const fingerprint = fingerprintHash([
|
||||
[...header.tags].sort().join(","),
|
||||
url,
|
||||
normalizePath(url),
|
||||
errors[0] ? normalizeError(errors[0]) : null,
|
||||
failedEndpoints[0] ?? null,
|
||||
header.severity,
|
||||
failedEndpoints[0] ? normalizePath(failedEndpoints[0]) : null,
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -511,6 +522,47 @@ function stripQuery(u: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse ID-like path segments to `:id` so URLs with embedded
|
||||
* UUID/ULID/CUID/numeric identifiers fingerprint identically across users.
|
||||
*
|
||||
* Conservative on purpose — only matches segments whose entire content is a
|
||||
* recognised id format, leaving meaningful path words alone. Used both for
|
||||
* the page url and for failed-endpoint paths.
|
||||
*
|
||||
* Examples:
|
||||
* /dashboard/vehicles/6a3e7c44-d64b-4245-abc7-adb692a90fff/categories/29ee50e7-887f-4124-b1fc-bbeea5e358d1
|
||||
* → /dashboard/vehicles/:id/categories/:id
|
||||
* /api/orders/12345/items → /api/orders/:id/items
|
||||
* /insights/i/cmpwrb82s002f14fza8lbc7f4 → /insights/i/:id
|
||||
*/
|
||||
function normalizePath(u: string | null | undefined): string | null {
|
||||
if (!u) return u ?? null;
|
||||
let path: string;
|
||||
try {
|
||||
const parsed = new URL(u, "https://x");
|
||||
path = parsed.pathname || u;
|
||||
} catch {
|
||||
const q = u.indexOf("?");
|
||||
path = q === -1 ? u : u.slice(0, q);
|
||||
}
|
||||
return path
|
||||
.split("/")
|
||||
.map((seg) => {
|
||||
if (!seg) return seg;
|
||||
// UUID v1–v8 canonical form
|
||||
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(seg)) return ":id";
|
||||
// ULID — Crockford base32, exactly 26 chars
|
||||
if (/^[0-9A-HJKMNP-TV-Z]{26}$/.test(seg)) return ":id";
|
||||
// CUID2 / similar — lowercase alphanumeric, length >= 20 starting with a letter
|
||||
if (/^[a-z][a-z0-9]{19,}$/.test(seg)) return ":id";
|
||||
// Pure numeric id
|
||||
if (/^\d+$/.test(seg)) return ":id";
|
||||
return seg;
|
||||
})
|
||||
.join("/");
|
||||
}
|
||||
|
||||
function truncate(s: string, n: number): string {
|
||||
return s.length > n ? `${s.slice(0, n)}…` : s;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user