diff --git a/.changeset/bright-requests-observe.md b/.changeset/bright-requests-observe.md new file mode 100644 index 00000000..cd498eec --- /dev/null +++ b/.changeset/bright-requests-observe.md @@ -0,0 +1,5 @@ +--- +"@prisma/studio-core": minor +--- + +Add a Requests observability view with dummy request rows, trace spans, and associated logs. diff --git a/Architecture/navigation-url-state.md b/Architecture/navigation-url-state.md index 5f43a21f..a92de18f 100644 --- a/Architecture/navigation-url-state.md +++ b/Architecture/navigation-url-state.md @@ -8,7 +8,7 @@ Navigation state MUST be URL-driven and managed through `useNavigation` + Nuqs. This architecture governs: -- active Studio view (`table`, `schema`, `console`, `sql`, `stream`) +- active Studio view (`table`, `schema`, `console`, `requests`, `sql`, `stream`) - active schema/table/stream - active stream follow mode - active stream aggregation-panel visibility @@ -86,7 +86,7 @@ Adding a new URL key requires updating `StateKey` in `nuqs.ts` first. When Studio is running without a database connection but with Streams enabled: - the resolved default `view` MUST become `"stream"` instead of `"table"` -- stale database-oriented views such as `table`, `schema`, `console`, and `sql` MUST resolve back to the stream view instead of trying to render database-only UI against a disabled database session +- stale database-oriented views such as `table`, `schema`, `console`, `requests`, and `sql` MUST resolve back to the stream view instead of trying to render database-only UI against a disabled database session When URL params are stale from a previous DB, invalid `schema`/`table` values MUST be resolved to valid current defaults. Shared table page size and infinite-scroll mode are not derived from URL defaults; they are restored through Studio UI state and then mirrored into query behavior by `usePagination`. diff --git a/Architecture/non-standard-ui.md b/Architecture/non-standard-ui.md index 62bc7a88..7a3d35e9 100644 --- a/Architecture/non-standard-ui.md +++ b/Architecture/non-standard-ui.md @@ -145,6 +145,18 @@ It deliberately excludes: - The storage breakdowns also need collapsible ledger-style accounting boxes whose headers surface the section totals when folded shut, plus faint shared-cap annotations that sit beside right-aligned byte values and one shared cap marker spanning both Routing and Exact cache rows, which is not a stock ShadCN pattern. - No stock ShadCN pattern covers that descriptor-driven observability layout, especially when the UI must distinguish logical bytes from physical storage signals, separate search coverage from historical run indexes, hide unconfigured routing rows, and keep the remaining cost caveats explicit instead of inventing unavailable totals. +### Requests Trace Timeline + +- Canonical component: + - [`ui/studio/views/requests/RequestsView.tsx`](ui/studio/views/requests/RequestsView.tsx) +- Closest standard ShadCN alternatives: + - `Table` + - `Badge` + - `ToggleGroup` +- Why it stays non-standard: + - The Requests view needs a dense request-log row that expands in place into a span waterfall. Standard ShadCN components cover the table, badges, and view toggle, but there is no stock ShadCN timeline primitive that can display OpenTelemetry-style nested spans with proportional start offsets and durations. + - The custom portion is limited to the timeline grid and span bars; the surrounding list and controls remain built from ShadCN primitives. + ## Standardization Candidates These are the current high-signal places where Studio is bypassing a plausible standard ShadCN component or composition pattern. diff --git a/Architecture/requests-view.md b/Architecture/requests-view.md new file mode 100644 index 00000000..7c8b30ca --- /dev/null +++ b/Architecture/requests-view.md @@ -0,0 +1,100 @@ +# Requests View Architecture + +This document is normative for the Studio Requests view. + +The Requests view is a database-session view that presents request-level observability data in the existing Studio shell. The first implementation uses deterministic dummy data, but its UI contract is shaped so real request, span, and log data can replace that source later without changing navigation. + +## Scope + +This architecture governs: + +- routing into `view=requests` +- sidebar and command-palette entry points +- request list ordering and summary columns +- in-place request expansion +- trace and log detail toggling +- dummy request/span/log data shape + +## Canonical Components + +- [`ui/studio/Navigation.tsx`](../ui/studio/Navigation.tsx) +- [`ui/studio/CommandPalette.tsx`](../ui/studio/CommandPalette.tsx) +- [`ui/studio/Studio.tsx`](../ui/studio/Studio.tsx) +- [`ui/studio/views/requests/RequestsView.tsx`](../ui/studio/views/requests/RequestsView.tsx) +- [`ui/hooks/use-navigation.tsx`](../ui/hooks/use-navigation.tsx) +- [`ui/hooks/use-ui-state.ts`](../ui/hooks/use-ui-state.ts) + +## Non-Negotiable Rules + +- Requests routing MUST use the existing URL-backed `view` param with the value `requests`. +- The left navigation MUST render Requests in the Studio block immediately after Console and before SQL while database-backed Studio views are available. +- The request list MUST render most recent requests first. +- Summary rows MUST include timestamp, service, path, message, and duration. +- Request expansion MUST happen in place under the clicked row instead of navigating away or opening a modal. +- Expanded request state and the selected detail view MUST use `useUiState` with deterministic request-scoped keys. +- Trace details MUST show proportional span durations for external requests and Prisma OpenTelemetry-style subsystem spans when dummy data includes them. +- Log details MUST handle both string messages and structured object payloads without dropping the original structured object. + +## Dummy Data Contract + +Until Studio is wired to a real request source, dummy rows live in the Requests view module. Each request row MUST include: + +- a stable request id +- timestamp +- service +- method and path +- status +- message +- duration in milliseconds +- trace id +- zero or more trace spans +- zero or more associated log lines + +Each span MUST include: + +- stable span id +- display name +- service +- kind (`request`, `framework`, `external`, or `prisma`) +- start offset in milliseconds +- duration in milliseconds +- nesting depth +- status + +Each log line MUST include: + +- stable log id +- timestamp +- level +- service +- message as either a string or structured object + +## UI State Contract + +The expanded request id MUST be stored through `useUiState` using: + +- `requests:expanded-request` + +The active detail view for each expanded request MUST be stored through `useUiState` using: + +- `requests:${requestId}:detail-view` + +These keys keep request-detail interaction consistent with the existing Studio UI state architecture and avoid introducing component-local shared view state. + +## Forbidden Patterns + +- Do not introduce a second routing system for requests. +- Do not store expanded request state in module globals. +- Do not replace structured log objects with stringified summaries as the source data. +- Do not open trace or log details in a modal or side panel for the default row click behavior. + +## Testing Requirements + +Requests view changes MUST include tests for: + +- `view=requests` routing in `Studio` +- sidebar placement directly under Console +- newest-first dummy request list rendering with the required summary columns +- in-place row expansion +- trace detail rendering for external and Prisma spans +- log detail rendering for both string and structured object payloads diff --git a/Architecture/ui-state.md b/Architecture/ui-state.md index d1234a26..e89c186e 100644 --- a/Architecture/ui-state.md +++ b/Architecture/ui-state.md @@ -136,6 +136,7 @@ The following are valid examples of UI state and where they belong: - Scoped by active schema plus the current visualized table set so returning to the same schema graph restores dragged positions without leaking across schemas. - Includes the stored ELK baseline positions and reset-layout request token used by the header action. - Command-palette `x more...` handoff into table browsing: the same navigation table-name search `useUiState` entry, not a second command-palette-specific table-filter store +- Requests view expanded request id and selected request detail view: `uiLocalStateCollection` via `useUiState` If new UI state is shared across components, it MUST be assigned to one of these stores (or a new TanStack DB collection added in Studio context). Container-level fullscreen controls are now host-owned rather than Studio-owned, so they MUST NOT be reintroduced as implicit package-level shared UI state unless the architecture is updated first. diff --git a/FEATURES.md b/FEATURES.md index 9fe1a3cb..6c3d9a25 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -155,6 +155,12 @@ In table view it surfaces context-aware actions like row search, AI filtering, i Table navigation stays intentionally short by showing only the first 3 tables by default and the top 3 matches while filtering. If more tables exist, the palette shows an `x more...` entry that hands off into the existing sidebar table search so users keep one consistent table-filtering UI. `Search rows` and `Filter with AI` work in two modes: typing the command name keeps them as focus actions for the existing toolbar inputs, while free text turns them into direct `Search rows: ...` and `Filter with AI: ...` actions that execute immediately. Keyboard selection stays active from the moment the palette opens, so arrow keys can move through results before any typing, and the list auto-scrolls the active result into view as you move into lower sections. +## Request Observability + +The Requests view gives Studio a request-centered observability surface for inspecting dummy request data in the same shell as tables, SQL, and Console. +Each request row shows timestamp, service, path, message, and duration with newest requests first, then expands in place to show an OpenTelemetry-style trace timeline or the log lines associated with that request. +The trace view highlights external calls and Prisma subsystem spans, while the logs view preserves both plain string messages and structured JSON objects for detailed inspection. + ## Column Controls and Metadata Columns support drag-and-drop reordering, resizing, sorting, and pinning to keep important fields anchored during wide-table review. diff --git a/ui/studio/CommandPalette.test.tsx b/ui/studio/CommandPalette.test.tsx index 21cb7b6c..934e7111 100644 --- a/ui/studio/CommandPalette.test.tsx +++ b/ui/studio/CommandPalette.test.tsx @@ -20,7 +20,7 @@ interface NavigationMockValue { schemaParam: string; setSchemaParam: () => Promise; setTableParam: () => Promise; - viewParam: "table" | "schema" | "console" | "sql"; + viewParam: "table" | "schema" | "console" | "requests" | "sql"; } interface IntrospectionMockValue { @@ -150,8 +150,11 @@ vi.mock("../hooks/use-ui-state", async () => { vi.mock("./context", () => ({ useStudio: () => ({ + hasDatabase: true, isDarkMode, isNavigationOpen, + navigationWidth: 192, + setNavigationWidth: vi.fn(), setThemeMode: setThemeModeMock, themeMode, toggleNavigation: toggleNavigationMock, @@ -352,6 +355,7 @@ describe("Studio command palette", () => { expect(document.body.textContent).not.toContain("incidents"); expect(document.body.textContent).toContain("Visualizer"); expect(document.body.textContent).toContain("Console"); + expect(document.body.textContent).toContain("Requests"); expect(document.body.textContent).toContain("SQL"); expect(document.body.textContent).toContain("Studio theme"); expect(document.body.textContent).toContain("Light"); diff --git a/ui/studio/CommandPalette.tsx b/ui/studio/CommandPalette.tsx index 671fc58b..08a07b66 100644 --- a/ui/studio/CommandPalette.tsx +++ b/ui/studio/CommandPalette.tsx @@ -5,6 +5,7 @@ import { FileCode2, GalleryVerticalEnd, Laptop, + ListTree, Moon, Search, Sun, @@ -164,7 +165,9 @@ function AppearanceCommandItem(props: { value={value} className={cn( "justify-between gap-3", - disabled ? "text-muted-foreground/55" : "text-foreground hover:bg-secondary/85", + disabled + ? "text-muted-foreground/55" + : "text-foreground hover:bg-secondary/85", )} > @@ -508,6 +511,17 @@ function StudioCommandPalette() { }, section: "views", }, + { + disabled: false, + icon: ListTree, + id: "view:requests", + keywords: ["requests", "logs", "traces", "observability"], + label: "Requests", + onSelect: () => { + window.location.hash = createUrl({ viewParam: "requests" }); + }, + section: "views", + }, { disabled: false, icon: FileCode2, diff --git a/ui/studio/Navigation.test.tsx b/ui/studio/Navigation.test.tsx index ef86d614..7992ad45 100644 --- a/ui/studio/Navigation.test.tsx +++ b/ui/studio/Navigation.test.tsx @@ -17,7 +17,7 @@ interface NavigationMockValue { setSchemaParam: () => Promise; setTableParam: () => Promise; streamParam: string | null; - viewParam: "table" | "schema" | "console" | "sql" | "stream"; + viewParam: "table" | "schema" | "console" | "requests" | "sql" | "stream"; } interface IntrospectionMockValue { @@ -476,6 +476,37 @@ describe("Navigation", () => { container.remove(); }); + it("renders Requests directly under Console in the Studio menu", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + const studioLinks = [ + ...container.querySelectorAll( + 'nav[aria-label="Studio"] a', + ), + ].map((link) => ({ + href: link.getAttribute("href"), + text: link.textContent?.trim(), + })); + + expect(studioLinks).toEqual([ + { href: "#viewParam=schema", text: "Visualizer" }, + { href: "#viewParam=console", text: "Console" }, + { href: "#viewParam=requests", text: "Requests" }, + { href: "#viewParam=sql", text: "SQL" }, + ]); + + act(() => { + root.unmount(); + }); + container.remove(); + }); + it("closes table search on blur when the search input is empty", () => { const container = document.createElement("div"); document.body.appendChild(container); diff --git a/ui/studio/Navigation.tsx b/ui/studio/Navigation.tsx index 8c048906..963636fe 100644 --- a/ui/studio/Navigation.tsx +++ b/ui/studio/Navigation.tsx @@ -526,6 +526,15 @@ export function Navigation({ className }: NavigationProps) { Console + + + Requests + + ({ ConsoleView: () =>
Console view
, })); +vi.mock("./views/requests/RequestsView", () => ({ + RequestsView: () =>
Requests view
, +})); + vi.mock("./views/schema/SchemaView", () => ({ SchemaView: () =>
Schema view
, })); @@ -252,6 +256,46 @@ describe("Studio", () => { container.remove(); }); + it("renders the requests view when navigation selects requests", () => { + useNavigationMock.mockReturnValue({ + metadata: { + activeTable: undefined, + }, + viewParam: "requests", + }); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + expect(container.textContent).toContain("Requests view"); + expect(container.textContent).not.toContain( + "Could not load schema metadata", + ); + + act(() => { + root.unmount(); + }); + container.remove(); + }); + it("renders a database-unavailable placeholder for database views when the session has no database", () => { useStudioMock.mockReturnValue({ hasDatabase: false, diff --git a/ui/studio/Studio.tsx b/ui/studio/Studio.tsx index 6f7e2fe3..314233f9 100644 --- a/ui/studio/Studio.tsx +++ b/ui/studio/Studio.tsx @@ -14,6 +14,7 @@ import { IntrospectionStatusNotice } from "./IntrospectionStatusNotice"; import { Navigation } from "./Navigation"; import { StudioHeader } from "./StudioHeader"; import { ConsoleView } from "./views/console/ConsoleView"; +import { RequestsView } from "./views/requests/RequestsView"; import { SchemaView } from "./views/schema/SchemaView"; import { SqlView } from "./views/sql/SqlView"; import { StreamView } from "./views/stream/StreamView"; @@ -113,6 +114,7 @@ const views: Record JSX.Element | null> = { table: ActiveTableView, stream: StreamView, console: ConsoleView, + requests: RequestsView, sql: SqlView, default: BasicView, }; diff --git a/ui/studio/views/requests/RequestsView.test.tsx b/ui/studio/views/requests/RequestsView.test.tsx new file mode 100644 index 00000000..6f7e577e --- /dev/null +++ b/ui/studio/views/requests/RequestsView.test.tsx @@ -0,0 +1,143 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { RequestsView } from "./RequestsView"; + +const uiStateValues = new Map(); + +vi.mock("../../StudioHeader", () => ({ + StudioHeader: () =>
Studio header
, +})); + +vi.mock("../../../hooks/use-ui-state", async () => { + const React = await vi.importActual("react"); + + return { + useUiState: (key: string | undefined, initialValue: T) => { + const [value, setValue] = React.useState(() => { + if (key && !uiStateValues.has(key)) { + uiStateValues.set(key, initialValue); + } + + return key && uiStateValues.has(key) + ? (uiStateValues.get(key) as T) + : initialValue; + }); + + const setSharedValue = (updater: T | ((previous: T) => T)) => { + setValue((previous) => { + const nextValue = + typeof updater === "function" + ? (updater as (previous: T) => T)(previous) + : updater; + + if (key) { + uiStateValues.set(key, nextValue); + } + + return nextValue; + }); + }; + + return [value, setSharedValue, vi.fn()] as const; + }, + }; +}); + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +function click(element: Element) { + element.dispatchEvent( + new MouseEvent("click", { + bubbles: true, + cancelable: true, + }), + ); +} + +describe("RequestsView", () => { + afterEach(() => { + vi.clearAllMocks(); + uiStateValues.clear(); + document.body.innerHTML = ""; + }); + + it("renders dummy requests newest first with the required columns", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + expect(container.textContent).toContain("Timestamp"); + expect(container.textContent).toContain("Service"); + expect(container.textContent).toContain("Path"); + expect(container.textContent).toContain("Message"); + expect(container.textContent).toContain("Duration"); + + const rows = [ + ...container.querySelectorAll( + "[data-testid^='request-row-']", + ), + ]; + + expect(rows).toHaveLength(5); + expect(rows[0]?.textContent).toContain("/api/invoices"); + expect(rows[0]?.textContent).toContain("identity"); + + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it("expands a request in place and switches between trace and logs", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + const firstRequest = container.querySelector( + "[data-testid='request-row-demo-app-req-00000999']", + ); + + expect(firstRequest).not.toBeNull(); + + act(() => { + click(firstRequest!); + }); + + expect(container.textContent).toContain("Trace timeline"); + expect(container.textContent).toContain("prisma:engine:db_query"); + expect(container.textContent).toContain("api.stripe.com"); + + const logsTrigger = container.querySelector( + "[data-testid='request-detail-logs-trigger']", + ); + + expect(logsTrigger).not.toBeNull(); + + act(() => { + click(logsTrigger!); + }); + + expect(container.textContent).toContain("Request completed"); + expect(container.textContent).toContain( + '"traceId": "demo-app-trace-000249"', + ); + expect(container.textContent).toContain('"fingerprint"'); + + act(() => { + root.unmount(); + }); + container.remove(); + }); +}); diff --git a/ui/studio/views/requests/RequestsView.tsx b/ui/studio/views/requests/RequestsView.tsx new file mode 100644 index 00000000..0fef3706 --- /dev/null +++ b/ui/studio/views/requests/RequestsView.tsx @@ -0,0 +1,988 @@ +import { ChevronDown, ChevronRight, Search } from "lucide-react"; +import { Fragment, useMemo, useState } from "react"; + +import { Badge } from "@/ui/components/ui/badge"; +import { Input } from "@/ui/components/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/ui/components/ui/table"; +import { ToggleGroup, ToggleGroupItem } from "@/ui/components/ui/toggle-group"; +import { useUiState } from "@/ui/hooks/use-ui-state"; +import { cn } from "@/ui/lib/utils"; + +import { StudioHeader } from "../../StudioHeader"; +import type { ViewProps } from "../View"; + +type RequestStatus = "error" | "ok" | "warning"; +type RequestSpanKind = "external" | "framework" | "prisma" | "request"; +type RequestDetailView = "logs" | "trace"; + +interface RequestSpan { + depth: number; + durationMs: number; + id: string; + kind: RequestSpanKind; + name: string; + service: string; + startMs: number; + status: RequestStatus; +} + +interface RequestLogLine { + id: string; + level: "debug" | "error" | "info" | "warn"; + message: string | Record; + service: string; + timestamp: string; +} + +interface RequestEntry { + durationMs: number; + id: string; + logs: RequestLogLine[]; + message: string; + method: string; + path: string; + service: string; + spans: RequestSpan[]; + status: number; + timestamp: string; + traceId: string; +} + +const structuredIdentityLog: Record = { + timestamp: "2026-04-25T03:04:47.877Z", + level: "info", + service: "identity", + environment: "staging", + version: "compute-demo-v1", + region: "cdg", + requestId: "demo-app-req-00000999", + traceId: "demo-app-trace-000249", + spanId: "identity-span-00000999", + method: "PATCH", + path: "/api/invoices", + status: 200, + duration: 121, + message: "Request completed", + why: null, + fix: null, + link: null, + sampling: { + kept: true, + source: "compute-demo-generate", + }, + redaction: { + keys: [], + }, + context: { + actor: { + id: "user-00999", + plan: "pro", + }, + fingerprint: + "f7e2e36ec27723a51b69844018c94d51522f79d5edb3f39558e95351437f49637cc1282947c6128ec8fbf5523842d6772364f943a99f48ee6aefbc21ae3170addc2a268df6379d8e31837352d9cbfe6819b01bed196797e3931689906a8e2dd8", + request: { + bytes: 1511, + routeGroup: "invoices", + traceToken: "f7e2e36ec27723a51b69844018c94d51522f79d5edb3f395", + }, + traceContext: { + traceId: "demo-app-trace-000249", + spanId: "identity-span-00000999", + }, + tenant: "tenant-15", + host: "identity-cdg-3", + releaseChannel: "preview", + }, +}; + +const demoRequests: RequestEntry[] = [ + { + durationMs: 121, + id: "demo-app-req-00000999", + logs: [ + { + id: "demo-app-req-00000999-log-1", + level: "info", + message: "PATCH /api/invoices accepted by edge runtime", + service: "gateway", + timestamp: "2026-04-25T03:04:47.756Z", + }, + { + id: "demo-app-req-00000999-log-2", + level: "debug", + message: "Loaded tenant policy for tenant-15", + service: "identity", + timestamp: "2026-04-25T03:04:47.781Z", + }, + { + id: "demo-app-req-00000999-log-3", + level: "info", + message: "Updated invoice status with Prisma Client", + service: "billing", + timestamp: "2026-04-25T03:04:47.839Z", + }, + { + id: "demo-app-req-00000999-log-4", + level: "info", + message: structuredIdentityLog, + service: "identity", + timestamp: "2026-04-25T03:04:47.877Z", + }, + ], + message: "Request completed", + method: "PATCH", + path: "/api/invoices", + service: "identity", + spans: [ + { + depth: 0, + durationMs: 121, + id: "identity-root", + kind: "request", + name: "PATCH /api/invoices", + service: "identity", + startMs: 0, + status: "ok", + }, + { + depth: 1, + durationMs: 22, + id: "identity-policy", + kind: "framework", + name: "Load tenant policy", + service: "identity", + startMs: 8, + status: "ok", + }, + { + depth: 1, + durationMs: 42, + id: "identity-prisma-operation", + kind: "prisma", + name: "prisma:client:operation Invoice.update", + service: "billing", + startMs: 34, + status: "ok", + }, + { + depth: 2, + durationMs: 5, + id: "identity-prisma-serialize", + kind: "prisma", + name: "prisma:client:serialize", + service: "billing", + startMs: 36, + status: "ok", + }, + { + depth: 2, + durationMs: 31, + id: "identity-prisma-query", + kind: "prisma", + name: "prisma:engine:query", + service: "billing", + startMs: 42, + status: "ok", + }, + { + depth: 3, + durationMs: 18, + id: "identity-prisma-db-query", + kind: "prisma", + name: "prisma:engine:db_query", + service: "postgres", + startMs: 48, + status: "ok", + }, + { + depth: 1, + durationMs: 29, + id: "identity-stripe", + kind: "external", + name: "POST https://api.stripe.com/v1/invoices", + service: "stripe", + startMs: 78, + status: "ok", + }, + { + depth: 1, + durationMs: 9, + id: "identity-audit", + kind: "external", + name: "POST https://audit.internal/events", + service: "audit", + startMs: 108, + status: "ok", + }, + ], + status: 200, + timestamp: "2026-04-25T03:04:47.877Z", + traceId: "demo-app-trace-000249", + }, + { + durationMs: 684, + id: "demo-app-req-00000998", + logs: [ + { + id: "demo-app-req-00000998-log-1", + level: "info", + message: "POST /api/checkout started", + service: "checkout", + timestamp: "2026-04-25T03:03:10.032Z", + }, + { + id: "demo-app-req-00000998-log-2", + level: "warn", + message: "Payment provider retry budget exhausted", + service: "payments", + timestamp: "2026-04-25T03:03:10.598Z", + }, + { + id: "demo-app-req-00000998-log-3", + level: "error", + message: { + error: "ProviderTimeout", + fix: "Retry checkout after provider latency recovers", + link: "https://status.stripe.com/", + path: "/api/checkout", + requestId: "demo-app-req-00000998", + status: 502, + why: "stripe charge confirmation exceeded 500ms budget", + }, + service: "checkout", + timestamp: "2026-04-25T03:03:10.716Z", + }, + ], + message: "Payment provider timeout", + method: "POST", + path: "/api/checkout", + service: "checkout", + spans: [ + { + depth: 0, + durationMs: 684, + id: "checkout-root", + kind: "request", + name: "POST /api/checkout", + service: "checkout", + startMs: 0, + status: "error", + }, + { + depth: 1, + durationMs: 74, + id: "checkout-prisma-operation", + kind: "prisma", + name: "prisma:client:operation Cart.findUnique", + service: "checkout", + startMs: 18, + status: "ok", + }, + { + depth: 2, + durationMs: 49, + id: "checkout-prisma-db-query", + kind: "prisma", + name: "prisma:engine:db_query", + service: "postgres", + startMs: 35, + status: "ok", + }, + { + depth: 1, + durationMs: 501, + id: "checkout-stripe", + kind: "external", + name: "POST https://api.stripe.com/v1/payment_intents", + service: "stripe", + startMs: 111, + status: "error", + }, + { + depth: 1, + durationMs: 38, + id: "checkout-feature", + kind: "external", + name: "GET https://feature-flags.internal/evaluate", + service: "flags", + startMs: 625, + status: "ok", + }, + ], + status: 502, + timestamp: "2026-04-25T03:03:10.716Z", + traceId: "demo-app-trace-000248", + }, + { + durationMs: 78, + id: "demo-app-req-00000997", + logs: [ + { + id: "demo-app-req-00000997-log-1", + level: "info", + message: "Accounts list requested", + service: "api", + timestamp: "2026-04-25T03:01:58.124Z", + }, + { + id: "demo-app-req-00000997-log-2", + level: "debug", + message: "Applied status=active filter", + service: "api", + timestamp: "2026-04-25T03:01:58.155Z", + }, + { + id: "demo-app-req-00000997-log-3", + level: "info", + message: "Request completed", + service: "api", + timestamp: "2026-04-25T03:01:58.202Z", + }, + ], + message: "Fetched active accounts", + method: "GET", + path: "/api/accounts?status=active", + service: "api", + spans: [ + { + depth: 0, + durationMs: 78, + id: "accounts-root", + kind: "request", + name: "GET /api/accounts", + service: "api", + startMs: 0, + status: "ok", + }, + { + depth: 1, + durationMs: 46, + id: "accounts-prisma-operation", + kind: "prisma", + name: "prisma:client:operation Account.findMany", + service: "api", + startMs: 15, + status: "ok", + }, + { + depth: 2, + durationMs: 30, + id: "accounts-prisma-db-query", + kind: "prisma", + name: "prisma:engine:db_query", + service: "postgres", + startMs: 26, + status: "ok", + }, + ], + status: 200, + timestamp: "2026-04-25T03:01:58.202Z", + traceId: "demo-app-trace-000247", + }, + { + durationMs: 246, + id: "demo-app-req-00000996", + logs: [ + { + id: "demo-app-req-00000996-log-1", + level: "info", + message: "Dashboard route started", + service: "web", + timestamp: "2026-04-25T03:00:19.111Z", + }, + { + id: "demo-app-req-00000996-log-2", + level: "info", + message: "Rendered dashboard shell", + service: "web", + timestamp: "2026-04-25T03:00:19.357Z", + }, + ], + message: "Rendered dashboard", + method: "GET", + path: "/dashboard", + service: "web", + spans: [ + { + depth: 0, + durationMs: 246, + id: "dashboard-root", + kind: "request", + name: "GET /dashboard", + service: "web", + startMs: 0, + status: "ok", + }, + { + depth: 1, + durationMs: 63, + id: "dashboard-user", + kind: "external", + name: "GET https://identity.internal/session", + service: "identity", + startMs: 18, + status: "ok", + }, + { + depth: 1, + durationMs: 58, + id: "dashboard-prisma-operation", + kind: "prisma", + name: "prisma:client:operation DashboardMetric.findMany", + service: "web", + startMs: 90, + status: "ok", + }, + { + depth: 2, + durationMs: 41, + id: "dashboard-prisma-db-query", + kind: "prisma", + name: "prisma:engine:db_query", + service: "postgres", + startMs: 103, + status: "ok", + }, + { + depth: 1, + durationMs: 72, + id: "dashboard-render", + kind: "framework", + name: "Render React server components", + service: "web", + startMs: 158, + status: "ok", + }, + ], + status: 200, + timestamp: "2026-04-25T03:00:19.357Z", + traceId: "demo-app-trace-000246", + }, + { + durationMs: 932, + id: "demo-app-req-00000995", + logs: [ + { + id: "demo-app-req-00000995-log-1", + level: "info", + message: "Reconciliation enqueue requested", + service: "jobs", + timestamp: "2026-04-25T02:58:33.419Z", + }, + { + id: "demo-app-req-00000995-log-2", + level: "warn", + message: "Queue handoff was slow but accepted", + service: "jobs", + timestamp: "2026-04-25T02:58:34.351Z", + }, + ], + message: "Queued reconciliation job", + method: "POST", + path: "/api/reconcile", + service: "jobs", + spans: [ + { + depth: 0, + durationMs: 932, + id: "reconcile-root", + kind: "request", + name: "POST /api/reconcile", + service: "jobs", + startMs: 0, + status: "warning", + }, + { + depth: 1, + durationMs: 115, + id: "reconcile-prisma-operation", + kind: "prisma", + name: "prisma:client:operation ReconciliationRun.create", + service: "jobs", + startMs: 24, + status: "ok", + }, + { + depth: 2, + durationMs: 87, + id: "reconcile-prisma-db-query", + kind: "prisma", + name: "prisma:engine:db_query", + service: "postgres", + startMs: 43, + status: "ok", + }, + { + depth: 1, + durationMs: 702, + id: "reconcile-queue", + kind: "external", + name: "POST https://queue.internal/reconciliation", + service: "queue", + startMs: 157, + status: "warning", + }, + ], + status: 202, + timestamp: "2026-04-25T02:58:34.351Z", + traceId: "demo-app-trace-000245", + }, +]; + +demoRequests.sort( + (left, right) => + new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime(), +); + +export function RequestsView(_props: ViewProps) { + const [expandedRequestId, setExpandedRequestId] = useUiState( + "requests:expanded-request", + null, + ); + const [query, setQuery] = useState(""); + const normalizedQuery = query.trim().toLowerCase(); + const visibleRequests = useMemo(() => { + if (normalizedQuery.length === 0) { + return demoRequests; + } + + return demoRequests.filter((request) => + [ + request.id, + request.traceId, + request.service, + request.method, + request.path, + request.message, + String(request.status), + formatDuration(request.durationMs), + ] + .join(" ") + .toLowerCase() + .includes(normalizedQuery), + ); + }, [normalizedQuery]); + + function toggleExpandedRequest(requestId: string) { + setExpandedRequestId((current) => + current === requestId ? null : requestId, + ); + } + + return ( +
+ +
+
+
+

Requests

+

+ {visibleRequests.length} of {demoRequests.length} dummy requests +

+
+
+
+
+ +
+ + + + Timestamp + Service + Path + Message + + Duration + + + + + {visibleRequests.map((request) => { + const isExpanded = request.id === expandedRequestId; + + return ( + + toggleExpandedRequest(request.id)} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") { + return; + } + + event.preventDefault(); + toggleExpandedRequest(request.id); + }} + role="button" + tabIndex={0} + > + + + {formatTimestamp(request.timestamp)} + + + + + {request.service} + + + +
+ + {request.method} + + + {request.path} + +
+
+ +
+ {isExpanded ? ( +
+
+ + {formatDuration(request.durationMs)} + +
+ {isExpanded && ( + + + + + + )} +
+ ); + })} +
+
+
+
+
+ ); +} + +function RequestDetails({ request }: { request: RequestEntry }) { + const [detailView, setDetailView] = useUiState( + `requests:${request.id}:detail-view`, + "trace", + ); + + return ( +
+
+
+ + {request.id} + + + {request.traceId} + +
+ { + if (value === "trace" || value === "logs") { + setDetailView(value); + } + }} + size="sm" + type="single" + value={detailView} + variant="outline" + > + + Trace + + + Logs + + +
+ + {detailView === "trace" ? ( + + ) : ( + + )} +
+ ); +} + +function RequestTraceView({ request }: { request: RequestEntry }) { + const totalDuration = Math.max( + request.durationMs, + ...request.spans.map((span) => span.startMs + span.durationMs), + ); + + return ( +
+
+
+

Trace timeline

+

+ External calls and Prisma OpenTelemetry spans for this request +

+
+ + {formatDuration(totalDuration)} + +
+
+
Span
+
+ {[0, 0.25, 0.5, 0.75, 1].map((tick) => ( +
+ {formatDuration(Math.round(totalDuration * tick))} +
+ ))} +
+
+
+ {request.spans.map((span) => { + const left = percentage(span.startMs, totalDuration); + const width = Math.max(percentage(span.durationMs, totalDuration), 2); + + return ( +
+
+ + {formatSpanKind(span.kind)} + +
+
{span.name}
+
+ {span.service} +
+
+
+
+
+ + {formatDuration(span.durationMs)} + +
+
+
+ ); + })} +
+
+ ); +} + +function RequestLogsView({ request }: { request: RequestEntry }) { + return ( +
+
+
+

Associated logs

+

+ Log lines carrying request ID {request.id} +

+
+ + {request.logs.length} lines + +
+
+ {request.logs.map((log) => ( +
+
+ + {formatTimestamp(log.timestamp)} + + + {log.level} + + {log.service} + + {getLogMessageSummary(log)} + +
+ {typeof log.message !== "string" && ( +
+                {JSON.stringify(log.message, null, 2)}
+              
+ )} +
+ ))} +
+
+ ); +} + +function formatTimestamp(timestamp: string) { + const date = new Date(timestamp); + const month = date.toLocaleString("en-US", { + month: "short", + timeZone: "UTC", + }); + const day = pad(date.getUTCDate(), 2); + const hours = pad(date.getUTCHours(), 2); + const minutes = pad(date.getUTCMinutes(), 2); + const seconds = pad(date.getUTCSeconds(), 2); + const milliseconds = pad(date.getUTCMilliseconds(), 3); + + return `${month} ${day} ${hours}:${minutes}:${seconds}.${milliseconds}`; +} + +function formatDuration(durationMs: number) { + if (durationMs >= 1000) { + return `${(durationMs / 1000).toFixed(2)}s`; + } + + return `${Math.round(durationMs)}ms`; +} + +function formatSpanKind(kind: RequestSpanKind) { + switch (kind) { + case "external": + return "fetch"; + case "framework": + return "app"; + case "prisma": + return "prisma"; + case "request": + return "root"; + } +} + +function getLogMessageSummary(log: RequestLogLine) { + if (typeof log.message === "string") { + return log.message; + } + + const message = log.message.message; + + return typeof message === "string" ? message : "Structured log"; +} + +function getStatusBadgeVariant(status: number) { + if (status >= 500) { + return "destructive"; + } + + if (status >= 400) { + return "secondary"; + } + + return "success"; +} + +function getLogLevelBadgeVariant(level: RequestLogLine["level"]) { + switch (level) { + case "error": + return "destructive"; + case "warn": + return "secondary"; + case "debug": + case "info": + return "outline"; + } +} + +function getSpanBarClasses(span: RequestSpan) { + if (span.status === "error") { + return "bg-destructive/20 text-foreground"; + } + + if (span.status === "warning") { + return "bg-secondary text-secondary-foreground"; + } + + switch (span.kind) { + case "external": + return "bg-primary/15 text-foreground"; + case "framework": + return "bg-accent text-accent-foreground"; + case "prisma": + return "bg-muted text-foreground"; + case "request": + return "bg-background text-foreground"; + } +} + +function percentage(value: number, total: number) { + if (total <= 0) { + return 0; + } + + return (value / total) * 100; +} + +function pad(value: number, length: number) { + return value.toString().padStart(length, "0"); +}