Files
multica/packages/core/diagnostics/diagnostic-context.ts
Naiyuan Qing 38b08acf00 feat(diagnostics): report which page a desktop hang happened on (MUL-5345) (#5989)
* feat(diagnostics): attribute desktop hangs to route, function and stack (MUL-5345)

A desktop hang currently reports "froze for 8s" and nothing else, so MUL-5345
could not be diagnosed at all. Three gaps, all fixed here.

Route attribution was silently dead. The main window's route reporting lived in
the PostHog pageview tracker and was deleted with it (MUL-4127), leaving
`getDiagnosticContext` in main reading a WeakMap nothing ever wrote — every
field report carried only the asar index.html URL. `DiagnosticRouteReporter`
restores the push, and now feeds the in-renderer watchdog too: the renderer runs
a memory router, so `location.pathname` could never identify the page either.
Paths are bucketed to templates (`/:slug/issues/:id`) before publishing.

Function attribution did not exist. The watchdog now prefers
`long-animation-frame` over `longtask` where supported, which carries per-script
`sourceFunctionName` / `sourceURL` / `sourceCharPosition`. That covers hangs the
thread survives. For hangs it does not, main captures the JS call stack over CDP
— which requires the Debugger channel to be warmed at window creation, because a
command sent after the thread is stuck never gets dispatched. Commands go
through a four-verb allowlist, only scalar code locations are copied out of the
paused frames (never `scopeChain`, whose handles dereference into user data),
and resume is unconditional so a capture can never turn a recoverable hang into
a permanent one.

Reports could also be lost before delivery. `freeze:get-last` no longer deletes;
the renderer sends with `send_instantly` and acks by exact timestamp, so a
report killed by a second hang is retried next boot instead of vanishing with
the file (the MUL-4115 failure mode: three deterministic hangs, zero events). A
7-day TTL keeps an undeliverable breadcrumb from becoming permanent boot noise.

Operations name themselves: `parseMarkdownChunked` marks the diagnostic context
before it runs, and the mark travels to main over the async IPC channel that
still lands after the main thread stops responding. The event carries how stale
the mark is, so it reads as context rather than as a cause.

Verified on Electron 39.8.7 / Chromium 142 (throwaway spike, not committed):
Debugger.pause during a 12s synchronous block returned the stack in 2ms with the
blocking function on top; holding the channel open all session showed no cost
beyond run-to-run noise (A/B/A, ordering drift larger than the effect); DevTools
and the channel coexist in both open orders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(diagnostics): close the freeze report ack race and stop shipping raw ids (MUL-5345)

Two review findings on #5989.

Acking on hand-off was not acking on delivery. `onCaptured` fires when
posthog.capture() returns; the request is still in flight, and posthog-js
exposes no delivery callback to wait on (`CaptureOptions` has `send_instantly`
and `transport`, nothing else). Deleting the breadcrumb there loses the report
whenever the app freezes again or is killed in that gap — the same MUL-4115
failure the ack protocol was added to prevent. The flush now waits out a grace
window before acking: if the process dies inside it the timer never fires, the
file survives, and the next boot retries. Duplicates are the accepted trade and
are trivially deduped on `breadcrumb_ts`; a lost report is not.

Raw identifiers were reaching telemetry. The breadcrumb context was spread
wholesale into the event props, so `workspaceSlug`, `tabId` and the absolute
`windowUrl` shipped with every report despite the stated "bucketed path only"
constraint. Fixed at both ends: the slug and tab id are no longer put into the
route context at all (nothing else read them), the sanitizer constructs its
result explicitly so a stale renderer's payload can't reintroduce them,
`windowUrl` is dropped since it is an install path that can carry the OS
username and the bucketed route already says which page it was, and the event
props are now assembled field by field so a future context key cannot ship
itself.

The flush moved into `freeze-flush.ts` to make both behaviours testable:
`onCaptured` does not ack, the grace window does, a cancelled window keeps the
breadcrumb, and props built from a context still carrying slug/tabId/windowUrl
contain none of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* refactor(diagnostics): reduce MUL-5345 to route attribution only

Scope call from product review: the next hang should answer "which page", and
nothing more. Removes the CDP stack capture, the long-animation-frame observer,
the operation breadcrumb, and the read/ack delivery protocol added earlier on
this branch, along with the spike-derived build note. What stays is the smallest
change that gives the two existing hang events a real route.

The route reporting had been dead since MUL-4127 (#4996) deleted it along with
the PostHog pageview tracker: `getDiagnosticContext` in main kept reading a
WeakMap nothing wrote, so a hang report carried only the asar index.html URL.
`DiagnosticRouteReporter` restores the push to main — the only party alive
during a true hang, which cannot ask a blocked renderer anything — and also
publishes to the in-renderer watchdog, whose `location.pathname` is that same
useless packaged path because the shell runs a memory router.

Paths are bucketed to templates (`/:slug/issues/:id`) before publishing, and
the workspace slug and tab id are not sent at all; nothing outside diagnostics
read them. The sanitizer constructs its result explicitly so a renderer older
than this build cannot reintroduce them, `windowUrl` is dropped because it is an
install path that can carry the OS username, and the breadcrumb event props are
assembled by whitelist rather than by spreading the context.

Both hang events now report `path` under the same name, so they group in one
query.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(diagnostics): bucket hang routes by known structure, not id shape (MUL-5345)

The bucketer guessed which segments were ids by looking at them — UUID, issue
key, or all digits. Every id that does not look like one therefore travelled to
telemetry intact, and most of ours do not: project, autopilot, agent, member,
squad, runtime, skill and attachment ids are arbitrary strings from
`paths.ts`. `/acme/projects/p1` bucketed to `/:slug/projects/p1`, and
`/acme/runtimes/machine%2Fruntime/runtime/runtime%20one` came through
completely unchanged.

It now matches structurally against the known route shapes, so a `:param` slot
is whatever occupies that position regardless of how it is spelled or encoded.
Where several patterns fit, the most literal one wins, which keeps `agents/new`
the create page rather than an agent whose id happens to be "new".

An unmatched path is masked (`/:slug/issues/*`, `/:slug/*`) rather than passed
through: a route we do not know is exactly the case where an id cannot be told
from a page name, so nothing from it travels. That makes a route added to
paths.ts without being added here a loss of detail instead of a leak.

To stop the list falling behind quietly, a parity test walks the real path
builders — not a copy of them — and asserts that no builder leaks its slug or
ids and that none falls back to the mask. Removing a single route from the
table fails it with the builder named.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:20:23 +08:00

168 lines
5.3 KiB
TypeScript

// Where the app thinks it is — the one field a freeze report is useless
// without.
//
// Desktop runs a memory router, so `location.pathname` in the renderer is the
// packaged `index.html` file path and can never identify the visible page. The
// desktop shell publishes its bucketed route here; the freeze watchdog reads it
// so an in-thread freeze event carries a real route. Web sets nothing and keeps
// using `location.pathname`.
let route: string | null = null;
/** Longest route we keep; these are our own short templates. */
const MAX_ROUTE_LENGTH = 256;
/**
* Publish the current app route, already bucketed to a template — see
* `bucketDiagnosticPath`. Pass null when leaving a known route.
*/
export function setDiagnosticRoute(next: string | null): void {
if (typeof next !== "string") {
route = null;
return;
}
const trimmed = next.trim();
route = trimmed ? trimmed.slice(0, MAX_ROUTE_LENGTH) : null;
}
export function getDiagnosticRoute(): string | null {
return route;
}
/** Test seam: drop state so cases don't leak into each other. */
export function resetDiagnosticContext(): void {
route = null;
}
// The known route shapes, mirroring `packages/core/paths/paths.ts` (the single
// path builder every navigation goes through) and the routers that consume it.
// Bucketing matches against these STRUCTURALLY: a `:param` slot is whatever
// occupies that position, whether it is a UUID, an issue key, `p1`, `skl_123`
// or a percent-encoded string.
//
// Guessing from the shape of a segment instead would leak every id that does
// not look like an id — project, skill, agent, runtime and attachment ids are
// all arbitrary strings. `paths.ts` also URL-encodes them, so an id containing
// a slash arrives as one already-escaped segment and must not be mistaken for
// two path segments.
//
// A route added to `paths.ts` without being added here is not a leak: an
// unmatched path is masked below rather than passed through. The parity test in
// diagnostic-context.test.ts walks the real builders and fails when one is
// missing, so this list cannot silently fall behind.
type RoutePattern = readonly string[];
const WORKSPACE_ROUTES: readonly RoutePattern[] = [
["issues"],
["issues", ":id"],
["projects"],
["projects", ":id"],
["autopilots"],
["autopilots", ":id"],
["agents"],
["agents", "new"],
["agents", ":id"],
["members", ":id"],
["squads"],
["squads", ":id"],
["inbox"],
["chat"],
["my-issues"],
["usage"],
["billing"],
["runtimes"],
["runtimes", ":id"],
["runtimes", ":id", "runtime", ":runtimeId"],
["skills"],
["skills", ":id"],
["settings"],
["attachments", ":id", "preview"],
];
const GLOBAL_ROUTES: readonly RoutePattern[] = [
["login"],
["signup"],
["logout"],
["workspaces", "new"],
["invite", ":id"],
["invitations"],
["onboarding"],
["auth", "callback"],
];
const WORKSPACE_SECTIONS = new Set(WORKSPACE_ROUTES.map((route) => route[0]!));
const GLOBAL_SECTIONS = new Set(GLOBAL_ROUTES.map((route) => route[0]!));
/**
* Collapse a concrete path to its route template: `/acme/issues/MUL-12` becomes
* `/:slug/issues/:id`. Diagnostics only need to know which screen the user was
* on, and a template keeps workspace slugs and resource ids out of telemetry
* while making the field groupable in one query.
*
* A path that matches no known route is masked with `*` rather than reported —
* an unrecognized segment is exactly the case where we cannot tell an id from a
* page name, so it never travels.
*/
export function bucketDiagnosticPath(path: string): string {
const [pathname = ""] = path.split(/[?#]/);
const segments = pathname.split("/").filter(Boolean);
if (segments.length === 0) return "/";
const globalRoute = matchRoute(segments, GLOBAL_ROUTES);
if (globalRoute) return `/${globalRoute.join("/")}`;
// Reserved slugs mean a known global section can never also be a workspace,
// so an unmatched path under one keeps its section and masks the rest.
const first = segments[0]!;
if (GLOBAL_SECTIONS.has(first)) return `/${first}/*`;
// Everything else is workspace-scoped; the leading segment is the slug.
const rest = segments.slice(1);
if (rest.length === 0) return "/:slug";
const scoped = matchRoute(rest, WORKSPACE_ROUTES);
if (scoped) return `/:slug/${scoped.join("/")}`;
const section = rest[0]!;
return WORKSPACE_SECTIONS.has(section)
? `/:slug/${section}/*`
: "/:slug/*";
}
/**
* Find the route this path follows. A `:param` slot accepts any single segment;
* every other slot must match literally. When several patterns fit, the most
* literal one wins, so `/agents/new` is the create page rather than an agent
* whose id happens to be "new".
*/
function matchRoute(
segments: readonly string[],
patterns: readonly RoutePattern[],
): RoutePattern | null {
let best: RoutePattern | null = null;
let bestLiterals = -1;
for (const pattern of patterns) {
if (pattern.length !== segments.length) continue;
let literals = 0;
let matches = true;
for (let i = 0; i < pattern.length; i += 1) {
const slot = pattern[i]!;
if (slot.startsWith(":")) continue;
if (slot !== segments[i]) {
matches = false;
break;
}
literals += 1;
}
if (matches && literals > bestLiterals) {
best = pattern;
bestLiterals = literals;
}
}
return best;
}