Skip to content

feat(observability): consolidate observability UI updates - #1975

Open
mantrakp04 wants to merge 1 commit into
stack/observability-sdk-configfrom
stack/observability-ui
Open

feat(observability): consolidate observability UI updates#1975
mantrakp04 wants to merge 1 commit into
stack/observability-sdk-configfrom
stack/observability-ui

Conversation

@mantrakp04

@mantrakp04 mantrakp04 commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Align issue, trace, performance, source-map, and release dashboard surfaces.
  • Harden analytics path visualization, shared formatting, navigation, and loading states.
  • Update shared dashboard components, Playground coverage, and observability documentation.

Stack

Base: SDK and config contracts
Final replacement PR in this stack.

Test plan

  • Run dashboard observability unit and component tests.
  • Run dashboard lint and typecheck with the repository development environment.
  • Verify the updated observability pages in the development environment.

Summary by cubic

Consolidates Observability UI around new backend contracts and shared helpers. Pages now use one formatCount, issue links carry the selected range (default 24h), trace event scans are bounded to the selected trace ±15m with link caps, and source‑map uploads only reject fully injected debug‑IDs. Also fixes exception chain ordering, makes empty sparklines accessible, and removes duplicated issue link helpers.

  • Aligns Issues
    • Detail/list links include ?range=; DEFAULT_ISSUE_RANGE_HOURS and ISSUE_RANGE_PARAM_KEY live in issues/issue-links.
    • Sparklines return a dense, zero‑filled series for the selected window; ClickHouse timestamps parse via a single helper.
    • Exception handling matches Sentry semantics: the last exception.values entry is the primary; cause list is reversed to read direct‑cause first.
    • Optimistic status overrides reconcile per‑row; adds nextStatusForAction and count adjustment helpers.
    • Event search supports cursor pagination (appends next pages).
    • Culprit treats the backend’s <unknown> sentinel as missing; never renders blank.
    • Stack snippets use valid inline elements in pre and show mapped status from frame symbolication.
  • Traces
    • Event fetch is bound to the selected trace window with 15‑minute slack; span links are limited by SPAN_LINKS_CAP.
    • Waterfall traversal uses a shared ancestry stack to avoid quadratic walks; test harness fixes window.scrollTo typing.
  • Source maps
    • Adds putPresignedArtifact and tight detection for injected debug‑ID identifiers; bare hexclave-dbid- mentions no longer false‑fail.
  • Shared/UI
    • Moves count formatting to observability/format and adopts it in Services and Performance; updates docs to clarify environment default.
    • Empty activity sparklines announce the chart via ariaLabel instead of hiding the hairline.
    • Tweaks zinc badge tone; documents row click target in the data grid.
    • Updates URL prefetchers to /warehouse/* paths (queries/tables moved from /analytics/*).

Migration

  • Use issue link helpers from projects/[projectId]/observability/issues/issue-links (issuesListHref, issueDetailHref, …); duplicates were removed from observability-links.
  • Import formatCount from ../format instead of page‑local copies in Services/Performance.
  • Ensure backend/config SDK contracts are deployed before merging; this UI expects the new issue/token and trace query shapes.

Written for commit 9f45bba. Summary will update on new commits.

Review in cubic

Align issue, trace, performance, source-map, analytics-path, and shared dashboard surfaces with the new backend contracts.

Co-authored-by: Cursor <cursoragent@cursor.com>
@vercel

vercel Bot commented Aug 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hexclave-marshal Error Error Aug 15, 2026 2:45am
stack-auth-hosted-components Ready Ready Preview Aug 15, 2026 2:45am
stack-auth-internal-tool Ready Ready Preview Aug 15, 2026 2:45am
stack-auth-mcp Ready Ready Preview Aug 15, 2026 2:45am
stack-auth-skills Ready Ready Preview Aug 15, 2026 2:45am
stack-backend Ready Ready Preview Aug 15, 2026 2:45am
stack-dashboard Ready Ready Preview Aug 15, 2026 2:45am
stack-demo Ready Ready Preview Aug 15, 2026 2:45am
stack-preview-backend Ready Ready Preview Aug 15, 2026 2:45am
stack-preview-dashboard Ready Ready Preview Aug 15, 2026 2:45am

@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2713edfd-115b-4150-81d8-8d1979dcd667

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

14 issues found across 47 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/observability/issues/stack-frame-list.tsx">

<violation number="1" location="apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/observability/issues/stack-frame-list.tsx:186">
P2: When occurrence navigation changes an all-library run to the same run followed by an app frame, this key stays unchanged although `defaultExpanded` changes. React preserves `CollapsedFrameGroup`'s old `expanded` state, so the new occurrence ignores its default; include `defaultExpanded` and the complete run identity in the key.</violation>
</file>

<file name="apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/paths/normalize-url.ts">

<violation number="1" location="apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/paths/normalize-url.ts:22">
P2: 16–19-character URL-safe base64 IDs are no longer normalized, so routes containing them remain split instead of grouping by route shape. The `api` and `auth` examples already fail the 16-character regex; keep the runtime check aligned with `BASE64_TOKEN_REGEX` and add a regression test for the intended minimum.</violation>
</file>

<file name="apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/observability/issues/issue-format.ts">

<violation number="1" location="apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/observability/issues/issue-format.ts:92">
P3: This insertion detaches the existing `issueCulprit` JSDoc from the function and associates it with `SERVER_UNKNOWN_CULPRIT_SENTINEL`, so editor/API documentation describes the wrong symbol. Move the sentinel declaration before the helper's JSDoc, or place the helper JSDoc after the sentinel declaration.</violation>
</file>

<file name="apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/observability/releases/page-client.tsx">

<violation number="1" location="apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/observability/releases/page-client.tsx:287">
P2: When an add-commit request completes after the admin selects another release, this guard leaves the new release selected, but the following setters clear its form and show a success notice for the old release. Guard the commit form resets and notice with the same stale-operation check.</violation>

<violation number="2" location="apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/observability/releases/page-client.tsx:287">
P3: The guarded-refresh pattern is now duplicated verbatim between addCommit and addDeployment (fetch by selectedRelease.version, then setSelectedRelease with the id-match guard). Consider extracting a small `refreshSelectedRelease(refreshedRelease)` helper so the stale-selection logic stays in one place and is used consistently (the create path still sets selectedRelease unconditionally).</violation>
</file>

<file name="apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/observability/traces/page-client.tsx">

<violation number="1" location="apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/observability/traces/page-client.tsx:790">
P2: When a trace has more than 10,000 spans, events from spans after the retained oldest 10,000 are omitted from the waterfall. Extend the event window to the current time, or obtain the uncapped trace interval, whenever the span result is capped.</violation>

<violation number="2" location="apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/observability/traces/page-client.tsx:813">
P3: When a trace has exactly 1,000 span links, this condition still shows a warning claiming that further links exist. Fetch one extra row and compare with the cap, or use a count query before setting this flag.</violation>
</file>

<file name="apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/observability/issues/page-client.tsx">

<violation number="1" location="apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/observability/issues/page-client.tsx:86">
P3: This re-implements the namespaced select-value codec that already exists for the service filter in `service-identity.ts`. The environment variant is simpler (plain string rather than an object) but the sentinel/prefix convention is duplicated in the page component instead of next to that codec, risking drift. Consider colocating the environment codec with service-identity's or documenting the shared convention.</violation>

<violation number="2" location="apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/observability/issues/page-client.tsx:251">
P2: When a list refresh completes while a status PATCH is pending, this reverse adjustment applies to fresh server counts instead of the optimistic baseline, so the status tabs can show incorrect totals. Track whether the optimistic delta is still present before undoing it, or reconcile with a fresh server response.</violation>
</file>

<file name="apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/observability/format.ts">

<violation number="1" location="apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/observability/format.ts:72">
P3: At the top of the k range the output rolls over to "1000k" rather than into M. `formatCount(999_500)` produces "1000k" because `(value/1000).toFixed(0)` rounds to 1000 and the `k` suffix is appended, while the M branch handles its own rollover gracefully (`9_999_999` → "10.0M"). The two magnitude branches are inconsistent: the k branch never carries the decimal at its top, so it prints the malformed "1000k" instead of "1.0M". Guard the `k` result so it rolls into M when it rounds to 1000.</violation>
</file>

<file name="apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/observability/issues/issue-event-search.tsx">

<violation number="1" location="apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/observability/issues/issue-event-search.tsx:55">
P2: When Load more fails, this catch removes all successfully loaded pages and the cursor needed to retry. Only clear `items` and `nextCursor` for a fresh search; preserve them when `cursor` is non-null.</violation>

<violation number="2" location="apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/observability/issues/issue-event-search.tsx:106">
P2: After page 1, editing a search field or parent filter makes Load more send the old cursor with new filters, which the backend rejects as an invalid cursor. Store the complete filter snapshot with the cursor and use that snapshot for subsequent pages.</violation>
</file>

<file name="apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/observability/issues/[issueId]/page-client.tsx">

<violation number="1" location="apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/observability/issues/[issueId]/page-client.tsx:184">
P2: `PageClient` now calls `useSearchParams()`, but `page.tsx` renders it directly as a static server component with no `<Suspense>` boundary. A client component that reads search params inside a statically prerendered page must be wrapped in `Suspense`, or the page can fail prerendering with `useSearchParams() should be wrapped in a suspense boundary`. Wrap the component in `<Suspense>` (or confirm the page isn't statically prerendered).</violation>
</file>

<file name="apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/observability/issues/use-issue-data.ts">

<violation number="1" location="apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/observability/issues/use-issue-data.ts:173">
P3: The sparkline grid is anchored to the browser's `Date.now()`, but the query window is anchored to the server's `now64(3)`. If the two clocks disagree by more than one bucket step (an hour on the 24h range, a minute on the 1h range), the newest server bucket falls outside the client-rebuilt grid and is silently dropped by the bounds check, so the most recent activity vanishes with no error surfaced. This mirrors the existing services `buildServiceTimelines`, which has the same skew dependency, but the added `Date.now()`-based grid here reintroduces it for issues. Anchoring to server time (e.g. snapping via the same `now64(3)` the query uses, or deriving `nowMs` from the returned data) would avoid the silent loss; at minimum, document the skew assumption rather than treating the drop as only the intentional partial-bucket clipping.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

// default) onto a DIFFERENT frame run after occurrence
// navigation swaps the stack. First/last location plus length is
// enough to distinguish runs without hashing every frame.
key={`collapsed-${group.startIndex}-${group.frames.length}-${frameLocationLabel(group.frames[0] ?? throwMissingCollapsedFrame())}-${frameLocationLabel(group.frames[group.frames.length - 1] ?? throwMissingCollapsedFrame())}`}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When occurrence navigation changes an all-library run to the same run followed by an app frame, this key stays unchanged although defaultExpanded changes. React preserves CollapsedFrameGroup's old expanded state, so the new occurrence ignores its default; include defaultExpanded and the complete run identity in the key.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/observability/issues/stack-frame-list.tsx, line 186:

<comment>When occurrence navigation changes an all-library run to the same run followed by an app frame, this key stays unchanged although `defaultExpanded` changes. React preserves `CollapsedFrameGroup`'s old `expanded` state, so the new occurrence ignores its default; include `defaultExpanded` and the complete run identity in the key.</comment>

<file context>
@@ -163,7 +177,13 @@ export function StackFrameList({
+              // default) onto a DIFFERENT frame run after occurrence
+              // navigation swaps the stack. First/last location plus length is
+              // enough to distinguish runs without hashing every frame.
+              key={`collapsed-${group.startIndex}-${group.frames.length}-${frameLocationLabel(group.frames[0] ?? throwMissingCollapsedFrame())}-${frameLocationLabel(group.frames[group.frames.length - 1] ?? throwMissingCollapsedFrame())}`}
               frames={group.frames}
               defaultExpanded={group.defaultExpanded}
</file context>
Suggested change
key={`collapsed-${group.startIndex}-${group.frames.length}-${frameLocationLabel(group.frames[0] ?? throwMissingCollapsedFrame())}-${frameLocationLabel(group.frames[group.frames.length - 1] ?? throwMissingCollapsedFrame())}`}
key={`collapsed-${group.startIndex}-${group.defaultExpanded ? "expanded" : "collapsed"}-${group.frames.map((frame) => `${frameFunctionLabel(frame)}:${frameLocationLabel(frame)}`).join("|")}`}

Comment on lines +22 to 24
// Length 20, not the regex's 16: shorter base64-shaped words (`api`, `auth`)
// would otherwise collapse into `:id`.
if (segment.length >= 20 && BASE64_TOKEN_REGEX.test(segment)) return true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: 16–19-character URL-safe base64 IDs are no longer normalized, so routes containing them remain split instead of grouping by route shape. The api and auth examples already fail the 16-character regex; keep the runtime check aligned with BASE64_TOKEN_REGEX and add a regression test for the intended minimum.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/paths/normalize-url.ts, line 22:

<comment>16–19-character URL-safe base64 IDs are no longer normalized, so routes containing them remain split instead of grouping by route shape. The `api` and `auth` examples already fail the 16-character regex; keep the runtime check aligned with `BASE64_TOKEN_REGEX` and add a regression test for the intended minimum.</comment>

<file context>
@@ -4,45 +4,36 @@
 
-  // Base64 tokens (only for longer segments to avoid false positives on
-  // short path segments like "api" or "auth")
+  // Length 20, not the regex's 16: shorter base64-shaped words (`api`, `auth`)
+  // would otherwise collapse into `:id`.
   if (segment.length >= 20 && BASE64_TOKEN_REGEX.test(segment)) return true;
</file context>
Suggested change
// Length 20, not the regex's 16: shorter base64-shaped words (`api`, `auth`)
// would otherwise collapse into `:id`.
if (segment.length >= 20 && BASE64_TOKEN_REGEX.test(segment)) return true;
// Base64-like tokens use the regex's 16-character lower bound.
if (BASE64_TOKEN_REGEX.test(segment)) return true;

// flight, and unconditionally setting would yank the view back to the
// release the commit was added to.
const refreshedRelease = await fetchReleaseByVersion(adminApp, selectedRelease.version);
setSelectedRelease((current) => (current?.id === refreshedRelease.id ? refreshedRelease : current));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When an add-commit request completes after the admin selects another release, this guard leaves the new release selected, but the following setters clear its form and show a success notice for the old release. Guard the commit form resets and notice with the same stale-operation check.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/observability/releases/page-client.tsx, line 287:

<comment>When an add-commit request completes after the admin selects another release, this guard leaves the new release selected, but the following setters clear its form and show a success notice for the old release. Guard the commit form resets and notice with the same stale-operation check.</comment>

<file context>
@@ -270,7 +279,12 @@ export default function PageClient() {
+      // flight, and unconditionally setting would yank the view back to the
+      // release the commit was added to.
+      const refreshedRelease = await fetchReleaseByVersion(adminApp, selectedRelease.version);
+      setSelectedRelease((current) => (current?.id === refreshedRelease.id ? refreshedRelease : current));
       setCommitSha("");
       setCommitMessage("");
</file context>

// Open spans (endMs == null) extend the interval to "now". A capped
// trace can under-report its true end; the capped-trace alert already
// flags that view as partial.
const spanEndMs = Math.max(...spans.map((span) => span.endMs ?? loadedAtMs));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a trace has more than 10,000 spans, events from spans after the retained oldest 10,000 are omitted from the waterfall. Extend the event window to the current time, or obtain the uncapped trace interval, whenever the span result is capped.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/observability/traces/page-client.tsx, line 790:

<comment>When a trace has more than 10,000 spans, events from spans after the retained oldest 10,000 are omitted from the waterfall. Extend the event window to the current time, or obtain the uncapped trace interval, whenever the span result is capped.</comment>

<file context>
@@ -738,28 +764,53 @@ export default function PageClient() {
+        // Open spans (endMs == null) extend the interval to "now". A capped
+        // trace can under-report its true end; the capped-trace alert already
+        // flags that view as partial.
+        const spanEndMs = Math.max(...spans.map((span) => span.endMs ?? loadedAtMs));
+        const eventQuery = getSelectedTraceEventQuery(traceId, highlightEventAtMs, {
+          // A deep-linked highlight timestamp widens the window so clock skew
</file context>
Suggested change
const spanEndMs = Math.max(...spans.map((span) => span.endMs ?? loadedAtMs));
const spanEndMs = Math.max(
...spans.map((span) => span.endMs ?? loadedAtMs),
spansResponse.result.length >= 10000 ? loadedAtMs : -Infinity,
);

// Narrow catch around one call: revert and surface. Never swallowed, and
// never a toast — a failed state change must stay on screen.
setOverrides(clearOptimisticStatus(overridesRef.current, issue.id));
setCounts((current) => adjustIssueStatusCounts(current, status, from));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a list refresh completes while a status PATCH is pending, this reverse adjustment applies to fresh server counts instead of the optimistic baseline, so the status tabs can show incorrect totals. Track whether the optimistic delta is still present before undoing it, or reconcile with a fresh server response.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/observability/issues/page-client.tsx, line 251:

<comment>When a list refresh completes while a status PATCH is pending, this reverse adjustment applies to fresh server counts instead of the optimistic baseline, so the status tabs can show incorrect totals. Track whether the optimistic delta is still present before undoing it, or reconcile with a fresh server response.</comment>

<file context>
@@ -193,19 +221,37 @@ export default function PageClient() {
       // Narrow catch around one call: revert and surface. Never swallowed, and
       // never a toast — a failed state change must stay on screen.
       setOverrides(clearOptimisticStatus(overridesRef.current, issue.id));
+      setCounts((current) => adjustIssueStatusCounts(current, status, from));
       setStatusError(error instanceof Error ? error.message : String(error));
+    } finally {
</file context>

.map(parseTraceLinkRow)
.filter((link): link is TraceLink => link != null));
setTraceResultWasCapped(spansResponse.result.length >= 10000);
setLinksResultWasCapped(linksResponse.result.length >= SPAN_LINKS_CAP);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: When a trace has exactly 1,000 span links, this condition still shows a warning claiming that further links exist. Fetch one extra row and compare with the cap, or use a count query before setting this flag.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/observability/traces/page-client.tsx, line 813:

<comment>When a trace has exactly 1,000 span links, this condition still shows a warning claiming that further links exist. Fetch one extra row and compare with the cap, or use a count query before setting this flag.</comment>

<file context>
@@ -738,28 +764,53 @@ export default function PageClient() {
         .map(parseTraceLinkRow)
         .filter((link): link is TraceLink => link != null));
       setTraceResultWasCapped(spansResponse.result.length >= 10000);
+      setLinksResultWasCapped(linksResponse.result.length >= SPAN_LINKS_CAP);
       setNowMs(Date.now());
     } catch (e) {
</file context>

if (!Number.isFinite(value)) throw new Error(`Cannot format a non-finite count: ${value}`);
if (value < 0) throw new Error(`Cannot format a negative count: ${value}`);
if (value < 10_000) return value.toLocaleString();
if (value < 1_000_000) return `${(value / 1_000).toFixed(value < 100_000 ? 1 : 0)}k`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: At the top of the k range the output rolls over to "1000k" rather than into M. formatCount(999_500) produces "1000k" because (value/1000).toFixed(0) rounds to 1000 and the k suffix is appended, while the M branch handles its own rollover gracefully (9_999_999 → "10.0M"). The two magnitude branches are inconsistent: the k branch never carries the decimal at its top, so it prints the malformed "1000k" instead of "1.0M". Guard the k result so it rolls into M when it rounds to 1000.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/observability/format.ts, line 72:

<comment>At the top of the k range the output rolls over to "1000k" rather than into M. `formatCount(999_500)` produces "1000k" because `(value/1000).toFixed(0)` rounds to 1000 and the `k` suffix is appended, while the M branch handles its own rollover gracefully (`9_999_999` → "10.0M"). The two magnitude branches are inconsistent: the k branch never carries the decimal at its top, so it prints the malformed "1000k" instead of "1.0M". Guard the `k` result so it rolls into M when it rounds to 1000.</comment>

<file context>
@@ -51,6 +55,24 @@ export function formatDuration(ms: number | null): string {
+  if (!Number.isFinite(value)) throw new Error(`Cannot format a non-finite count: ${value}`);
+  if (value < 0) throw new Error(`Cannot format a negative count: ${value}`);
+  if (value < 10_000) return value.toLocaleString();
+  if (value < 1_000_000) return `${(value / 1_000).toFixed(value < 100_000 ? 1 : 0)}k`;
+  return `${(value / 1_000_000).toFixed(value < 10_000_000 ? 1 : 0)}M`;
+}
</file context>
Suggested change
if (value < 1_000_000) return `${(value / 1_000).toFixed(value < 100_000 ? 1 : 0)}k`;
if (value < 1_000_000) {
const k = (value / 1_000).toFixed(value < 100_000 ? 1 : 0);
return k === "1000" ? "1.0M" : `${k}k`;
}

// flight, and unconditionally setting would yank the view back to the
// release the commit was added to.
const refreshedRelease = await fetchReleaseByVersion(adminApp, selectedRelease.version);
setSelectedRelease((current) => (current?.id === refreshedRelease.id ? refreshedRelease : current));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The guarded-refresh pattern is now duplicated verbatim between addCommit and addDeployment (fetch by selectedRelease.version, then setSelectedRelease with the id-match guard). Consider extracting a small refreshSelectedRelease(refreshedRelease) helper so the stale-selection logic stays in one place and is used consistently (the create path still sets selectedRelease unconditionally).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/observability/releases/page-client.tsx, line 287:

<comment>The guarded-refresh pattern is now duplicated verbatim between addCommit and addDeployment (fetch by selectedRelease.version, then setSelectedRelease with the id-match guard). Consider extracting a small `refreshSelectedRelease(refreshedRelease)` helper so the stale-selection logic stays in one place and is used consistently (the create path still sets selectedRelease unconditionally).</comment>

<file context>
@@ -270,7 +279,12 @@ export default function PageClient() {
+      // flight, and unconditionally setting would yank the view back to the
+      // release the commit was added to.
+      const refreshedRelease = await fetchReleaseByVersion(adminApp, selectedRelease.version);
+      setSelectedRelease((current) => (current?.id === refreshedRelease.id ? refreshedRelease : current));
       setCommitSha("");
       setCommitMessage("");
</file context>

const { query, params } = getIssueSparklineQuery(hours, wanted);
const response = await queryObservability(adminApp, { query, params });
const parsed = parseIssueSparklineRows(response.result, wanted);
const parsed = parseIssueSparklineRows(response.result, wanted, hours, Date.now());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The sparkline grid is anchored to the browser's Date.now(), but the query window is anchored to the server's now64(3). If the two clocks disagree by more than one bucket step (an hour on the 24h range, a minute on the 1h range), the newest server bucket falls outside the client-rebuilt grid and is silently dropped by the bounds check, so the most recent activity vanishes with no error surfaced. This mirrors the existing services buildServiceTimelines, which has the same skew dependency, but the added Date.now()-based grid here reintroduces it for issues. Anchoring to server time (e.g. snapping via the same now64(3) the query uses, or deriving nowMs from the returned data) would avoid the silent loss; at minimum, document the skew assumption rather than treating the drop as only the intentional partial-bucket clipping.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/observability/issues/use-issue-data.ts, line 173:

<comment>The sparkline grid is anchored to the browser's `Date.now()`, but the query window is anchored to the server's `now64(3)`. If the two clocks disagree by more than one bucket step (an hour on the 24h range, a minute on the 1h range), the newest server bucket falls outside the client-rebuilt grid and is silently dropped by the bounds check, so the most recent activity vanishes with no error surfaced. This mirrors the existing services `buildServiceTimelines`, which has the same skew dependency, but the added `Date.now()`-based grid here reintroduces it for issues. Anchoring to server time (e.g. snapping via the same `now64(3)` the query uses, or deriving `nowMs` from the returned data) would avoid the silent loss; at minimum, document the skew assumption rather than treating the drop as only the intentional partial-bucket clipping.</comment>

<file context>
@@ -166,7 +170,7 @@ export function useIssueSparklines(
         const { query, params } = getIssueSparklineQuery(hours, wanted);
         const response = await queryObservability(adminApp, { query, params });
-        const parsed = parseIssueSparklineRows(response.result, wanted);
+        const parsed = parseIssueSparklineRows(response.result, wanted, hours, Date.now());
         if (cancelled) return;
         setError(null);
</file context>

* dropdown — the URL codec and the API both carry the raw environment name.
*/
const ALL_ENVIRONMENTS_SELECT_VALUE = "all";
const ENVIRONMENT_SELECT_VALUE_PREFIX = "env:";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This re-implements the namespaced select-value codec that already exists for the service filter in service-identity.ts. The environment variant is simpler (plain string rather than an object) but the sentinel/prefix convention is duplicated in the page component instead of next to that codec, risking drift. Consider colocating the environment codec with service-identity's or documenting the shared convention.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/observability/issues/page-client.tsx, line 86:

<comment>This re-implements the namespaced select-value codec that already exists for the service filter in `service-identity.ts`. The environment variant is simpler (plain string rather than an object) but the sentinel/prefix convention is duplicated in the page component instead of next to that codec, risking drift. Consider colocating the environment codec with service-identity's or documenting the shared convention.</comment>

<file context>
@@ -71,7 +76,28 @@ import { useIssueFacets, useIssueSparklines } from "./use-issue-data";
+ * dropdown — the URL codec and the API both carry the raw environment name.
+ */
 const ALL_ENVIRONMENTS_SELECT_VALUE = "all";
+const ENVIRONMENT_SELECT_VALUE_PREFIX = "env:";
+
+function environmentToSelectValue(environment: string | null): string {
</file context>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant