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)"]),
|
...(hypotheses.length ? hypotheses.map((h) => `- ${h}`) : ["- (none)"]),
|
||||||
].join("\n");
|
].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([
|
const fingerprint = fingerprintHash([
|
||||||
[...header.tags].sort().join(","),
|
[...header.tags].sort().join(","),
|
||||||
url,
|
normalizePath(url),
|
||||||
errors[0] ? normalizeError(errors[0]) : null,
|
errors[0] ? normalizeError(errors[0]) : null,
|
||||||
failedEndpoints[0] ?? null,
|
failedEndpoints[0] ? normalizePath(failedEndpoints[0]) : null,
|
||||||
header.severity,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
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 {
|
function truncate(s: string, n: number): string {
|
||||||
return s.length > n ? `${s.slice(0, n)}…` : s;
|
return s.length > n ? `${s.slice(0, n)}…` : s;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user