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:
Semih
2026-06-02 19:42:25 +03:00
parent 9a479f9a5b
commit a7fe80f0c5

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