fix(insights): tighten session fingerprint so duplicate UX issues dedupe #5

Merged
root merged 3 commits from fix/insight-fingerprint-dedupe into main 2026-06-03 20:47:12 +03:00
Showing only changes of commit a7fe80f0c5 - Show all commits

View File

@@ -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 v1v8 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;
}