From b6b59c4f06949d36be29fd280ebd0a6a9ec38143 Mon Sep 17 00:00:00 2001 From: acoshift Date: Sun, 21 Jun 2026 19:46:40 +0700 Subject: [PATCH] deployment: add Errors tab (application error detection) Add an Errors tab to the deployment detail view (non-Static) that surfaces grouped, deduplicated application-error issues from the error-detection pipeline (SPEC-error-detection.md, PR 4/7). - New route deployment/errors with issue list (kind badge, title, count, last-seen, status chip), Open/Resolved/Muted/All status pills, client-side text filter, and offset-cursor "Load more". - Expand-in-place issue detail: full sample stack in a monospace surface, recent occurrences (pod + relative time), and Resolve / Mute / Reopen GuardedButtons gated by deployment.logs. - States: empty, forbidden (no deployment.logs), and location-unavailable. - Add deployment.errors / errorGet / errorUpdate request+result types to src/types/api.d.ts and mock handlers (multi-kind/-status fixtures, two-page cursor paging, sg1 = location-unavailable) for bun dev:mock. All helpers (pod hue, relative-time, kind/severity) are local to the page; logFormat.ts is deliberately not created or imported (owned by another PR). bun check and bun lint are clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011d4bVuGLnCbcJD9ZvastPH --- src/lib/server/mock.ts | 179 +++++ .../deployment/(detail)/+layout.svelte | 1 + .../deployment/(detail)/errors/+page.svelte | 754 ++++++++++++++++++ .../deployment/(detail)/errors/+page.ts | 11 + src/types/api.d.ts | 82 ++ 5 files changed, 1027 insertions(+) create mode 100644 src/routes/(auth)/(project)/deployment/(detail)/errors/+page.svelte create mode 100644 src/routes/(auth)/(project)/deployment/(detail)/errors/+page.ts diff --git a/src/lib/server/mock.ts b/src/lib/server/mock.ts index 88c6f156..9d6b859f 100644 --- a/src/lib/server/mock.ts +++ b/src/lib/server/mock.ts @@ -1329,6 +1329,185 @@ const handlers: Record object> = { }) }, + // Application-error detection (Sentry-lite). Synthetic issues across kinds + // and statuses so the Errors tab renders offline. Cursor-aware: the first + // page returns nextCursor='page2', the second returns the rest with no + // further cursor. The 'sg1' location simulates a location with no log + // capture (error detection unavailable). + 'deployment.errors': (args) => { + if (args?.location === 'gke.cluster-sg1') { + return err('api: error detection is not available for this location') + } + const base = Date.now() + const at = (mins: number) => new Date(base - mins * 60_000).toISOString() + const all: Api.DeploymentErrorIssue[] = [ + { + id: 'iss_go_nilmap', + fingerprint: 'a1b2c3d4e5', + kind: 'go', + title: 'panic: assignment to entry in nil map', + status: 'open', + count: 1284, + firstSeen: at(60 * 26), + lastSeen: at(2), + samplePod: 'web-12-7d9f8-abcde' + }, + { + id: 'iss_py_keyerror', + fingerprint: 'b2c3d4e5f6', + kind: 'python', + title: "KeyError: 'user_id'", + status: 'open', + count: 342, + firstSeen: at(60 * 9), + lastSeen: at(11), + samplePod: 'web-12-7d9f8-fghij' + }, + { + id: 'iss_node_undef', + fingerprint: 'c3d4e5f6a7', + kind: 'node', + title: "TypeError: Cannot read properties of undefined (reading 'id')", + status: 'open', + count: 87, + firstSeen: at(60 * 4), + lastSeen: at(38), + samplePod: 'web-12-7d9f8-abcde' + }, + { + id: 'iss_java_npe', + fingerprint: 'd4e5f6a7b8', + kind: 'java', + title: 'java.lang.NullPointerException: Cannot invoke "String.length()"', + status: 'muted', + count: 56, + firstSeen: at(60 * 40), + lastSeen: at(60 * 3), + samplePod: 'web-12-7d9f8-fghij' + }, + { + id: 'iss_ruby_nomethod', + fingerprint: 'e5f6a7b8c9', + kind: 'ruby', + title: "NoMethodError: undefined method `name' for nil:NilClass", + status: 'resolved', + count: 19, + firstSeen: at(60 * 72), + lastSeen: at(60 * 30), + samplePod: 'web-12-7d9f8-abcde' + }, + { + id: 'iss_generic_oom', + fingerprint: 'f6a7b8c9d0', + kind: 'generic', + title: 'fatal: out of memory allocating 268435456 bytes', + status: 'resolved', + count: 4, + firstSeen: at(60 * 90), + lastSeen: at(60 * 50), + samplePod: 'web-12-7d9f8-fghij' + } + ] + const status = (args?.status as Api.DeploymentErrorStatusFilter | undefined) ?? 'open' + const filtered = status === 'all' ? all : all.filter((it) => it.status === status) + // Two-page paging: first request (no cursor) returns the first 3, then + // nextCursor='page2' yields the remainder. Pages are only meaningful when + // the filter leaves more than one page. + if (!args?.cursor) { + const head = filtered.slice(0, 3) + return ok({ issues: head, nextCursor: filtered.length > 3 ? 'page2' : undefined }) + } + return ok({ issues: filtered.slice(3), nextCursor: undefined }) + }, + 'deployment.errorGet': (args) => { + const base = Date.now() + const at = (mins: number) => new Date(base - mins * 60_000).toISOString() + const samples: Record = { + iss_go_nilmap: { + title: 'panic: assignment to entry in nil map', + sample: 'panic: assignment to entry in nil map\n\n' + + 'goroutine 17 [running]:\n' + + 'main.(*Server).handleCheckout(0xc0001a4000, {0x9b2e40, 0xc0002b8000}, 0xc0001fe000)\n' + + '\t/app/internal/server/checkout.go:142 +0x1f4\n' + + 'net/http.HandlerFunc.ServeHTTP(0xc0001b0000, {0x9b2e40, 0xc0002b8000}, 0xc0001fe000)\n' + + '\t/usr/local/go/src/net/http/server.go:2136 +0x2f\n' + + 'net/http.(*ServeMux).ServeHTTP(0xc0001c0000, {0x9b2e40, 0xc0002b8000}, 0xc0001fe000)\n' + + '\t/usr/local/go/src/net/http/server.go:2514 +0x149' + }, + iss_py_keyerror: { + title: "KeyError: 'user_id'", + sample: 'Traceback (most recent call last):\n' + + ' File "/app/handlers/checkout.py", line 88, in post\n' + + ' user = session["user_id"]\n' + + ' File "/app/lib/session.py", line 42, in __getitem__\n' + + ' return self._data[key]\n' + + "KeyError: 'user_id'" + }, + iss_node_undef: { + title: "TypeError: Cannot read properties of undefined (reading 'id')", + sample: "TypeError: Cannot read properties of undefined (reading 'id')\n" + + ' at resolveUser (/app/src/auth.js:51:23)\n' + + ' at /app/src/routes/checkout.js:18:30\n' + + ' at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)\n' + + ' at next (/app/node_modules/express/lib/router/route.js:144:13)' + }, + iss_java_npe: { + title: 'java.lang.NullPointerException: Cannot invoke "String.length()"', + sample: 'java.lang.NullPointerException: Cannot invoke "String.length()" because "name" is null\n' + + '\tat com.acme.checkout.CheckoutService.validate(CheckoutService.java:73)\n' + + '\tat com.acme.checkout.CheckoutController.post(CheckoutController.java:41)\n' + + '\tat java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)' + }, + iss_ruby_nomethod: { + title: "NoMethodError: undefined method `name' for nil:NilClass", + sample: "app/services/checkout_service.rb:24:in `validate': undefined method `name' for nil:NilClass (NoMethodError)\n" + + "\tfrom app/controllers/checkout_controller.rb:12:in `create'\n" + + "\tfrom actionpack (7.1.0) lib/action_controller/metal/basic_implicit_render.rb:8:in `send_action'" + }, + iss_generic_oom: { + title: 'fatal: out of memory allocating 268435456 bytes', + sample: 'fatal: out of memory allocating 268435456 bytes\n' + + ' while processing batch job #4821\n' + + ' rss=512MiB limit=512MiB' + } + } + const id = String(args?.id ?? 'iss_go_nilmap') + const s = samples[id] ?? samples.iss_go_nilmap + const issue: Api.DeploymentErrorIssueDetail = { + id, + fingerprint: 'a1b2c3d4e5', + kind: id.startsWith('iss_py') + ? 'python' + : id.startsWith('iss_node') + ? 'node' + : id.startsWith('iss_java') + ? 'java' + : id.startsWith('iss_ruby') + ? 'ruby' + : id.startsWith('iss_generic') + ? 'generic' + : 'go', + title: s.title, + status: id.startsWith('iss_ruby') || id.startsWith('iss_generic') + ? 'resolved' + : id.startsWith('iss_java') + ? 'muted' + : 'open', + count: 1284, + firstSeen: at(60 * 26), + lastSeen: at(2), + samplePod: 'web-12-7d9f8-abcde', + sampleMessage: s.sample, + recentEvents: [ + { pod: 'web-12-7d9f8-abcde', timestamp: at(2), object: '_errorlog/o1.ndjson.zst', offset: 12 }, + { pod: 'web-12-7d9f8-fghij', timestamp: at(14), object: '_errorlog/o1.ndjson.zst', offset: 88 }, + { pod: 'web-12-7d9f8-abcde', timestamp: at(31), object: '_errorlog/o2.ndjson.zst', offset: 4 } + ] + } + return ok({ issue }) + }, + 'deployment.errorUpdate': () => ok({}), + 'disk.list': () => list(disks), 'disk.get': (args) => ok({ ...disks[0], name: args?.name ?? 'data', location: args?.location ?? LOCATION_ID }), 'disk.create': () => ok({}), diff --git a/src/routes/(auth)/(project)/deployment/(detail)/+layout.svelte b/src/routes/(auth)/(project)/deployment/(detail)/+layout.svelte index fee91cdb..aa5b76ce 100644 --- a/src/routes/(auth)/(project)/deployment/(detail)/+layout.svelte +++ b/src/routes/(auth)/(project)/deployment/(detail)/+layout.svelte @@ -30,6 +30,7 @@ ? [] : [ { label: 'Logs', path: '/deployment/logs' }, + { label: 'Errors', path: '/deployment/errors' }, { label: 'Events', path: '/deployment/events' } ]) ]) diff --git a/src/routes/(auth)/(project)/deployment/(detail)/errors/+page.svelte b/src/routes/(auth)/(project)/deployment/(detail)/errors/+page.svelte new file mode 100644 index 00000000..489ba2c4 --- /dev/null +++ b/src/routes/(auth)/(project)/deployment/(detail)/errors/+page.svelte @@ -0,0 +1,754 @@ + + + + +
+
+
Errors
+ {#if loaded && !forbidden && !unavailable && !errorMessage} + {countLabel} + {/if} + + + +
+ {#each STATUS_FILTERS as f (f.value)} + + {/each} +
+ + +
+ +
+ {#if loading && !loaded} +
+
Loading errors…
+
+ {:else if forbidden} +
+ +
You don't have access to errors
+
Viewing application errors requires the deployment.logs permission.
+
+ {:else if unavailable} +
+ +
Error detection isn't available for this location yet.
+
This deployment's location has no log capture, so application errors can't be detected here.
+
+ {:else if errorMessage} +
+ +
Couldn't load errors
+
{errorMessage}
+
+ {:else if visibleIssues.length === 0} +
+ +
+ {query.trim() ? 'No issues match your filter.' : 'No application errors detected.'} +
+ {#if !query.trim()} +
Stack traces and unhandled exceptions printed by this app would show up here.
+ {/if} +
+ {:else} +
    + {#each visibleIssues as issue (issue.id)} + {@const meta = kindMeta(issue.kind)} +
  • + + + {#if expandedId === issue.id} +
    +
    + {#if issue.status !== 'resolved'} + updateStatus(issue, 'resolved')}> + Resolve + + {/if} + {#if issue.status === 'open'} + updateStatus(issue, 'muted')}> + Mute + + {/if} + {#if issue.status !== 'open'} + updateStatus(issue, 'open')}> + Reopen + + {/if} + +
    + kind {meta.label} + first seen {relTime(issue.firstSeen, now)} ago + last seen {relTime(issue.lastSeen, now)} ago +
    +
    + + {#if detailLoading} +
    Loading issue…
    + {:else if detailError} +
    {detailError}
    + {:else if detail} + +
    {detail.sampleMessage}
    + + {#if detail.recentEvents.length > 0} + +
      + {#each detail.recentEvents as occ, i (i)} +
    • + {occ.pod} + {relTime(occ.timestamp, now)} ago +
    • + {/each} +
    + {/if} + {/if} +
    + {/if} +
  • + {/each} +
+ + {#if nextCursor} +
+ +
+ {/if} + {/if} +
+
diff --git a/src/routes/(auth)/(project)/deployment/(detail)/errors/+page.ts b/src/routes/(auth)/(project)/deployment/(detail)/errors/+page.ts new file mode 100644 index 00000000..c5aef408 --- /dev/null +++ b/src/routes/(auth)/(project)/deployment/(detail)/errors/+page.ts @@ -0,0 +1,11 @@ +import type { PageLoad } from './$types' + +export const load: PageLoad = async ({ parent }) => { + const { + deployment + } = await parent() + + return { + deployment + } +} diff --git a/src/types/api.d.ts b/src/types/api.d.ts index f7a597b4..4889b991 100644 --- a/src/types/api.d.ts +++ b/src/types/api.d.ts @@ -920,6 +920,88 @@ declare namespace Api { cappedByBytes?: boolean } + // Application-error detection (Sentry-lite). An "issue" is a group of + // identical application-level errors (panics, exceptions, stack traces the + // app prints) deduplicated by a stable server-side fingerprint. + export type DeploymentErrorKind = 'go' | 'java' | 'python' | 'node' | 'ruby' | 'generic' + export type DeploymentErrorStatus = 'open' | 'resolved' | 'muted' + // The status query also accepts 'all' (no filter); the default is 'open'. + export type DeploymentErrorStatusFilter = DeploymentErrorStatus | 'all' + export type DeploymentErrorSort = 'lastSeen' | 'firstSeen' | 'count' + + // One grouped issue as returned by deployment.errors (the list view). + export type DeploymentErrorIssue = { + id: string + fingerprint: string + kind: DeploymentErrorKind + title: string + status: DeploymentErrorStatus + count: number + firstSeen: string + lastSeen: string + samplePod: string + } + + // A lightweight pointer to one occurrence of an issue (recent_events). The + // full sample stack lives once on the issue; these only carry where/when. + export type DeploymentErrorOccurrence = { + pod: string + timestamp: string + // object + offset locate the occurrence in the durable _errorlog stream + // (used by the History deep-link, not surfaced in v1 of the tab). + object?: string + offset?: number + } + + // The detail view: all list fields plus the representative sample stack and + // the recent occurrence pointers. + export type DeploymentErrorIssueDetail = DeploymentErrorIssue & { + sampleMessage: string + recentEvents: DeploymentErrorOccurrence[] + } + + // Args of deployment.errors (list). status defaults to 'open', sort to + // 'lastSeen'; cursor is the opaque page token from a prior nextCursor. + export type DeploymentErrorsArgs = { + project: string + location: string + name: string + status?: DeploymentErrorStatusFilter + limit?: number + cursor?: string + sort?: DeploymentErrorSort + } + + // Result of deployment.errors. nextCursor is non-empty while more issues + // remain for the current filter/sort. + export type DeploymentErrorsResult = { + issues: DeploymentErrorIssue[] + nextCursor?: string + } + + // Args of deployment.errorGet (detail). + export type DeploymentErrorGetArgs = { + project: string + location: string + name: string + id: string + } + + // Result of deployment.errorGet. + export type DeploymentErrorGetResult = { + issue: DeploymentErrorIssueDetail + } + + // Args of deployment.errorUpdate (triage). status flips the lifecycle: + // resolved / open (reopen) / muted. + export type DeploymentErrorUpdateArgs = { + project: string + location: string + name: string + id: string + status: DeploymentErrorStatus + } + export type BillingReportProject = { sid: string name: string