diff --git a/.changeset/busy-onions-obey.md b/.changeset/busy-onions-obey.md new file mode 100644 index 00000000..33dfab3a --- /dev/null +++ b/.changeset/busy-onions-obey.md @@ -0,0 +1,5 @@ +--- +"@prisma/studio-core": minor +--- + +Add stream request observability diff --git a/AGENTS.md b/AGENTS.md index 272da5bb..d4292c58 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,8 @@ These instructions apply to the `@prisma/studio-core` package. - `pnpm lint` - `pnpm test` - `pnpm test:data` +- `pnpm test:data:mysql` when `STUDIO_MYSQL_TEST_URL` or local Vitess/MySQL is available +- `STUDIO_INCLUDE_HEAVY_LOCAL_TESTS=1 pnpm test` only when intentionally exercising heavyweight local suites excluded from the default aggregate - `pnpm test:checkpoint` - `pnpm build` - `pnpm check:exports` diff --git a/Architecture/navigation-url-state.md b/Architecture/navigation-url-state.md index ba0279b7..f8f35b8a 100644 --- a/Architecture/navigation-url-state.md +++ b/Architecture/navigation-url-state.md @@ -11,6 +11,7 @@ This architecture governs: - active Studio view (`table`, `schema`, `console`, `sql`, `stream`, `queries`) - active schema/table/stream - active stream follow mode +- active stream request-observability sheet lookup - active stream aggregation-panel visibility - active stream aggregation range while the aggregation panel is open - pagination URL state @@ -42,6 +43,7 @@ Only keys declared in [`ui/hooks/nuqs.ts`](../ui/hooks/nuqs.ts) are allowed: - `table` - `stream` - `streamFollow` +- `streamObserve` - `aggregations` - `streamAggregationRange` - `filter` @@ -61,6 +63,7 @@ Notes: - `pageIndex` remains URL-backed for table navigation. - `pageSize` remains a supported hash key for compatibility, but table rendering now takes its authoritative rows-per-page preference from `studioUiCollection.tablePageSize` in [`Architecture/ui-state.md`](ui-state.md). - `streamFollow` stores the active stream follow mode (`paused`, `live`, or `tail`). +- `streamObserve` stores the active request-observability lookup for supported Streams profiles. Values serialize as `req:`, `trace:`, or `span:`. - `aggregations` is an open-only flag for the active stream aggregation strip; when present it MUST be serialized as a bare key with no explicit value. - `streamAggregationRange` stores the active stream aggregation range, but MUST only be serialized while `aggregations` is present. @@ -81,6 +84,7 @@ Adding a new URL key requires updating `StateKey` in `nuqs.ts` first. - `queries`: no standalone default; only meaningful when the current adapter provides query insights - `stream`: no default; only meaningful when `view=stream` - `streamFollow`: no global default in `useNavigation`; the active stream view MUST resolve an absent value to `tail` and materialize that into the hash +- `streamObserve`: no global default in `useNavigation`; the active stream view MUST treat an absent or malformed value as a closed request-observability sheet - `aggregations`: no global default in `useNavigation`; the active stream view MUST treat an absent flag as closed and MUST NOT materialize that closed state into the hash - `streamAggregationRange`: no standalone default; the active stream view MUST clear it whenever `aggregations` is absent, and MUST materialize its default range only after the aggregation panel is opened diff --git a/Architecture/non-standard-ui.md b/Architecture/non-standard-ui.md index 209278d9..62397996 100644 --- a/Architecture/non-standard-ui.md +++ b/Architecture/non-standard-ui.md @@ -145,6 +145,25 @@ 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. +### Stream Request Observability Sheet + +- Canonical components: + - [`ui/studio/views/stream/StreamObserveSheet.tsx`](../ui/studio/views/stream/StreamObserveSheet.tsx) + - [`ui/studio/views/stream/StreamObserveTimelineSection.tsx`](../ui/studio/views/stream/StreamObserveTimelineSection.tsx) + - [`ui/studio/views/stream/StreamObserveTraceSection.tsx`](../ui/studio/views/stream/StreamObserveTraceSection.tsx) + - [`ui/studio/views/stream/StreamObserveEventSection.tsx`](../ui/studio/views/stream/StreamObserveEventSection.tsx) + - [`ui/hooks/use-stream-observe-request.ts`](../ui/hooks/use-stream-observe-request.ts) +- Closest standard ShadCN alternatives: + - `Sheet` + - `ToggleGroup` + - `Table` + - `Badge` + - `Skeleton` +- Why it stays non-standard: + - The request detail surface needs to combine a merged event/span timeline, a trace waterfall with proportional span bars, expandable raw span details, service-call edges, root-cause event fields, source stream labels, and coverage warnings in one compact sheet. + - ShadCN provides the surrounding primitives, but no stock component models that request-correlation workflow or the proportional waterfall rows. + - The section selector still uses `ToggleGroup`, the shell uses `Sheet`, and the status chips use `Badge`; only the request-specific timeline and waterfall composition remain custom. + ### Queries Live Table And Detail Sheet - Canonical component: diff --git a/Architecture/request-observability.md b/Architecture/request-observability.md new file mode 100644 index 00000000..e16b92d5 --- /dev/null +++ b/Architecture/request-observability.md @@ -0,0 +1,125 @@ +# Request Observability Architecture + +This document is normative for Studio's request observability surface over Prisma Streams `evlog` and `otel-traces` streams. + +The feature is a stream-detail drilldown, not a standalone Studio view. It lets users expand an observability event or span row, open a request detail sheet, and inspect the correlated event, trace timeline, trace waterfall, errors, service calls, and partial-result warnings returned by Prisma Streams. + +## Scope + +This architecture governs: + +- detection of observability-capable stream profiles +- URL-backed request lookup state +- loading request correlation data from Prisma Streams +- rendering the request detail sheet from a single correlation response +- demo seeding for local request-observability validation + +## Canonical Components + +- [`ui/hooks/use-stream-observe-request.ts`](../ui/hooks/use-stream-observe-request.ts) +- [`ui/studio/views/stream/StreamObserveSheet.tsx`](../ui/studio/views/stream/StreamObserveSheet.tsx) +- [`ui/studio/views/stream/StreamObserveTimelineSection.tsx`](../ui/studio/views/stream/StreamObserveTimelineSection.tsx) +- [`ui/studio/views/stream/StreamObserveTraceSection.tsx`](../ui/studio/views/stream/StreamObserveTraceSection.tsx) +- [`ui/studio/views/stream/StreamObserveEventSection.tsx`](../ui/studio/views/stream/StreamObserveEventSection.tsx) +- [`ui/studio/views/stream/StreamView.tsx`](../ui/studio/views/stream/StreamView.tsx) +- [`demo/ppg-dev/seed-streams.ts`](../demo/ppg-dev/seed-streams.ts) +- [`demo/ppg-dev/seed-streams-scale.ts`](../demo/ppg-dev/seed-streams-scale.ts) + +## Non-Negotiable Rules + +- Request observability MUST only appear for streams whose resolved profile is `evlog` or `otel-traces`. +- Stream profile detection and request-pair descriptors MUST come from Streams metadata normalized by `useStreams` and `useStreamDetails`; feature code MUST NOT infer observability support from stream names. +- The active lookup MUST be URL-backed through `streamObserve` and `useNavigation`; components MUST NOT write or parse `window.location.hash` directly. +- `streamObserve` values MUST serialize as `req:`, `trace:`, or `span:`. +- Expanded event rows MAY expose the request-detail action only when the decoded event body has a usable request ID, trace ID, or span ID for the active profile. +- Correlation loading MUST go through `useStreamObserveRequest`; view components MUST NOT call `/v1/observe/request` directly. +- The request sheet MUST treat the Streams response as authoritative and surface `coverage.warnings` when present. +- Missing counterpart streams MUST be explained in the sheet instead of rendering an empty trace or event section as complete. +- The UI MUST use ShadCN primitives for the sheet, badges, buttons, skeletons, and section selector. The waterfall and timeline are custom request-observability composites and are documented in [`non-standard-ui.md`](non-standard-ui.md). + +## API Contract + +Studio expects the configured Streams base URL to expose: + +- `POST {streamsUrl}/v1/observe/request` + +The hook sends: + +```json +{ + "streams": { + "events": "app-events", + "traces": "app-traces" + }, + "lookup": { + "requestId": "req_123" + }, + "include": { + "events": true, + "trace": true, + "timeline": true + }, + "limits": { + "events": 50, + "spans": 2000 + } +} +``` + +`lookup` contains exactly one of `requestId`, `traceId`, or `spanId`. If one counterpart stream is unavailable, Studio omits that stream and sets the matching include flag to `false`. + +The response is normalized into: + +- `lookup`: resolved request, trace, and span IDs +- `summary`: title, method/path, service, environment, duration, status, level, and error summary fields +- `evlog`: the primary event plus match count +- `trace`: deduplicated spans, tree, critical path, errors, service map, and partial-state metadata +- `timeline`: merged event/span timeline items +- `coverage`: searched sides and warnings + +The sheet renders three sections from that one response: + +- `Timeline`: merged event, span-start, span-event, and exception items +- `Trace`: waterfall rows, span details, errors, and service calls +- `Event`: primary evlog event, root-cause fields, and raw JSON + +## Pairing Model + +When the active stream is `evlog`, Studio uses that stream as the event stream. Its trace counterpart MUST come from `details.observability.request.tracesStream`. +When the active stream is `otel-traces`, Studio uses that stream as the trace stream. Its event counterpart MUST come from `details.observability.request.eventsStream`. + +If the descriptor is absent, Studio may still open the sheet for the active stream side and MUST explain the missing event or trace side. Studio MUST NOT choose the first stream with the opposite profile. + +## Demo Contract + +`pnpm demo:ppg` seeds two local profiled streams: + +- `app-events` with the `evlog` profile +- `app-traces` with the `otel-traces` profile + +The seed data MUST include successful requests, failed requests with root-cause fields, slow requests, event-only requests, trace-only requests, and at least one deeper multi-service trace that exercises nested service calls, repeated network spans, and downstream worker/service spans. The demo also starts a ticker that appends fresh correlated requests so `Tail` mode and request-detail refresh can be exercised locally. + +The demo MUST create both streams with `Content-Type: application/json` before profile installation, and the installed profiles MUST declare their request-observability counterparts. + +`pnpm demo:ppg:seed-scale -- --streams-url ` appends deterministic scale data to the same two streams. It MUST use the shared seed builder so local performance checks exercise the same profile shape as `pnpm demo:ppg`. + +## Forbidden Patterns + +- matching observability streams by hard-coded stream names in the UI +- storing request-detail data in component-local state outside React Query +- adding request-observability methods to the database adapter +- hiding coverage warnings or partial trace state +- inventing request IDs from arbitrary payload text +- adding a standalone request-observability route before the stream row workflow needs one + +## Testing Requirements + +Request observability changes MUST include tests for: + +- lookup param serialization and parsing +- event-row lookup extraction for both `evlog` and `otel-traces` +- descriptor-based counterpart stream resolution without first-profile fallback +- `useStreamObserveRequest` request body, disabled state, failure state, and response normalization +- sheet loading, warning, timeline, trace, event, missing-stream, and close behavior +- stream-row affordance visibility and URL-backed sheet opening +- demo seed shape, profiled stream creation, and scale-seed batch generation diff --git a/Architecture/stream-event-view.md b/Architecture/stream-event-view.md index 7ea0c6a1..67c15fd2 100644 --- a/Architecture/stream-event-view.md +++ b/Architecture/stream-event-view.md @@ -22,6 +22,7 @@ This architecture governs: - URL-backed stream follow mode selection - URL-backed stream search term state - URL-backed stream routing-key selection state +- URL-backed request-observability lookup state - URL-backed aggregation-panel visibility and aggregation range selection - batched reveal of newly arrived events - transient highlighting of newly revealed event rows @@ -34,6 +35,7 @@ This architecture governs: - [`ui/hooks/use-stream-events.ts`](../ui/hooks/use-stream-events.ts) - [`ui/hooks/use-stream-details.ts`](../ui/hooks/use-stream-details.ts) - [`ui/hooks/use-stream-aggregations.ts`](../ui/hooks/use-stream-aggregations.ts) +- [`ui/hooks/use-stream-observe-request.ts`](../ui/hooks/use-stream-observe-request.ts) (`useStreamObserveRequest`) - [`ui/hooks/use-ui-state.ts`](../ui/hooks/use-ui-state.ts) - [`ui/hooks/use-navigation.tsx`](../ui/hooks/use-navigation.tsx) - [`ui/studio/views/stream/StreamView.tsx`](../ui/studio/views/stream/StreamView.tsx) @@ -158,6 +160,9 @@ The stream view MUST treat that latest metadata count separately from `visibleEv - while the suggestion panel is open, background stream refreshes MUST NOT rewrite the suggestion content underneath the user's keyboard navigation; only explicit input changes may do that - keyboard navigation inside the suggestion panel MUST keep exactly one suggestion visually selected at a time and MUST scroll the active row into view as the highlight moves - when `useStreamDetails` exposes one or more aggregation rollups, the header MUST render a sibling icon-only aggregation toggle button with an accessible label instead of a numbered text pill +- when the active stream profile is `evlog` or `otel-traces`, an expanded event row MAY render a request-detail action if the decoded event body has a request ID, trace ID, or span ID usable by that profile +- clicking the request-detail action MUST write the serialized lookup into `streamObserve` through `useNavigation`; it MUST NOT keep the request sheet open state only in component-local state +- when `streamObserve` contains a valid lookup for a supported profile, the stream page MUST render the request-observability sheet and resolve counterpart streams from `useStreamDetails().details.observability` - the aggregation toggle open/closed state MUST be URL-backed through `useNavigation` - that header count SHOULD fall back to the rollup-definition count from `useStreamDetails`, but once aggregate window data has loaded it MUST prefer the resolved aggregation-series count so metrics-style rollups report their real card count - the list remains bounded by `visibleEventCount` until the user reveals newer events @@ -263,6 +268,7 @@ Stream navigation chrome MUST be URL-backed through `useNavigation` with keys su - `streamFollow` - `streamRoutingKey` +- `streamObserve` - `aggregations` - `streamAggregationRange` - `search` @@ -287,6 +293,7 @@ The infinite-scroll `pageCount` and `visibleEventCount` are view-local transient - introducing stream-event URL pagination params - allowing more than one expanded row at a time - fetching aggregation rollups or aggregate windows directly inside `StreamView` without going through the dedicated hooks +- fetching request-observability correlation directly inside `StreamView` without going through `useStreamObserveRequest` - deriving fake indexed fields from arbitrary payload properties ## Testing Requirements @@ -306,6 +313,7 @@ Changes to this architecture MUST include tests for: - expanded-row match highlighting for stream search - aggregation-rollup request normalization in `useStreamAggregations` - stream-view aggregation toggle plus range switching, including range cleanup when the panel closes +- stream-view request-observability affordance and URL-backed sheet state - infinite-scroll page growth behavior for both older history and newly revealed events - stream-view transient highlighting for newly revealed rows, including automatic clearance - stream navigation into `view=stream` diff --git a/Architecture/streams.md b/Architecture/streams.md index 9257cb21..ce99d67d 100644 --- a/Architecture/streams.md +++ b/Architecture/streams.md @@ -16,7 +16,9 @@ This architecture governs: - discovering active-stream routing-key metadata - tracking URL-backed active-stream routing-key selection - discovering active-stream aggregation rollups from stream details metadata +- detecting request-observability stream profiles - loading active-stream aggregate rollup windows through the Prisma Streams aggregate endpoint +- loading request-observability correlation data through the Prisma Streams observe endpoint - loading active-stream filtered events through the Prisma Streams search endpoint - loading active-stream routing-key pages through the Prisma Streams routing-key listing endpoint - deep-linking from a table view into Prisma WAL stream history when `prisma-wal` is available @@ -35,9 +37,11 @@ This architecture governs: - [`ui/hooks/use-stream-routing-keys.ts`](../ui/hooks/use-stream-routing-keys.ts) - [`ui/hooks/use-stream-aggregations.ts`](../ui/hooks/use-stream-aggregations.ts) - [`ui/hooks/use-stream-events.ts`](../ui/hooks/use-stream-events.ts) +- [`ui/hooks/use-stream-observe-request.ts`](../ui/hooks/use-stream-observe-request.ts) - [`ui/studio/Navigation.tsx`](../ui/studio/Navigation.tsx) - [`ui/studio/views/table/ActiveTableView.tsx`](../ui/studio/views/table/ActiveTableView.tsx) - [`ui/studio/views/stream/StreamView.tsx`](../ui/studio/views/stream/StreamView.tsx) +- [`ui/studio/views/stream/StreamObserveSheet.tsx`](../ui/studio/views/stream/StreamObserveSheet.tsx) - [`ui/studio/views/stream/StreamRoutingKeySelector.tsx`](../ui/studio/views/stream/StreamRoutingKeySelector.tsx) - [`ui/studio/views/stream/StreamAggregationsPanel.tsx`](../ui/studio/views/stream/StreamAggregationsPanel.tsx) - [`demo/ppg-dev/config.ts`](../demo/ppg-dev/config.ts) @@ -54,12 +58,14 @@ This architecture governs: - Active-stream search-capability discovery MUST go through [`useStreamDetails`](../ui/hooks/use-stream-details.ts); feature code MUST NOT read `_details.schema.search` ad hoc from view components. - Active-stream routing-key discovery MUST go through [`useStreamDetails`](../ui/hooks/use-stream-details.ts); feature code MUST NOT read `_details.schema.routingKey` ad hoc from view components. - Active-stream aggregation-rollup discovery MUST go through [`useStreamDetails`](../ui/hooks/use-stream-details.ts); feature code MUST NOT read `_details.schema.search.rollups` ad hoc from view components. +- Active-stream request-observability profile detection and pairing descriptors MUST go through [`useStreamDetails`](../ui/hooks/use-stream-details.ts); feature code MUST NOT infer observability support from stream names or by picking another stream with the opposite profile. - Active-stream storage, upload, and index-status diagnostics MUST go through [`useStreamDetails`](../ui/hooks/use-stream-details.ts); feature code MUST NOT add a separate `_index_status` fetch path from the stream footer or other active-stream chrome. - Active-stream aggregate window loading MUST go through [`useStreamAggregations`](../ui/hooks/use-stream-aggregations.ts); feature code MUST NOT `POST` stream `_aggregate` ad hoc from view components. - Active-stream count refresh MUST reuse [`useStreamDetails`](../ui/hooks/use-stream-details.ts); feature code MUST NOT introduce a second count or metadata polling path for the active stream page. - Stream event loading MUST go through [`useStreamEvents`](../ui/hooks/use-stream-events.ts); feature code MUST NOT fetch `/v1/stream/:name` ad hoc from view components. - Stream search loading MUST also go through [`useStreamEvents`](../ui/hooks/use-stream-events.ts); feature code MUST NOT `POST` stream `_search` ad hoc from view components. - Stream routing-key listing MUST go through [`useStreamRoutingKeys`](../ui/hooks/use-stream-routing-keys.ts); feature code MUST NOT fetch stream `_routing_keys` ad hoc from view components. +- Request-observability correlation MUST go through [`useStreamObserveRequest`](../ui/hooks/use-stream-observe-request.ts); feature code MUST NOT `POST` `/v1/observe/request` ad hoc from view components. - When Studio discovers a `prisma-wal` stream, table view MAY expose a history affordance that deep-links into `view=stream&stream=prisma-wal`, but that jump MUST still be expressed through normal URL state and the shared stream search param rather than inventing a table-specific WAL route. - When the active stream is `prisma-wal`, the resolved stream profile is `state-protocol`, and the visible search term matches Studio's table-history deep-link shapes, the stream view SHOULD render a compact WAL scope banner describing the current table or row scope instead of leaving that context implicit in the raw search string alone. - `useStreams` MUST treat `streamsUrl` as a base URL and append the Prisma Streams list endpoint path (`/v1/streams`) itself. @@ -99,6 +105,10 @@ For routing-key-capable streams, Studio may also use the routing-key listing end - `GET {streamsUrl}/v1/stream/{streamName}/_routing_keys?limit={n}&after={cursor}` +For request observability on `evlog` and `otel-traces` streams, Studio may also use the request observe endpoint: + +- `POST {streamsUrl}/v1/observe/request` + The response is treated as a list of stream records containing at least: - `name` @@ -108,12 +118,15 @@ The response is treated as a list of stream records containing at least: - `next_offset` - `sealed_through` - `uploaded_through` +- `profile` +- `observability` `useStreams` normalizes that payload into the `StudioStream` shape used by the sidebar. On the active stream page, Studio instead uses `_details.stream` as the authoritative summary payload for `epoch`, `next_offset`, and the footer byte total, and keeps that summary current through `_details` conditional long polling. That long-poll path must keep one stable loop alive across ETag updates instead of restarting the request on every successful `200`, so the browser does not accumulate a stream of client-side canceled `_details` fetches between real wakes. If `_details.schema.search` is present, `useStreamDetails` also normalizes the advertised field bindings, aliases, default fields, and primary timestamp metadata so the stream view can render the shared search control and build correct `_search` requests without re-parsing raw schema JSON. If `_details.schema.routingKey` is present, `useStreamDetails` also normalizes that routing-key pointer metadata so the stream view can render a routing-key selector, preserve its selected-key state, and optionally compose that key into an exact search clause when the schema advertises a matching exact keyword field. If `_details.schema.search.rollups` is present, `useStreamDetails` also normalizes that rollup metadata into Studio's aggregation-rollup model, including advertised dimensions, so the view can render aggregation controls without re-parsing raw schema JSON. +If `_details.stream.observability.request` is present, `useStreamDetails` normalizes `events_stream` and `traces_stream` into the active-stream request-observability descriptor. That descriptor is the only supported source for a counterpart stream. If `_details.stream` includes WAL or pending-tail metadata, `_details.storage` exposes byte/object buckets, `_details.object_store_requests` exposes node-local request accounting, and `_details.index_status` is present, `useStreamDetails` also normalizes those diagnostics into the active-stream details model so the footer diagnostics popover can describe upload coverage, object-storage composition, retained-local-storage buckets, node-local object-store request counts, and per-index/search-family progress, including the routing-key lexicon family, without inventing a second metadata request. When a stream is actively selected, `useStreamDetails` may also read `GET {streamsUrl}/v1/server/_details` to normalize server-wide configured cache limits for those diagnostics; that server-scoped descriptor must stay inside the same hook instead of introducing a second view-local fetch path. `useStreamEvents` computes the encoded `offset` for the currently requested tail window, fetches decoded JSON events from the stream read endpoint, and normalizes them into `StudioStreamEvent` rows for the main event list. @@ -123,6 +136,7 @@ When the active stream search term is non-empty and the stream advertises search Studio uses those resolved series both to render the aggregation strip and to upgrade the header aggregation toggle from raw rollup-count metadata to the real visible aggregation count once the aggregate query has loaded. The hook only polls those aggregate windows when the stream view is in `live` or `tail` follow mode and the selected range is relative. Per-series aggregation preferences such as enabled statistics and unit overrides remain in the TanStack DB-backed local UI state collection as user-authored state; aggregate fetches may read them but MUST NOT rewrite them just because a different range resolves a different set of series or statistics. +When the active stream profile is `evlog` or `otel-traces`, expanded rows may expose a request-detail action that opens `StreamObserveSheet`. The sheet uses the URL-backed `streamObserve` lookup state and calls `useStreamObserveRequest`, which posts the active lookup plus the active stream and any descriptor-resolved counterpart stream names to `/v1/observe/request`. See [`Architecture/request-observability.md`](request-observability.md) for the detailed contract. When a table is open and the discovered stream list includes `prisma-wal`, `ActiveTableView` may construct a stream deep link using the WAL schema's exact-search aliases. The table-scoped jump must use `table:"schema.table"`, and if exactly one visible row is selected and the table has exactly one primary-key column, the row-scoped jump may refine that query to `table:"schema.table" AND key:"value"`. Composite or multi-row selections must not invent an unsupported key encoding. When Studio later opens that `prisma-wal` search in the stream view, the active page may recognize exactly those table-scoped and row-scoped query forms and render a small contextual banner such as `Showing wal events for public.posts` or `Showing wal events for row key 42 in public.posts`. That banner is intentionally narrow: if the search term adds any extra clauses or stops matching the known WAL history shapes, Studio must hide the banner instead of trying to summarize an arbitrary WAL query. @@ -168,5 +182,9 @@ Streams changes MUST include tests for: - stream-view aggregation-button rendering and aggregation-panel range behavior - `ppg-dev` config wiring for the browser-facing Streams URL - `ppg-dev` proxy handling for aggregate `POST` requests +- `ppg-dev` observability seed creation for profiled `evlog` and `otel-traces` streams +- `ppg-dev` scale seed command for deterministic request-observability load data +- stream-view request-observability affordance and URL-backed sheet opening +- `useStreamObserveRequest` request body and response normalization When the compute bundle path changes, tests MUST also verify that the packaged demo can boot and serve `/api/config` with Streams enabled. diff --git a/FEATURES.md b/FEATURES.md index e0fa7c5f..f3d06d19 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -97,6 +97,14 @@ The same popover also splits search coverage from run accelerators, so users can When the Streams server also advertises node-wide cache limits through `GET /v1/server/_details`, Studio annotates the local cache rows with faint shared-cap labels such as `(512 MiB cap)` instead of pretending those limits belong to one stream. Segment and companion caches show those caps inline, while Routing and Exact caches share one centered disk-cache cap marker across both rows because they draw from the same server-side run-cache budget. When Streams does not expose a meaningful lag duration for a coverage or accelerator row, Studio simply omits that lag text instead of rendering distracting placeholders like `Unavailable behind`. +## Stream Request Observability + +When the active stream profile is `evlog` or `otel-traces`, expanded event rows can open a request details sheet from a correlated request ID, trace ID, or span ID. +Studio uses the active stream details' explicit `observability.request` descriptor to pair event and trace streams, instead of guessing from the first opposite-profile stream. +The sheet calls the Streams `POST /v1/observe/request` endpoint through Studio's `/api/streams` proxy, keeps the lookup in the URL hash as `streamObserve`, and renders a merged timeline, trace waterfall, primary evlog event, root-cause fields, service calls, span errors, source stream labels, and partial-result warnings from the single response. +If only one observability stream is available, the sheet still opens and explains the missing event or trace side instead of presenting an empty result as complete. +The local `ppg-dev` demo now seeds paired `app-events` and `app-traces` streams with realistic successes, failures, slow requests, event-only requests, trace-only requests, and deeper multi-service production-style traces, then appends fresh correlated requests on a timer so request details, Tail mode, and refresh behavior can be validated against a live local Streams server. `pnpm demo:ppg:seed-scale -- --streams-url ` appends deterministic scale data for local performance checks. + ## Stream Search and Match Highlighting When a stream advertises search capability in its `_details` descriptor, Studio reuses the same compact expandable search control used by tables instead of introducing a separate stream-only search box. diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 00000000..9a786d5c --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,33 @@ +# Product + +## Register + +product + +## Users + +Prisma developers and operators using Studio inside CLI, Console, or embedded host surfaces to inspect data, debug streams and query behavior, edit rows, run SQL, and understand database or stream state while actively working. + +## Product Purpose + +Prisma Studio provides an embeddable visual database and Streams workbench for exploring schema, browsing and editing data, filtering and searching records, inspecting query activity, and following stream event history. Success means users can move from live operational signals to the underlying data or stream events without leaving the task surface. + +## Brand Personality + +Precise, calm, operational. The interface should feel like a trustworthy Prisma tool: dense when needed, restrained, predictable, and respectful of existing host product chrome. + +## Anti-references + +Avoid decorative dashboards, marketing-style hero layouts, over-designed cards, non-standard controls, custom UI that bypasses ShadCN without justification, compatibility fallback paths, and noisy status copy that distracts from the active workflow. + +## Design Principles + +- Make current state inspectable: expose enough status, coverage, and diagnostics for operators to trust what they are seeing. +- Keep workflows close to the data: navigation, filters, query details, and stream diagnostics should connect directly to the relevant row, query, request, or event. +- Use familiar controls first: ShadCN primitives, standard table, sheet, and popover patterns, and existing Studio conventions should carry new features. +- Preserve task flow: background refresh, live updates, and AI helpers should assist without stealing focus or changing visible state unexpectedly. +- Stay embeddable: Studio owns its internal interaction model while leaving auth, routing, tenancy, and product chrome to the host. + +## Accessibility & Inclusion + +Target accessible product UI behavior: keyboard navigation for controls, visible focus states, readable contrast, reduced-motion-safe state changes, and screen-reader labels for icon-only actions. diff --git a/README.md b/README.md index 42442540..aeb7bb5c 100644 --- a/README.md +++ b/README.md @@ -625,11 +625,15 @@ Revert to the published npm packages with `pnpm streams:use-npm`. ## Useful Commands - `pnpm demo:ppg` - run local Studio demo with seeded Prisma Postgres dev +- `pnpm demo:ppg:seed-scale -- --streams-url ` - append deterministic request-observability scale data to a running Streams server - `pnpm typecheck` - run TypeScript checks -- `pnpm lint` - run ESLint (`--fix`) -- `pnpm test` - run default vitest suite +- `pnpm lint` - run ESLint +- `pnpm lint:fix` - run ESLint with automatic fixes +- `pnpm test` - run default vitest suite with external MySQL integration and heavyweight local suites skipped unless explicitly enabled +- `STUDIO_INCLUDE_HEAVY_LOCAL_TESTS=1 pnpm test` - include the Compute bundle boot smoke test and monolithic active-table filtering suite - `pnpm test:checkpoint` - run checkpoint tests - `pnpm test:data` - run data-layer tests +- `pnpm test:data:mysql` - run MySQL/Vitess-backed data integration tests against `STUDIO_MYSQL_TEST_URL` or `mysql://root@localhost:15306/studio` - `pnpm test:demo` - run demo/server tests - `pnpm test:ui` - run UI tests - `pnpm test:e2e` - run e2e tests diff --git a/data/mysql-core/adapter.test.ts b/data/mysql-core/adapter.test.ts index 9da64b61..68574902 100644 --- a/data/mysql-core/adapter.test.ts +++ b/data/mysql-core/adapter.test.ts @@ -5,14 +5,25 @@ import type { Adapter } from "../adapter"; import { createMySQL2Executor } from "../mysql2"; import { createMySQLAdapter } from "./adapter"; -describe("mysql-core/adapter", () => { +const MYSQL_TEST_URL = process.env.STUDIO_MYSQL_TEST_URL; +const describeMysql = MYSQL_TEST_URL ? describe : describe.skip; + +function getMysqlTestUrl(): string { + if (!MYSQL_TEST_URL) { + throw new Error( + "STUDIO_MYSQL_TEST_URL is required for MySQL integration tests", + ); + } + + return MYSQL_TEST_URL; +} + +describeMysql("mysql-core/adapter", () => { let adapter: Adapter; let pool: Pool; beforeAll(async () => { - // we connect to vitess instead of regular mysql because vitess is more restrictive. - // pool = createPool("mysql://root:root@localhost:3306/studio"); - pool = createPool("mysql://root@localhost:15306/studio"); + pool = createPool(getMysqlTestUrl()); const executor = createMySQL2Executor(pool); adapter = createMySQLAdapter({ executor }); diff --git a/data/mysql-core/dml.test.ts b/data/mysql-core/dml.test.ts index 2cdcc071..0184abaf 100644 --- a/data/mysql-core/dml.test.ts +++ b/data/mysql-core/dml.test.ts @@ -25,7 +25,20 @@ import { mockSelectQuery, } from "./dml"; -describe("mysql-core/dml", () => { +const MYSQL_TEST_URL = process.env.STUDIO_MYSQL_TEST_URL; +const describeMysql = MYSQL_TEST_URL ? describe : describe.skip; + +function getMysqlTestUrl(): string { + if (!MYSQL_TEST_URL) { + throw new Error( + "STUDIO_MYSQL_TEST_URL is required for MySQL integration tests", + ); + } + + return MYSQL_TEST_URL; +} + +describeMysql("mysql-core/dml", () => { let executor: Executor; let introspection: ReturnType; let pool: Pool; @@ -269,9 +282,7 @@ describe("mysql-core/dml", () => { now: baseTimestamp, }); - // we connect to vitess instead of regular mysql because vitess is more restrictive. - // pool = createPool("mysql://root:root@localhost:3306/studio"); - pool = createPool("mysql://root@localhost:15306/studio"); + pool = createPool(getMysqlTestUrl()); executor = createMySQL2Executor(pool); introspection = mockIntrospect(); table = introspection.schemas.studio.tables.users; diff --git a/data/mysql-core/introspection.test.ts b/data/mysql-core/introspection.test.ts index 2357cb73..7799950d 100644 --- a/data/mysql-core/introspection.test.ts +++ b/data/mysql-core/introspection.test.ts @@ -14,14 +14,25 @@ import { createMySQL2Executor } from "../mysql2"; import { asQuery, type Query } from "../query"; import { getTablesQuery, getTimezoneQuery } from "./introspection"; -describe("mysql-core/introspection", () => { +const MYSQL_TEST_URL = process.env.STUDIO_MYSQL_TEST_URL; +const describeMysql = MYSQL_TEST_URL ? describe : describe.skip; + +function getMysqlTestUrl(): string { + if (!MYSQL_TEST_URL) { + throw new Error( + "STUDIO_MYSQL_TEST_URL is required for MySQL integration tests", + ); + } + + return MYSQL_TEST_URL; +} + +describeMysql("mysql-core/introspection", () => { let executor: Executor; let pool: Pool; beforeAll(async () => { - // we connect to vitess instead of regular mysql because vitess is more restrictive. - // pool = createPool("mysql://root:root@localhost:3306/studio"); - pool = createPool("mysql://root@localhost:15306/studio"); + pool = createPool(getMysqlTestUrl()); executor = createMySQL2Executor(pool); await pool.query(` diff --git a/demo/ppg-dev/runtime.test.ts b/demo/ppg-dev/runtime.test.ts index 5a932974..2c9c7307 100644 --- a/demo/ppg-dev/runtime.test.ts +++ b/demo/ppg-dev/runtime.test.ts @@ -12,8 +12,9 @@ function createFakePostgresClient() { describe("startDemoRuntime", () => { it("starts the local Prisma Dev stack by default", async () => { const fakePostgresClient = createFakePostgresClient(); + const closePrismaDevServerMock = vi.fn(() => Promise.resolve()); const startPrismaDevServerMock = vi.fn().mockResolvedValue({ - close: vi.fn(() => Promise.resolve()), + close: closePrismaDevServerMock, database: { connectionString: "postgres://local-demo-db", }, @@ -24,6 +25,11 @@ describe("startDemoRuntime", () => { }, }); const seedDatabaseMock = vi.fn(() => Promise.resolve()); + const seedObservabilityStreamsMock = vi.fn(() => Promise.resolve()); + const stopObservabilityTickerMock = vi.fn(); + const startObservabilityStreamTickerMock = vi.fn( + () => stopObservabilityTickerMock, + ); const createPostgresExecutorMock = vi.fn(() => ({ execute: vi.fn(), })); @@ -38,12 +44,20 @@ describe("startDemoRuntime", () => { createPostgresExecutor: createPostgresExecutorMock as never, createSeededTimestamp: () => "2026-03-30T10:00:00.000Z", seedDatabase: seedDatabaseMock, + seedObservabilityStreams: seedObservabilityStreamsMock, + startObservabilityStreamTicker: startObservabilityStreamTickerMock, startPrismaDevServer: startPrismaDevServerMock, }, ); expect(startPrismaDevServerMock).toHaveBeenCalledTimes(1); expect(seedDatabaseMock).toHaveBeenCalledWith("postgres://local-demo-db"); + expect(seedObservabilityStreamsMock).toHaveBeenCalledWith({ + streamsServerUrl: "http://127.0.0.1:51216", + }); + expect(startObservabilityStreamTickerMock).toHaveBeenCalledWith({ + streamsServerUrl: "http://127.0.0.1:51216", + }); expect(runtime.mode).toBe("local"); expect(runtime.hasDatabase).toBe(true); expect(runtime.databaseConnectionString).toBe("postgres://local-demo-db"); @@ -51,7 +65,59 @@ describe("startDemoRuntime", () => { expect(runtime.streamsServerUrl).toBe("http://127.0.0.1:51216"); expect(runtime.prismaDevServer).not.toBeNull(); expect(createPostgresExecutorMock).toHaveBeenCalledWith(fakePostgresClient); - expect(runtime.cleanupCallbacks).toHaveLength(2); + expect(runtime.cleanupCallbacks).toHaveLength(3); + + for (const cleanupCallback of runtime.cleanupCallbacks) { + await cleanupCallback(); + } + + expect(stopObservabilityTickerMock).toHaveBeenCalledTimes(1); + expect(closePrismaDevServerMock).toHaveBeenCalledTimes(1); + }); + + it("closes the local Prisma Dev server when observability seeding fails", async () => { + const closePrismaDevServerMock = vi.fn(() => Promise.resolve()); + const startPrismaDevServerMock = vi.fn().mockResolvedValue({ + close: closePrismaDevServerMock, + database: { + connectionString: "postgres://local-demo-db", + }, + experimental: { + streams: { + serverUrl: "http://127.0.0.1:51216", + }, + }, + }); + const seedDatabaseMock = vi.fn(() => Promise.resolve()); + const seedObservabilityStreamsMock = vi.fn(() => + Promise.reject(new Error("seed failed")), + ); + const startObservabilityStreamTickerMock = vi.fn(); + + await expect( + startDemoRuntime( + { + databaseUrl: null, + streamsServerUrl: null, + }, + { + createPostgresClient: vi.fn( + () => createFakePostgresClient() as never, + ), + seedDatabase: seedDatabaseMock, + seedObservabilityStreams: seedObservabilityStreamsMock, + startObservabilityStreamTicker: startObservabilityStreamTickerMock, + startPrismaDevServer: startPrismaDevServerMock, + }, + ), + ).rejects.toThrow("seed failed"); + + expect(seedDatabaseMock).toHaveBeenCalledWith("postgres://local-demo-db"); + expect(seedObservabilityStreamsMock).toHaveBeenCalledWith({ + streamsServerUrl: "http://127.0.0.1:51216", + }); + expect(startObservabilityStreamTickerMock).not.toHaveBeenCalled(); + expect(closePrismaDevServerMock).toHaveBeenCalledTimes(1); }); it("uses external data sources without starting local Prisma Dev or seeding", async () => { diff --git a/demo/ppg-dev/runtime.ts b/demo/ppg-dev/runtime.ts index a0f65675..00ad7cfb 100644 --- a/demo/ppg-dev/runtime.ts +++ b/demo/ppg-dev/runtime.ts @@ -9,6 +9,10 @@ import { hasExternalStreamsServerUrl, } from "./runtime-options"; import { seedDatabase } from "./seed-database"; +import { + seedObservabilityStreams, + startObservabilityStreamTicker, +} from "./seed-streams"; type PrismaDevServer = Awaited>; type PostgresExecutor = ReturnType; @@ -35,6 +39,8 @@ interface DemoRuntimeDependencies { createPostgresExecutor?: typeof createPostgresJSExecutor; createSeededTimestamp?: () => string; seedDatabase?: typeof seedDatabase; + seedObservabilityStreams?: typeof seedObservabilityStreams; + startObservabilityStreamTicker?: typeof startObservabilityStreamTicker; startPrismaDevServer?: typeof startPrismaDevServer; } @@ -52,6 +58,11 @@ export async function startDemoRuntime( const createSeededTimestamp = dependencies.createSeededTimestamp ?? (() => new Date().toISOString()); const seedDatabaseImpl = dependencies.seedDatabase ?? seedDatabase; + const seedObservabilityStreamsImpl = + dependencies.seedObservabilityStreams ?? seedObservabilityStreams; + const startObservabilityStreamTickerImpl = + dependencies.startObservabilityStreamTicker ?? + startObservabilityStreamTicker; const startPrismaDevServerImpl = dependencies.startPrismaDevServer ?? startPrismaDevServer; @@ -93,12 +104,41 @@ export async function startDemoRuntime( }; } + // The observability demo streams receive appends every few seconds, which + // would keep the local Streams server's WAL search overlay permanently + // outside its default 5s quiet period and make fresh events unsearchable. + process.env.DS_SEARCH_WAL_OVERLAY_QUIET_MS ??= "250"; + const prismaDevServer = await startPrismaDevServerImpl({ name: `studio-ppg-demo-${process.pid}`, }); cleanupCallbacks.push(() => prismaDevServer.close()); - await seedDatabaseImpl(prismaDevServer.database.connectionString); + try { + await seedDatabaseImpl(prismaDevServer.database.connectionString); + + const localStreamsServerUrl = + prismaDevServer.experimental.streams.serverUrl; + + if (localStreamsServerUrl) { + await seedObservabilityStreamsImpl({ + streamsServerUrl: localStreamsServerUrl, + }); + + const stopObservabilityTicker = startObservabilityStreamTickerImpl({ + streamsServerUrl: localStreamsServerUrl, + }); + + cleanupCallbacks.push(() => stopObservabilityTicker()); + } + } catch (error) { + await Promise.allSettled( + cleanupCallbacks.map((cleanupCallback) => + Promise.resolve().then(() => cleanupCallback()), + ), + ); + throw error; + } const postgresClient = createPostgresClient( prismaDevServer.database.connectionString, diff --git a/demo/ppg-dev/seed-streams-scale.test.ts b/demo/ppg-dev/seed-streams-scale.test.ts new file mode 100644 index 00000000..e6d24c7e --- /dev/null +++ b/demo/ppg-dev/seed-streams-scale.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; + +import { + buildObservabilityScaleSeed, + parseScaleSeedArgs, +} from "./seed-streams-scale"; + +describe("parseScaleSeedArgs", () => { + it("parses the reusable scale-seed CLI options", () => { + const options = parseScaleSeedArgs([ + "--streams-url", + "http://127.0.0.1:55591", + "--batches=12", + "--seed", + "42", + "--spacing-ms", + "1000", + "--now", + "2026-06-11T12:00:00.000Z", + ]); + + expect(options).toEqual({ + batches: 12, + now: new Date("2026-06-11T12:00:00.000Z"), + randomSeed: 42, + spacingMs: 1000, + streamsServerUrl: "http://127.0.0.1:55591", + }); + }); + + it("requires a streams URL", () => { + const originalStreamsUrl = process.env.STREAMS_URL; + const originalStudioStreamsUrl = process.env.STUDIO_STREAMS_URL; + + try { + delete process.env.STREAMS_URL; + delete process.env.STUDIO_STREAMS_URL; + + expect(() => parseScaleSeedArgs(["--batches", "2"])).toThrow( + /Missing --streams-url/, + ); + } finally { + process.env.STREAMS_URL = originalStreamsUrl; + process.env.STUDIO_STREAMS_URL = originalStudioStreamsUrl; + } + }); + + it("does not treat a following flag as a streams URL value", () => { + const originalStreamsUrl = process.env.STREAMS_URL; + const originalStudioStreamsUrl = process.env.STUDIO_STREAMS_URL; + + try { + delete process.env.STREAMS_URL; + delete process.env.STUDIO_STREAMS_URL; + + expect(() => + parseScaleSeedArgs(["--streams-url", "--batches", "2"]), + ).toThrow(/Missing --streams-url/); + } finally { + process.env.STREAMS_URL = originalStreamsUrl; + process.env.STUDIO_STREAMS_URL = originalStudioStreamsUrl; + } + }); +}); + +describe("buildObservabilityScaleSeed", () => { + it("builds deterministic multi-batch observability data", () => { + const seed = buildObservabilityScaleSeed({ + batches: 3, + now: new Date("2026-06-11T12:00:00.000Z"), + randomSeed: 99, + spacingMs: 60_000, + }); + const requestIds = new Set( + seed.events.map((event) => event.requestId).filter(Boolean), + ); + + expect(seed.events.length).toBe(36); + expect(seed.spans.length).toBe(168); + expect(requestIds.size).toBe(36); + expect(seed.events[0]?.timestamp).toBe("2026-06-11T11:58:50.000Z"); + expect(seed.events.at(-1)?.timestamp).toBe("2026-06-11T11:24:00.000Z"); + }); +}); diff --git a/demo/ppg-dev/seed-streams-scale.ts b/demo/ppg-dev/seed-streams-scale.ts new file mode 100644 index 00000000..ef4085b2 --- /dev/null +++ b/demo/ppg-dev/seed-streams-scale.ts @@ -0,0 +1,145 @@ +import { + appendObservabilitySeed, + buildObservabilityStreamSeed, + type DemoObservabilitySeed, + ensureObservabilityStreams, +} from "./seed-streams"; + +export interface ScaleSeedOptions { + batches: number; + now: Date; + randomSeed: number; + spacingMs: number; + streamsServerUrl: string; +} + +const DEFAULT_BATCHES = 140; +const DEFAULT_RANDOM_SEED = 0x51ca1e; +const DEFAULT_SPACING_MS = 30_000; + +function parsePositiveInteger(value: string, label: string): number { + const parsed = Number(value); + + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${label} must be a positive integer`); + } + + return parsed; +} + +function readOptionValue(args: string[], name: string): string | null { + const inlinePrefix = `${name}=`; + const inline = args.find((arg) => arg.startsWith(inlinePrefix)); + + if (inline) { + return inline.slice(inlinePrefix.length); + } + + const index = args.indexOf(name); + + if (index < 0) { + return null; + } + + const next = args[index + 1]; + + return next && !next.startsWith("-") ? next : null; +} + +export function parseScaleSeedArgs(args: string[]): ScaleSeedOptions { + const streamsServerUrl = + readOptionValue(args, "--streams-url") ?? + process.env.STREAMS_URL ?? + process.env.STUDIO_STREAMS_URL ?? + null; + + if (!streamsServerUrl) { + throw new Error( + "Missing --streams-url. Example: pnpm demo:ppg:seed-scale -- --streams-url http://127.0.0.1:55591", + ); + } + + const batches = parsePositiveInteger( + readOptionValue(args, "--batches") ?? String(DEFAULT_BATCHES), + "--batches", + ); + const randomSeed = parsePositiveInteger( + readOptionValue(args, "--seed") ?? String(DEFAULT_RANDOM_SEED), + "--seed", + ); + const spacingMs = parsePositiveInteger( + readOptionValue(args, "--spacing-ms") ?? String(DEFAULT_SPACING_MS), + "--spacing-ms", + ); + const nowRaw = readOptionValue(args, "--now"); + const now = nowRaw ? new Date(nowRaw) : new Date(); + + if (Number.isNaN(now.getTime())) { + throw new Error("--now must be an ISO timestamp"); + } + + return { + batches, + now, + randomSeed, + spacingMs, + streamsServerUrl, + }; +} + +export function buildObservabilityScaleSeed( + options: Pick< + ScaleSeedOptions, + "batches" | "now" | "randomSeed" | "spacingMs" + >, +): DemoObservabilitySeed { + const events: Array> = []; + const spans: Array> = []; + + for (let index = 0; index < options.batches; index += 1) { + const batchSeed = buildObservabilityStreamSeed({ + now: new Date(options.now.getTime() - index * options.spacingMs), + randomSeed: options.randomSeed + index, + }); + + events.push(...batchSeed.events); + spans.push(...batchSeed.spans); + } + + return { events, spans }; +} + +export async function runScaleSeed(options: ScaleSeedOptions): Promise<{ + events: number; + spans: number; +}> { + await ensureObservabilityStreams({ + streamsServerUrl: options.streamsServerUrl, + }); + + const seed = buildObservabilityScaleSeed(options); + + await appendObservabilitySeed({ + seed, + streamsServerUrl: options.streamsServerUrl, + }); + + return { + events: seed.events.length, + spans: seed.spans.length, + }; +} + +if (import.meta.main) { + try { + const options = parseScaleSeedArgs(Bun.argv.slice(2)); + const result = await runScaleSeed(options); + + console.info( + `[demo] appended ${result.events} evlog events and ${result.spans} otel spans to ${options.streamsServerUrl}`, + ); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/demo/ppg-dev/seed-streams.test.ts b/demo/ppg-dev/seed-streams.test.ts new file mode 100644 index 00000000..217bc274 --- /dev/null +++ b/demo/ppg-dev/seed-streams.test.ts @@ -0,0 +1,273 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + buildObservabilityStreamSeed, + DEMO_OBSERVABILITY_EVENTS_STREAM, + DEMO_OBSERVABILITY_TRACES_STREAM, + seedObservabilityStreams, +} from "./seed-streams"; + +const TRACE_ID_PATTERN = /^[0-9a-f]{32}$/; +const SPAN_ID_PATTERN = /^[0-9a-f]{16}$/; + +function createOkResponse() { + return { + ok: true, + status: 200, + text: () => Promise.resolve(""), + }; +} + +describe("buildObservabilityStreamSeed", () => { + const now = new Date("2026-06-11T12:00:00.000Z"); + const seed = buildObservabilityStreamSeed({ now }); + + it("produces correlated evlog events and otel spans", () => { + expect(seed.events.length).toBeGreaterThanOrEqual(12); + expect(seed.spans.length).toBeGreaterThanOrEqual(55); + + const spanTraceIds = new Set( + seed.spans.map((span) => span.traceId as string), + ); + + for (const traceId of spanTraceIds) { + expect(traceId).toMatch(TRACE_ID_PATTERN); + } + + for (const span of seed.spans) { + expect(span.spanId).toMatch(SPAN_ID_PATTERN); + expect(typeof span.startUnixNano).toBe("string"); + expect(typeof span.endUnixNano).toBe("string"); + } + + const correlatedEvents = seed.events.filter( + (event) => typeof event.traceId === "string", + ); + + expect(correlatedEvents.length).toBeGreaterThan(0); + + for (const event of correlatedEvents) { + expect(spanTraceIds.has(event.traceId as string)).toBe(true); + } + }); + + it("links root spans back to the evlog request id", () => { + const rootSpans = seed.spans.filter((span) => span.parentSpanId === null); + + expect(rootSpans.length).toBeGreaterThan(0); + + const eventRequestIds = new Set( + seed.events.map((event) => event.requestId as string), + ); + const correlatedRootSpans = rootSpans.filter((span) => { + const attributes = span.attributes as Record; + + return typeof attributes["request.id"] === "string"; + }); + + expect(correlatedRootSpans.length).toBeGreaterThan(0); + + const correlatedRequestIds = correlatedRootSpans.map((span) => { + const attributes = span.attributes as Record; + + return attributes["request.id"] as string; + }); + + expect( + correlatedRequestIds.some((requestId) => eventRequestIds.has(requestId)), + ).toBe(true); + }); + + it("includes the documented partial-coverage failure modes", () => { + const eventOnlyRequests = seed.events.filter( + (event) => event.traceId === null, + ); + const eventTraceIds = new Set( + seed.events + .map((event) => event.traceId) + .filter((traceId): traceId is string => typeof traceId === "string"), + ); + const traceOnlyTraceIds = new Set( + seed.spans + .map((span) => span.traceId as string) + .filter((traceId) => !eventTraceIds.has(traceId)), + ); + + expect(eventOnlyRequests.length).toBeGreaterThan(0); + expect(traceOnlyTraceIds.size).toBeGreaterThan(0); + }); + + it("includes an error request with root-cause fields", () => { + const errorEvent = seed.events.find((event) => event.level === "error"); + + expect(errorEvent).toBeDefined(); + expect(typeof errorEvent?.why).toBe("string"); + expect(typeof errorEvent?.fix).toBe("string"); + }); + + it("includes production-shaped nested traces", () => { + expect(seed.events.map((event) => event.message)).toEqual( + expect.arrayContaining([ + "Inventory reservation retried", + "Query insights snapshot viewed", + "Workspace dashboard opened", + ]), + ); + + const snapshotEvent = seed.events.find( + (event) => event.message === "Query insights snapshot viewed", + ); + + expect(snapshotEvent).toMatchObject({ + duration: 3486, + method: "POST", + path: "/api/query-insights/snapshot", + service: "console", + status: 200, + }); + + const snapshotTraceId = snapshotEvent?.traceId as string; + const snapshotSpans = seed.spans.filter( + (span) => span.traceId === snapshotTraceId, + ); + + expect(snapshotSpans).toHaveLength(19); + expect( + snapshotSpans.some( + (span) => span.name === "postgresql client local-db:5432", + ), + ).toBe(true); + expect( + snapshotSpans.some( + (span) => span.name === "Durable Object TENANT_MANAGER", + ), + ).toBe(true); + + const rootSpan = snapshotSpans.find((span) => span.parentSpanId === null); + + expect(rootSpan?.name).toBe("fetchHandler POST"); + expect( + snapshotSpans.filter((span) => span.parentSpanId === rootSpan?.spanId) + .length, + ).toBeGreaterThanOrEqual(2); + + const serviceNames = new Set( + snapshotSpans.map((span) => { + const resource = span.resource as { + attributes: Record; + }; + + return resource.attributes["service.name"]; + }), + ); + + expect(serviceNames.has("console")).toBe(true); + expect(serviceNames.has("tenant-manager")).toBe(true); + + const localDbSpan = snapshotSpans.find( + (span) => span.name === "postgresql client local-db:5432", + ); + const localDbAttributes = localDbSpan?.attributes as Record< + string, + unknown + >; + + expect(localDbAttributes["network.protocol.name"]).toBe("tcp"); + expect(localDbAttributes["server.address"]).toBe("local-db"); + expect(localDbAttributes["server.port"]).toBe(5432); + }); + + it("is deterministic for a fixed seed and time", () => { + const again = buildObservabilityStreamSeed({ now }); + + expect(again).toEqual(seed); + }); +}); + +describe("seedObservabilityStreams", () => { + it("creates both profiled streams and appends the seed batches", async () => { + const calls: Array<{ body?: string; method?: string; url: string }> = []; + const fetchImpl = vi.fn( + ( + url: string, + init?: { + body?: string; + headers?: Record; + method?: string; + }, + ) => { + calls.push({ body: init?.body, method: init?.method, url }); + + return Promise.resolve(createOkResponse()); + }, + ); + + await seedObservabilityStreams({ + fetchImpl, + now: new Date("2026-06-11T12:00:00.000Z"), + streamsServerUrl: "http://127.0.0.1:9999/", + }); + + expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([ + `PUT http://127.0.0.1:9999/v1/stream/${DEMO_OBSERVABILITY_EVENTS_STREAM}`, + `POST http://127.0.0.1:9999/v1/stream/${DEMO_OBSERVABILITY_EVENTS_STREAM}/_profile`, + `PUT http://127.0.0.1:9999/v1/stream/${DEMO_OBSERVABILITY_TRACES_STREAM}`, + `POST http://127.0.0.1:9999/v1/stream/${DEMO_OBSERVABILITY_TRACES_STREAM}/_profile`, + `POST http://127.0.0.1:9999/v1/stream/${DEMO_OBSERVABILITY_EVENTS_STREAM}`, + `POST http://127.0.0.1:9999/v1/stream/${DEMO_OBSERVABILITY_TRACES_STREAM}`, + ]); + + const eventsProfileCall = calls[1]; + const tracesProfileCall = calls[3]; + + expect(JSON.parse(eventsProfileCall?.body ?? "{}")).toMatchObject({ + profile: { + kind: "evlog", + observability: { + request: { + tracesStream: DEMO_OBSERVABILITY_TRACES_STREAM, + }, + }, + }, + }); + expect(JSON.parse(tracesProfileCall?.body ?? "{}")).toMatchObject({ + profile: { + kind: "otel-traces", + observability: { + request: { + eventsStream: DEMO_OBSERVABILITY_EVENTS_STREAM, + }, + }, + }, + }); + + const eventsAppendCall = calls[4]; + const appendedEvents = JSON.parse(eventsAppendCall?.body ?? "[]") as Array< + Record + >; + + expect(Array.isArray(appendedEvents)).toBe(true); + expect(appendedEvents.length).toBeGreaterThan(0); + }); + + it("fails loudly when the profile install is rejected", async () => { + const fetchImpl = vi.fn((url: string, init?: { method?: string }) => { + if (init?.method === "POST" && url.endsWith("/_profile")) { + return Promise.resolve({ + ok: false, + status: 400, + text: () => Promise.resolve("profile rejected"), + }); + } + + return Promise.resolve(createOkResponse()); + }); + + await expect( + seedObservabilityStreams({ + fetchImpl, + streamsServerUrl: "http://127.0.0.1:9999", + }), + ).rejects.toThrow(/profile rejected/); + }); +}); diff --git a/demo/ppg-dev/seed-streams.ts b/demo/ppg-dev/seed-streams.ts new file mode 100644 index 00000000..a0667d1f --- /dev/null +++ b/demo/ppg-dev/seed-streams.ts @@ -0,0 +1,1156 @@ +export const DEMO_OBSERVABILITY_EVENTS_STREAM = "app-events"; +export const DEMO_OBSERVABILITY_TRACES_STREAM = "app-traces"; + +const DEFAULT_TICKER_INTERVAL_MS = 6_000; +const JSON_HEADERS = { "content-type": "application/json" } as const; + +type FetchImplementation = ( + input: string, + init?: { + body?: string; + headers?: Record; + method?: string; + }, +) => Promise<{ ok: boolean; status: number; text(): Promise }>; + +interface DemoSpanSeed { + attributes?: Record; + durationMs: number; + errorMessage?: string; + exception?: { + message: string; + offsetMs: number; + type: string; + }; + kind: "client" | "consumer" | "internal" | "producer" | "server"; + name: string; + parentIndex?: number; + resourceAttributes?: Record; + service: string; + startOffsetMs: number; +} + +interface DemoRequestSeed { + ageMs: number; + context?: Record; + durationMs: number; + fix?: string; + level: "debug" | "error" | "info" | "warn"; + message: string; + method: string; + path: string; + route?: string; + service: string; + /** When false, no evlog event is emitted (trace-only request). */ + skipEvent?: boolean; + /** When true, no otel spans are emitted (event-only request). */ + skipTrace?: boolean; + spans: DemoSpanSeed[]; + status: number; + why?: string; +} + +export interface DemoObservabilitySeed { + events: Array>; + spans: Array>; +} + +function createDeterministicRandom(seed: number): () => number { + let state = seed >>> 0; + + return () => { + state += 0x6d2b79f5; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function createHexIdFactory(random: () => number) { + return (length: number): string => { + let id = ""; + + for (let index = 0; index < length; index += 1) { + id += Math.floor(random() * 16).toString(16); + } + + // All-zero trace/span ids are rejected by the otel-traces profile. + return id.includes("1") || id.includes("f") ? id : `1${id.slice(1)}`; + }; +} + +function toUnixNanoString(unixMs: number): string { + return (BigInt(Math.round(unixMs)) * 1_000_000n).toString(); +} + +function buildDemoRequestSeeds(): DemoRequestSeed[] { + const consoleWorkerResource = { + "cloud.platform": "cloudflare-workers", + "cloud.provider": "cloudflare", + "cloud.region": "earth", + "faas.max_memory": 134_217_728, + "scope.name": "@microlabs/otel-cf-workers", + "service.namespace": "control-plane", + }; + const tenantManagerResource = { + "cloud.platform": "cloudflare-workers", + "cloud.provider": "cloudflare", + "cloud.region": "earth", + "faas.max_memory": 134_217_728, + "scope.name": "@microlabs/otel-cf-workers", + "service.namespace": "control-plane", + }; + const checkoutSpans: DemoSpanSeed[] = [ + { + attributes: { + "http.response.status_code": 402, + "http.route": "/api/checkout", + }, + durationMs: 234, + errorMessage: "card declined", + kind: "server", + name: "POST /api/checkout", + service: "checkout", + startOffsetMs: 0, + }, + { + attributes: { "db.operation": "SELECT", "db.system": "postgresql" }, + durationMs: 8, + kind: "client", + name: "SELECT users", + parentIndex: 0, + service: "checkout", + startOffsetMs: 12, + }, + { + attributes: { "db.operation": "SELECT", "db.system": "postgresql" }, + durationMs: 11, + kind: "client", + name: "SELECT carts", + parentIndex: 0, + service: "checkout", + startOffsetMs: 24, + }, + { + attributes: { "url.full": "https://payments.internal/charges" }, + durationMs: 151, + errorMessage: "402 from issuer", + exception: { + message: "Card declined by issuer", + offsetMs: 149, + type: "CardDeclinedError", + }, + kind: "client", + name: "POST payments /charges", + parentIndex: 0, + service: "payments", + startOffsetMs: 41, + }, + ]; + + const productsSpans = (cacheHit: boolean): DemoSpanSeed[] => [ + { + attributes: { + "http.response.status_code": 200, + "http.route": "/api/products", + }, + durationMs: cacheHit ? 14 : 56, + kind: "server", + name: "GET /api/products", + service: "storefront", + startOffsetMs: 0, + }, + { + attributes: { "db.system": "redis" }, + durationMs: 3, + kind: "client", + name: cacheHit ? "GET cache products" : "MISS cache products", + parentIndex: 0, + service: "storefront", + startOffsetMs: 2, + }, + ...(cacheHit + ? [] + : [ + { + attributes: { + "db.operation": "SELECT", + "db.system": "postgresql", + }, + durationMs: 38, + kind: "client", + name: "SELECT products", + parentIndex: 0, + service: "storefront", + startOffsetMs: 9, + } satisfies DemoSpanSeed, + ]), + ]; + + const queryInsightsSnapshotSpans: DemoSpanSeed[] = [ + { + attributes: { + "http.request.method": "POST", + "http.response.status_code": 200, + "network.protocol.name": "http", + "url.path": "/api/query-insights/snapshot", + }, + durationMs: 3486, + kind: "server", + name: "fetchHandler POST", + resourceAttributes: consoleWorkerResource, + service: "console", + startOffsetMs: 0, + }, + { + durationMs: 3486, + kind: "internal", + name: "action.studio.bff", + parentIndex: 0, + resourceAttributes: consoleWorkerResource, + service: "console", + startOffsetMs: 0, + }, + { + durationMs: 1494, + kind: "internal", + name: "getSessionActor", + parentIndex: 1, + resourceAttributes: consoleWorkerResource, + service: "console", + startOffsetMs: 13, + }, + { + attributes: { + "db.operation": "get", + "db.system": "cloudflare-kv", + }, + durationMs: 101, + kind: "client", + name: "KV kvUserSessions get", + parentIndex: 2, + resourceAttributes: consoleWorkerResource, + service: "console", + startOffsetMs: 38, + }, + { + attributes: { + "db.system": "postgresql", + "network.protocol.name": "tcp", + "server.address": "local-db", + "server.port": 5432, + }, + durationMs: 431, + kind: "client", + name: "postgresql client local-db:5432", + parentIndex: 2, + resourceAttributes: consoleWorkerResource, + service: "console", + startOffsetMs: 431, + }, + { + attributes: { + "db.system": "postgresql", + "network.protocol.name": "tcp", + "server.address": "local-db", + "server.port": 5432, + }, + durationMs: 321, + kind: "client", + name: "postgresql client local-db:5432", + parentIndex: 2, + resourceAttributes: consoleWorkerResource, + service: "console", + startOffsetMs: 862, + }, + { + attributes: { + "db.system": "postgresql", + "network.protocol.name": "tcp", + "server.address": "local-db", + "server.port": 5432, + }, + durationMs: 328, + kind: "client", + name: "postgresql client local-db:5432", + parentIndex: 2, + resourceAttributes: consoleWorkerResource, + service: "console", + startOffsetMs: 1189, + }, + { + durationMs: 1992, + kind: "internal", + name: "getQueryInsightsSnapshot", + parentIndex: 1, + resourceAttributes: consoleWorkerResource, + service: "console", + startOffsetMs: 1494, + }, + { + attributes: { + "db.operation": "executeSql", + "rpc.service": "tenant-manager", + }, + durationMs: 1040, + kind: "client", + name: "control-plane.tenant-manager.ppg.executesql", + parentIndex: 7, + resourceAttributes: consoleWorkerResource, + service: "console", + startOffsetMs: 1494, + }, + { + attributes: { + "rpc.method": "executeSql", + "rpc.service": "tenantManager", + }, + durationMs: 1040, + kind: "client", + name: "Service Binding tenantManager", + parentIndex: 8, + resourceAttributes: consoleWorkerResource, + service: "console", + startOffsetMs: 1494, + }, + { + attributes: { + "http.request.method": "POST", + "http.response.status_code": 200, + }, + durationMs: 1040, + kind: "server", + name: "fetchHandler POST", + parentIndex: 9, + resourceAttributes: tenantManagerResource, + service: "tenant-manager", + startOffsetMs: 1494, + }, + { + attributes: { + "cloudflare.durable_object.name": "TENANT_MANAGER", + }, + durationMs: 1040, + kind: "internal", + name: "Durable Object TENANT_MANAGER", + parentIndex: 10, + resourceAttributes: tenantManagerResource, + service: "tenant-manager", + startOffsetMs: 1494, + }, + { + attributes: { + "db.operation": "executeSql", + "rpc.service": "tenant-manager", + }, + durationMs: 952, + kind: "client", + name: "control-plane.tenant-manager.ppg.executesql", + parentIndex: 7, + resourceAttributes: consoleWorkerResource, + service: "console", + startOffsetMs: 2534, + }, + { + attributes: { + "rpc.method": "executeSql", + "rpc.service": "tenantManager", + }, + durationMs: 950, + kind: "client", + name: "Service Binding tenantManager", + parentIndex: 12, + resourceAttributes: consoleWorkerResource, + service: "console", + startOffsetMs: 2536, + }, + { + attributes: { + "http.request.method": "POST", + "http.response.status_code": 200, + }, + durationMs: 948, + kind: "server", + name: "fetchHandler POST", + parentIndex: 13, + resourceAttributes: tenantManagerResource, + service: "tenant-manager", + startOffsetMs: 2538, + }, + { + attributes: { + "cloudflare.durable_object.name": "TENANT_MANAGER", + }, + durationMs: 946, + kind: "internal", + name: "Durable Object TENANT_MANAGER", + parentIndex: 14, + resourceAttributes: tenantManagerResource, + service: "tenant-manager", + startOffsetMs: 2540, + }, + { + attributes: { + "db.system": "postgresql", + "network.protocol.name": "tcp", + "server.address": "local-db", + "server.port": 5432, + }, + durationMs: 625, + kind: "client", + name: "postgresql client local-db:5432", + parentIndex: 0, + resourceAttributes: consoleWorkerResource, + service: "console", + startOffsetMs: 0, + }, + { + attributes: { + "db.system": "postgresql", + "network.protocol.name": "tcp", + "server.address": "local-db", + "server.port": 5432, + }, + durationMs: 414, + kind: "client", + name: "postgresql client local-db:5432", + parentIndex: 0, + resourceAttributes: consoleWorkerResource, + service: "console", + startOffsetMs: 625, + }, + { + attributes: { + "db.system": "postgresql", + "network.protocol.name": "tcp", + "server.address": "local-db", + "server.port": 5432, + }, + durationMs: 625, + kind: "client", + name: "postgresql client local-db:5432", + parentIndex: 0, + resourceAttributes: consoleWorkerResource, + service: "console", + startOffsetMs: 1040, + }, + ]; + + return [ + { + ageMs: 70_000, + context: { + cartId: "cart_551", + paymentProvider: "stripe", + userId: "user_910", + }, + durationMs: 234, + fix: "Ask the customer to retry with a different card.", + level: "error", + message: "Payment failed", + method: "POST", + path: "/api/checkout", + route: "/api/checkout", + service: "checkout", + spans: checkoutSpans, + status: 402, + why: "Card declined by issuer", + }, + { + ageMs: 40_000, + context: { resultCount: 18 }, + durationMs: 612, + fix: "Add a covering index for category + price ordering.", + level: "warn", + message: "Product search exceeded latency budget", + method: "GET", + path: "/api/search", + route: "/api/search", + service: "storefront", + spans: [ + { + attributes: { + "http.response.status_code": 200, + "http.route": "/api/search", + }, + durationMs: 612, + kind: "server", + name: "GET /api/search", + service: "storefront", + startOffsetMs: 0, + }, + { + attributes: { + "db.operation": "SELECT", + "db.system": "postgresql", + }, + durationMs: 540, + kind: "client", + name: "SELECT products search", + parentIndex: 0, + service: "storefront", + startOffsetMs: 31, + }, + ], + status: 200, + why: "Sequential scan on products for an unindexed sort.", + }, + { + ageMs: 22 * 60_000, + context: { plan: "pro" }, + durationMs: 187, + fix: "Surface the duplicate-email validation before submit.", + level: "error", + message: "Signup failed", + method: "POST", + path: "/api/signup", + route: "/api/signup", + service: "accounts", + spans: [ + { + attributes: { + "http.response.status_code": 500, + "http.route": "/api/signup", + }, + durationMs: 187, + errorMessage: "unique constraint violation", + kind: "server", + name: "POST /api/signup", + service: "accounts", + startOffsetMs: 0, + }, + { + attributes: { + "db.operation": "INSERT", + "db.system": "postgresql", + }, + durationMs: 24, + errorMessage: "duplicate key value violates unique constraint", + exception: { + message: + 'duplicate key value violates unique constraint "users_email_key"', + offsetMs: 22, + type: "UniqueConstraintViolation", + }, + kind: "client", + name: "INSERT users", + parentIndex: 0, + service: "accounts", + startOffsetMs: 96, + }, + ], + status: 500, + why: "Email already exists but the form allowed resubmission.", + }, + { + ageMs: 95_000, + context: { + deploymentType: "preview", + projectId: "prj_4096", + queryGroups: 47, + tenantId: "tenant_8b12", + }, + durationMs: 3486, + level: "info", + message: "Query insights snapshot viewed", + method: "POST", + path: "/api/query-insights/snapshot", + route: "/api/query-insights/snapshot", + service: "console", + spans: queryInsightsSnapshotSpans, + status: 200, + }, + { + ageMs: 6 * 60_000, + context: { + projectId: "prj_9f34", + workspaceId: "wrk_eu_central", + }, + durationMs: 842, + level: "info", + message: "Workspace dashboard opened", + method: "GET", + path: "/api/workspaces/wrk_eu_central/dashboard", + route: "/api/workspaces/:id/dashboard", + service: "console", + spans: [ + { + attributes: { + "http.response.status_code": 200, + "http.route": "/api/workspaces/:id/dashboard", + }, + durationMs: 842, + kind: "server", + name: "GET /api/workspaces/:id/dashboard", + resourceAttributes: consoleWorkerResource, + service: "console", + startOffsetMs: 0, + }, + { + durationMs: 132, + kind: "internal", + name: "getSessionActor", + parentIndex: 0, + resourceAttributes: consoleWorkerResource, + service: "console", + startOffsetMs: 8, + }, + { + attributes: { + "db.operation": "SELECT", + "db.system": "postgresql", + }, + durationMs: 188, + kind: "client", + name: "SELECT workspace projects", + parentIndex: 0, + resourceAttributes: consoleWorkerResource, + service: "console", + startOffsetMs: 154, + }, + { + attributes: { + "rpc.method": "listDatabases", + "rpc.service": "tenantManager", + }, + durationMs: 411, + kind: "client", + name: "Service Binding tenantManager", + parentIndex: 0, + resourceAttributes: consoleWorkerResource, + service: "console", + startOffsetMs: 356, + }, + { + attributes: { + "http.request.method": "POST", + "http.response.status_code": 200, + }, + durationMs: 398, + kind: "server", + name: "fetchHandler POST", + parentIndex: 3, + resourceAttributes: tenantManagerResource, + service: "tenant-manager", + startOffsetMs: 365, + }, + { + attributes: { + "db.operation": "SELECT", + "db.system": "postgresql", + }, + durationMs: 259, + kind: "client", + name: "SELECT query_insights latest", + parentIndex: 0, + resourceAttributes: consoleWorkerResource, + service: "console", + startOffsetMs: 512, + }, + { + attributes: { "db.system": "redis" }, + durationMs: 44, + kind: "client", + name: "GET cache workspace-summary", + parentIndex: 0, + resourceAttributes: consoleWorkerResource, + service: "console", + startOffsetMs: 782, + }, + ], + status: 200, + }, + { + ageMs: 13 * 60_000, + context: { + orderId: "order_8831", + reservationAttempt: 2, + sku: "sku_hoodie_black_l", + }, + durationMs: 1280, + fix: "Increase stock-service timeout and keep the retry queue enabled.", + level: "warn", + message: "Inventory reservation retried", + method: "POST", + path: "/api/inventory/reservations", + route: "/api/inventory/reservations", + service: "checkout", + spans: [ + { + attributes: { + "http.response.status_code": 202, + "http.route": "/api/inventory/reservations", + }, + durationMs: 1280, + kind: "server", + name: "POST /api/inventory/reservations", + service: "checkout", + startOffsetMs: 0, + }, + { + attributes: { "db.system": "redis" }, + durationMs: 18, + kind: "client", + name: "SET lock inventory-reservation", + parentIndex: 0, + service: "checkout", + startOffsetMs: 11, + }, + { + attributes: { + "http.request.method": "POST", + "http.response.status_code": 504, + "url.full": "https://inventory.internal/reservations", + }, + durationMs: 702, + errorMessage: "upstream timeout", + exception: { + message: "inventory service timed out after 700ms", + offsetMs: 700, + type: "UpstreamTimeoutError", + }, + kind: "client", + name: "POST inventory /reservations", + parentIndex: 0, + service: "inventory", + startOffsetMs: 54, + }, + { + attributes: { + "db.operation": "SELECT", + "db.system": "postgresql", + }, + durationMs: 64, + kind: "client", + name: "SELECT inventory fallback", + parentIndex: 0, + service: "checkout", + startOffsetMs: 786, + }, + { + attributes: { + "messaging.destination.name": "inventory-reservations", + "messaging.operation": "publish", + "messaging.system": "sqs", + }, + durationMs: 87, + kind: "producer", + name: "publish inventory retry", + parentIndex: 0, + service: "checkout", + startOffsetMs: 890, + }, + { + attributes: { + "db.operation": "UPDATE", + "db.system": "postgresql", + }, + durationMs: 93, + kind: "client", + name: "UPDATE orders reservation_status", + parentIndex: 0, + service: "checkout", + startOffsetMs: 1004, + }, + { + attributes: { + "http.request.method": "POST", + "http.response.status_code": 200, + "url.full": "https://notifications.internal/events", + }, + durationMs: 116, + kind: "client", + name: "POST notification reservation_delayed", + parentIndex: 0, + service: "notifications", + startOffsetMs: 1128, + }, + ], + status: 202, + why: "The stock service timed out, so the checkout queued a retry.", + }, + { + ageMs: 3 * 60_000, + context: { orderId: "order_2204" }, + durationMs: 96, + level: "info", + message: "Order confirmation email queued", + method: "POST", + path: "/api/orders/2204/confirm", + route: "/api/orders/:id/confirm", + service: "checkout", + skipTrace: true, + spans: [], + status: 202, + why: undefined, + }, + { + ageMs: 8 * 60_000, + durationMs: 412, + level: "info", + message: "Order export completed", + method: "POST", + path: "/jobs/order-export", + route: "/jobs/order-export", + service: "worker", + skipEvent: true, + spans: [ + { + durationMs: 412, + kind: "consumer", + name: "process order-export", + service: "worker", + startOffsetMs: 0, + }, + { + attributes: { + "db.operation": "SELECT", + "db.system": "postgresql", + }, + durationMs: 188, + kind: "client", + name: "SELECT orders batch", + parentIndex: 0, + service: "worker", + startOffsetMs: 18, + }, + { + attributes: { "url.full": "https://storage.internal/exports" }, + durationMs: 121, + kind: "client", + name: "PUT exports/orders.csv", + parentIndex: 0, + service: "worker", + startOffsetMs: 245, + }, + ], + status: 200, + }, + ...[5 * 60_000, 11 * 60_000, 16 * 60_000, 27 * 60_000, 34 * 60_000].map( + (ageMs, index): DemoRequestSeed => ({ + ageMs, + context: { categoryId: `cat_${index + 1}` }, + durationMs: index % 2 === 0 ? 14 : 56, + level: "info", + message: "Products listed", + method: "GET", + path: "/api/products", + route: "/api/products", + service: "storefront", + spans: productsSpans(index % 2 === 0), + status: 200, + }), + ), + ]; +} + +export function buildObservabilityRequestRecords(args: { + hexId: (length: number) => string; + now: Date; + request: DemoRequestSeed; + requestIdSuffix: string; +}): DemoObservabilitySeed { + const { hexId, now, request, requestIdSuffix } = args; + const requestId = `req_${requestIdSuffix}`; + const traceId = hexId(32); + const startMs = now.getTime() - request.ageMs; + const spanIds = request.spans.map(() => hexId(16)); + const events: Array> = []; + const spans: Array> = []; + + if (!request.skipEvent) { + events.push({ + duration: request.durationMs, + environment: "production", + fix: request.fix ?? null, + level: request.level, + message: request.message, + method: request.method, + path: request.path, + requestId, + service: request.service, + spanId: request.skipTrace ? null : (spanIds[0] ?? null), + status: request.status, + timestamp: new Date(startMs).toISOString(), + traceId: request.skipTrace ? null : traceId, + why: request.why ?? null, + ...request.context, + }); + } + + if (!request.skipTrace) { + for (const [index, span] of request.spans.entries()) { + const spanStartMs = startMs + span.startOffsetMs; + const spanEndMs = spanStartMs + span.durationMs; + const isRoot = span.parentIndex == null; + + spans.push({ + attributes: { + ...(isRoot + ? { + "http.request.method": request.method, + "request.id": requestId, + "url.path": request.path, + } + : {}), + ...span.attributes, + }, + endUnixNano: toUnixNanoString(spanEndMs), + events: span.exception + ? [ + { + attributes: { + "exception.message": span.exception.message, + "exception.type": span.exception.type, + }, + name: "exception", + timeUnixNano: toUnixNanoString( + spanStartMs + span.exception.offsetMs, + ), + }, + ] + : [], + kind: span.kind, + name: span.name, + parentSpanId: + span.parentIndex == null ? null : spanIds[span.parentIndex], + resource: { + attributes: { + "deployment.environment": "production", + "service.name": span.service, + "service.version": "1.42.0", + ...span.resourceAttributes, + }, + }, + spanId: spanIds[index], + startUnixNano: toUnixNanoString(spanStartMs), + status: span.errorMessage + ? { code: "error", message: span.errorMessage } + : { code: "ok", message: null }, + traceId, + }); + } + } + + return { events, spans }; +} + +export function buildObservabilityStreamSeed(args: { + now: Date; + randomSeed?: number; +}): DemoObservabilitySeed { + const random = createDeterministicRandom(args.randomSeed ?? 0x5eed_1234); + const hexId = createHexIdFactory(random); + const events: Array> = []; + const spans: Array> = []; + + for (const [index, request] of buildDemoRequestSeeds().entries()) { + const records = buildObservabilityRequestRecords({ + hexId, + now: args.now, + request, + requestIdSuffix: `${(index + 1).toString(36)}${hexId(4)}`, + }); + + events.push(...records.events); + spans.push(...records.spans); + } + + return { events, spans }; +} + +async function postJson(args: { + body: unknown; + fetchImpl: FetchImplementation; + label: string; + url: string; +}): Promise { + const response = await args.fetchImpl(args.url, { + body: JSON.stringify(args.body), + headers: { ...JSON_HEADERS }, + method: "POST", + }); + + if (!response.ok) { + const detail = await response.text().catch(() => ""); + + throw new Error( + `[demo] ${args.label} failed: HTTP ${response.status}${detail ? ` ${detail}` : ""}`, + ); + } +} + +async function ensureProfiledStream(args: { + baseUrl: string; + fetchImpl: FetchImplementation; + profile: Record; + streamName: string; +}): Promise { + const streamUrl = `${args.baseUrl}/v1/stream/${args.streamName}`; + const createResponse = await args.fetchImpl(streamUrl, { + headers: { ...JSON_HEADERS }, + method: "PUT", + }); + + if (!createResponse.ok && createResponse.status !== 409) { + const detail = await createResponse.text().catch(() => ""); + + throw new Error( + `[demo] creating stream ${args.streamName} failed: HTTP ${createResponse.status}${detail ? ` ${detail}` : ""}`, + ); + } + + await postJson({ + body: { + apiVersion: "durable.streams/profile/v1", + profile: args.profile, + }, + fetchImpl: args.fetchImpl, + label: `installing ${args.streamName} profile`, + url: `${streamUrl}/_profile`, + }); +} + +export async function ensureObservabilityStreams(args: { + fetchImpl?: FetchImplementation; + streamsServerUrl: string; +}): Promise { + const fetchImpl = + args.fetchImpl ?? (globalThis.fetch as unknown as FetchImplementation); + const baseUrl = args.streamsServerUrl.replace(/\/+$/, ""); + + await ensureProfiledStream({ + baseUrl, + fetchImpl, + profile: { + kind: "evlog", + observability: { + request: { + tracesStream: DEMO_OBSERVABILITY_TRACES_STREAM, + }, + }, + redactKeys: ["sessiontoken"], + }, + streamName: DEMO_OBSERVABILITY_EVENTS_STREAM, + }); + await ensureProfiledStream({ + baseUrl, + fetchImpl, + profile: { + kind: "otel-traces", + observability: { + request: { + eventsStream: DEMO_OBSERVABILITY_EVENTS_STREAM, + }, + }, + }, + streamName: DEMO_OBSERVABILITY_TRACES_STREAM, + }); +} + +export async function appendObservabilitySeed(args: { + fetchImpl?: FetchImplementation; + seed: DemoObservabilitySeed; + streamsServerUrl: string; +}): Promise { + const fetchImpl = + args.fetchImpl ?? (globalThis.fetch as unknown as FetchImplementation); + const baseUrl = args.streamsServerUrl.replace(/\/+$/, ""); + + if (args.seed.events.length > 0) { + await postJson({ + body: args.seed.events, + fetchImpl, + label: "appending evlog events", + url: `${baseUrl}/v1/stream/${DEMO_OBSERVABILITY_EVENTS_STREAM}`, + }); + } + + if (args.seed.spans.length > 0) { + await postJson({ + body: args.seed.spans, + fetchImpl, + label: "appending otel spans", + url: `${baseUrl}/v1/stream/${DEMO_OBSERVABILITY_TRACES_STREAM}`, + }); + } +} + +export async function seedObservabilityStreams(args: { + fetchImpl?: FetchImplementation; + now?: Date; + streamsServerUrl: string; +}): Promise { + const fetchImpl = + args.fetchImpl ?? (globalThis.fetch as unknown as FetchImplementation); + const baseUrl = args.streamsServerUrl.replace(/\/+$/, ""); + const seed = buildObservabilityStreamSeed({ now: args.now ?? new Date() }); + + await ensureObservabilityStreams({ fetchImpl, streamsServerUrl: baseUrl }); + await appendObservabilitySeed({ + fetchImpl, + seed, + streamsServerUrl: baseUrl, + }); +} + +export function startObservabilityStreamTicker(args: { + fetchImpl?: FetchImplementation; + intervalMs?: number; + streamsServerUrl: string; +}): () => void { + const fetchImpl = + args.fetchImpl ?? (globalThis.fetch as unknown as FetchImplementation); + const baseUrl = args.streamsServerUrl.replace(/\/+$/, ""); + const requests = buildDemoRequestSeeds().filter( + (request) => !request.skipEvent && !request.skipTrace, + ); + + const timer = setInterval(() => { + const random = Math.random; + const request = requests[Math.floor(random() * requests.length)]; + + if (!request) { + return; + } + + const hexId = createHexIdFactory(random); + const records = buildObservabilityRequestRecords({ + hexId, + now: new Date(), + request: { ...request, ageMs: Math.floor(random() * 1_500) }, + requestIdSuffix: hexId(6), + }); + + void (async () => { + if (records.events.length > 0) { + await postJson({ + body: records.events, + fetchImpl, + label: "appending evlog tick", + url: `${baseUrl}/v1/stream/${DEMO_OBSERVABILITY_EVENTS_STREAM}`, + }); + } + + if (records.spans.length > 0) { + await postJson({ + body: records.spans, + fetchImpl, + label: "appending otel tick", + url: `${baseUrl}/v1/stream/${DEMO_OBSERVABILITY_TRACES_STREAM}`, + }); + } + })().catch((error: unknown) => { + console.warn( + `[demo] observability stream ticker append failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }); + }, args.intervalMs ?? DEFAULT_TICKER_INTERVAL_MS); + + return () => { + clearInterval(timer); + }; +} diff --git a/eslint.config.mjs b/eslint.config.mjs index 3a2c422d..e60bbca1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -33,8 +33,11 @@ export default tseslint.config( { ignores: [ "**/.agents/**/*", + "**/.playwright-cli/**/*", + "**/deploy/**/*", "**/node_modules/**/*", "**/dist/**/*", + "**/output/**/*", "**/*.d.ts", ], }, @@ -84,9 +87,20 @@ export default tseslint.config( "@typescript-eslint/no-base-to-string": "warn", "@typescript-eslint/no-redundant-type-constituents": "off", "@typescript-eslint/no-misused-promises": "warn", - "simple-import-sort/imports": "error", + "simple-import-sort/imports": "warn", "react/prop-types": "off", "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-duplicate-type-constituents": "warn", + "@typescript-eslint/no-unsafe-assignment": "warn", + "@typescript-eslint/no-unsafe-argument": "warn", + "@typescript-eslint/no-unsafe-call": "warn", + "@typescript-eslint/no-unsafe-member-access": "warn", + "@typescript-eslint/no-unsafe-return": "warn", + "@typescript-eslint/no-unnecessary-type-assertion": "warn", + "@typescript-eslint/require-await": "warn", + "@typescript-eslint/unbound-method": "warn", + "jsx-a11y/no-static-element-interactions": "warn", + "prefer-const": "warn", "@typescript-eslint/no-unused-vars": [ "error", { @@ -129,4 +143,10 @@ export default tseslint.config( }, eslintConfigPrettier, + + { + rules: { + "prettier/prettier": "warn", + }, + }, ); diff --git a/package.json b/package.json index 4fd266b6..aef8c4b4 100644 --- a/package.json +++ b/package.json @@ -130,10 +130,13 @@ "demo:ppg": "bun demo/ppg-dev/server.ts", "demo:ppg:build": "rm -rf demo/ppg-dev/bundle && bun build demo/ppg-dev/server.ts --target bun --outdir demo/ppg-dev/bundle", "demo:ppg:bundle": "pnpm demo:ppg:build && bun demo/ppg-dev/bundle/server.js", - "lint": "eslint --fix --cache --cache-location ./node_modules/.cache/eslint .", + "demo:ppg:seed-scale": "bun demo/ppg-dev/seed-streams-scale.ts", + "lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .", + "lint:fix": "eslint --fix --cache --cache-location ./node_modules/.cache/eslint .", "test": "vitest --passWithNoTests", "test:checkpoint": "vitest --project checkpoint", "test:data": "vitest --project data", + "test:data:mysql": "STUDIO_MYSQL_TEST_URL=${STUDIO_MYSQL_TEST_URL:-mysql://root@localhost:15306/studio} vitest --project data data/mysql-core/adapter.test.ts data/mysql-core/dml.test.ts data/mysql-core/introspection.test.ts", "test:demo": "vitest --project demo", "test:e2e": "vitest --passWithNoTests --project e2e", "streams:use-local": "node scripts/dev/install-local-streams.mjs", @@ -160,7 +163,7 @@ "@electric-sql/pglite": "0.3.15", "@eslint/eslintrc": "2.1.4", "@eslint/js": "8.57.0", - "@prisma/dev": "0.24.8", + "@prisma/dev": "0.24.13", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-context-menu": "2.2.16", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0653de71..d24d52f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,8 +92,8 @@ devDependencies: specifier: 8.57.0 version: 8.57.0 '@prisma/dev': - specifier: 0.24.8 - version: 0.24.8(typescript@5.9.3) + specifier: 0.24.13 + version: 0.24.13(typescript@5.9.3) '@radix-ui/react-alert-dialog': specifier: 1.1.15 version: 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) @@ -1041,13 +1041,13 @@ packages: resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} dev: true - /@hono/node-server@1.19.11(hono@4.12.8): - resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} + /@hono/node-server@1.19.14(hono@4.12.25): + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} peerDependencies: hono: ^4 dependencies: - hono: 4.12.8 + hono: 4.12.25 dev: true /@humanwhocodes/config-array@0.11.14: @@ -1192,19 +1192,19 @@ packages: resolution: {integrity: sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==} dev: true - /@prisma/dev@0.24.8(typescript@5.9.3): - resolution: {integrity: sha512-yfAdStjQxNxot3iMaENAnWzy/pYAl50yFZXeB3LYRr94gpB5jBHwyvqRTu+bUQCr44uN/K+INSBUXrEV/7g5KQ==} + /@prisma/dev@0.24.13(typescript@5.9.3): + resolution: {integrity: sha512-/CwN4+Cduq+cBeIkWL8IYj5lC04Nq7E5z7M5wO4nvmgtQJgxf9Yy7EILiCqe5p2quvv7mohqGPKAKWdUW/s5Ug==} dependencies: '@electric-sql/pglite': 0.4.3 '@electric-sql/pglite-socket': 0.1.3(@electric-sql/pglite@0.4.3) '@electric-sql/pglite-tools': 0.3.3(@electric-sql/pglite@0.4.3) - '@hono/node-server': 1.19.11(hono@4.12.8) + '@hono/node-server': 1.19.14(hono@4.12.25) '@prisma/get-platform': 7.2.0 '@prisma/query-plan-executor': 7.2.0 - '@prisma/streams-local': 0.1.9 + '@prisma/streams-local': 0.1.11 foreground-child: 3.3.1 get-port-please: 3.2.0 - hono: 4.12.8 + hono: 4.12.25 http-status-codes: 2.3.0 pathe: 2.0.3 proper-lockfile: 4.1.2 @@ -1226,8 +1226,8 @@ packages: resolution: {integrity: sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==} dev: true - /@prisma/streams-local@0.1.9: - resolution: {integrity: sha512-lI6YwthIykOXwHPgxgdfQTavc5fiTy03nRjlZeBSVK+NNp/KAX6yqN46H5gZ6BSlu+7dt+BlL8UuubUkhes2fg==} + /@prisma/streams-local@0.1.11: + resolution: {integrity: sha512-0TcebL559MByKqTJ+SsrFIEg228iw8UCVRFckzgfRSiJqczhs+MuAgWOF9lnOIV/IVqvu+KMnFTH0eDeTQMpUg==} engines: {bun: '>=1.2.0', node: '>=22.0.0'} dependencies: ajv: 8.18.0 @@ -5178,8 +5178,8 @@ packages: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} dev: true - /hono@4.12.8: - resolution: {integrity: sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==} + /hono@4.12.25: + resolution: {integrity: sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==} engines: {node: '>=16.9.0'} dev: true diff --git a/scripts/release/check-exports.mjs b/scripts/release/check-exports.mjs index 9e4e9cc7..b690aff1 100644 --- a/scripts/release/check-exports.mjs +++ b/scripts/release/check-exports.mjs @@ -9,6 +9,7 @@ export function packPackage(cwd = process.cwd()) { encoding: "utf8", env: { ...process.env, + COREPACK_ENABLE_STRICT: "0", npm_config_dry_run: "false", }, }); @@ -46,6 +47,10 @@ export function runExportCheck(cwd = process.cwd()) { ], { cwd, + env: { + ...process.env, + COREPACK_ENABLE_STRICT: "0", + }, stdio: "inherit", }, ); diff --git a/scripts/release/check-exports.test.ts b/scripts/release/check-exports.test.ts index 54f73566..cbc42d7b 100644 --- a/scripts/release/check-exports.test.ts +++ b/scripts/release/check-exports.test.ts @@ -67,7 +67,9 @@ describe("packPackage", () => { try { const tarballPath = packPackage(directory); - expect(tarballPath).toBe(join(directory, "release-fixture-dry-run-4.5.6.tgz")); + expect(tarballPath).toBe( + join(directory, "release-fixture-dry-run-4.5.6.tgz"), + ); expect(existsSync(tarballPath)).toBe(true); cleanupPackedTarball(tarballPath); @@ -81,4 +83,46 @@ describe("packPackage", () => { } } }); + + it("creates a tarball for pnpm-managed packages", () => { + const directory = mkdtempSync(join(tmpdir(), "studio-release-pnpm-")); + tempDirectories.push(directory); + + writeFileSync( + join(directory, "package.json"), + JSON.stringify( + { + files: ["index.js"], + name: "release-fixture-pnpm", + packageManager: "pnpm@8.15.9", + version: "7.8.9", + }, + null, + 2, + ), + ); + writeFileSync(join(directory, "index.js"), "module.exports = 1;\n"); + + const previousCorepackStrict = process.env.COREPACK_ENABLE_STRICT; + process.env.COREPACK_ENABLE_STRICT = "1"; + + try { + const tarballPath = packPackage(directory); + + expect(tarballPath).toBe( + join(directory, "release-fixture-pnpm-7.8.9.tgz"), + ); + expect(existsSync(tarballPath)).toBe(true); + + cleanupPackedTarball(tarballPath); + + expect(existsSync(tarballPath)).toBe(false); + } finally { + if (previousCorepackStrict === undefined) { + delete process.env.COREPACK_ENABLE_STRICT; + } else { + process.env.COREPACK_ENABLE_STRICT = previousCorepackStrict; + } + } + }); }); diff --git a/ui/hooks/nuqs.ts b/ui/hooks/nuqs.ts index 663c3ebe..20e922e7 100644 --- a/ui/hooks/nuqs.ts +++ b/ui/hooks/nuqs.ts @@ -23,6 +23,7 @@ export type StateKey = | "pin" | "streamAggregationRange" | "streamFollow" + | "streamObserve" | "streamRoutingKey" | "stream" | "table" diff --git a/ui/hooks/react-query.ts b/ui/hooks/react-query.ts index f0b69080..346fc44b 100644 --- a/ui/hooks/react-query.ts +++ b/ui/hooks/react-query.ts @@ -25,6 +25,17 @@ type QueryKey = | ["stream-search-metadata", string] | ["stream-routing-key-read-metadata", string] | ["stream-search-head", string, number, string, string, string] + | [ + "stream-observe-request", + string, + "events", + string, + "traces", + string, + "lookup", + string, + string, + ] | ["streams", string] | [ "streams", diff --git a/ui/hooks/use-navigation.tsx b/ui/hooks/use-navigation.tsx index 977fb990..371d816c 100644 --- a/ui/hooks/use-navigation.tsx +++ b/ui/hooks/use-navigation.tsx @@ -193,6 +193,8 @@ function useNavigationInternal() { useQueryState("aggregations"); const [streamFollowParam, setStreamFollowParam] = useQueryState("streamFollow"); + const [streamObserveParam, setStreamObserveParam] = + useQueryState("streamObserve"); const [streamRoutingKeyParam, setStreamRoutingKeyParam] = useQueryState("streamRoutingKey"); const [streamParam, setStreamParam] = useQueryState("stream"); @@ -251,6 +253,7 @@ function useNavigationInternal() { streamAggregationRangeParam, streamAggregationsParam, streamFollowParam, + streamObserveParam, streamRoutingKeyParam, streamParam, tableParam, @@ -268,6 +271,8 @@ function useNavigationInternal() { setStreamAggregationsParam: setStreamAggregationsParam as NuqsSetNullableValue, setStreamFollowParam: setStreamFollowParam as NuqsSetNullableValue, + setStreamObserveParam: + setStreamObserveParam as NuqsSetNullableValue, setStreamRoutingKeyParam: setStreamRoutingKeyParam as NuqsSetNullableValue, setStreamParam: setStreamParam as NuqsSetNullableValue, diff --git a/ui/hooks/use-stream-details.test.tsx b/ui/hooks/use-stream-details.test.tsx index 55c0365e..2b944e9a 100644 --- a/ui/hooks/use-stream-details.test.tsx +++ b/ui/hooks/use-stream-details.test.tsx @@ -131,8 +131,15 @@ function createStreamDetailsPayload(overrides?: { last_segment_cut_at: string | null; name: string; next_offset: string; + observability: { + request?: { + events_stream?: string; + traces_stream?: string; + }; + } | null; pending_bytes: string; pending_rows: string; + profile: string | null; sealed_through: string; segment_count: number; total_size_bytes: string; @@ -424,8 +431,10 @@ describe("useStreamDetails", () => { name: "prisma-wal", nextOffset: "2", objectStoreRequests: null, + observability: null, pendingBytes: 128n, pendingRows: 3n, + profile: null, routingKey: null, serverConfiguredLimits: { caches: { @@ -452,6 +461,39 @@ describe("useStreamDetails", () => { harness.cleanup(); }); + it("normalizes request observability descriptors from stream details", async () => { + mockStreamDetailsFetch({ + streamDetailsPayload: createStreamDetailsPayload({ + stream: { + name: "app-events", + observability: { + request: { + events_stream: "app-events", + traces_stream: "app-traces", + }, + }, + profile: "evlog", + }, + }), + }); + const harness = renderHarness({ + streamName: "app-events", + streamsUrl: "/api/streams", + }); + + await waitFor(() => harness.getLatestState()?.isSuccess === true); + + expect(harness.getLatestState()?.details?.profile).toBe("evlog"); + expect(harness.getLatestState()?.details?.observability).toEqual({ + request: { + eventsStream: "app-events", + tracesStream: "app-traces", + }, + }); + + harness.cleanup(); + }); + it("normalizes storage and index diagnostics from the combined details endpoint", async () => { mockStreamDetailsFetch({ streamDetailsPayload: createStreamDetailsPayload({ diff --git a/ui/hooks/use-stream-details.ts b/ui/hooks/use-stream-details.ts index 0c6de573..1bdfc4e6 100644 --- a/ui/hooks/use-stream-details.ts +++ b/ui/hooks/use-stream-details.ts @@ -2,7 +2,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useMemo, useRef } from "react"; import { useStudio } from "../studio/context"; -import type { StudioStream } from "./use-streams"; +import { normalizeStreamObservability, type StudioStream } from "./use-streams"; interface StreamDetailsApiPayload { index_status?: { @@ -115,8 +115,10 @@ interface StreamDetailsApiPayload { last_segment_cut_at?: string | null; name: string; next_offset: string; + observability?: unknown; pending_bytes?: string; pending_rows?: string; + profile?: string | null; sealed_through: string; segment_count?: number; total_size_bytes: string; @@ -1066,8 +1068,13 @@ function normalizeStreamDetailsPayload( name: payload.stream.name, nextOffset: payload.stream.next_offset, objectStoreRequests, + observability: normalizeStreamObservability(payload.stream.observability), pendingBytes: parseNonNegativeBigInt(payload.stream.pending_bytes ?? "0"), pendingRows: parseNonNegativeBigInt(payload.stream.pending_rows ?? "0"), + profile: + parseNullableString(payload.stream.profile) ?? + indexStatus?.profile ?? + null, routingKey, serverConfiguredLimits: null, search, diff --git a/ui/hooks/use-stream-events.test.tsx b/ui/hooks/use-stream-events.test.tsx index a87ecdcb..2cf08a6f 100644 --- a/ui/hooks/use-stream-events.test.tsx +++ b/ui/hooks/use-stream-events.test.tsx @@ -301,6 +301,8 @@ describe("useStreamEvents", () => { expiresAt: null, name: "prisma-wal", nextOffset: "3", + observability: null, + profile: null, sealedThrough: "-1", uploadedThrough: "-1", }, @@ -372,6 +374,8 @@ describe("useStreamEvents", () => { expiresAt: null, name: "golden-stream-2", nextOffset: "2", + observability: null, + profile: null, sealedThrough: "-1", uploadedThrough: "-1", }, @@ -446,6 +450,8 @@ describe("useStreamEvents", () => { expiresAt: null, name: "golden-stream-2", nextOffset: "200", + observability: null, + profile: null, sealedThrough: "-1", uploadedThrough: "-1", }, @@ -496,6 +502,8 @@ describe("useStreamEvents", () => { expiresAt: null, name: "gharchive-demo-all", nextOffset: "1", + observability: null, + profile: null, sealedThrough: "-1", uploadedThrough: "-1", }, @@ -543,6 +551,8 @@ describe("useStreamEvents", () => { expiresAt: null, name: "gharchive-demo-all", nextOffset: "1", + observability: null, + profile: null, sealedThrough: "-1", uploadedThrough: "-1", }, @@ -594,6 +604,8 @@ describe("useStreamEvents", () => { expiresAt: null, name: "prisma-wal", nextOffset: "100", + observability: null, + profile: null, sealedThrough: "-1", uploadedThrough: "-1", }; @@ -666,6 +678,8 @@ describe("useStreamEvents", () => { expiresAt: null, name: "prisma-wal", nextOffset: "120", + observability: null, + profile: null, sealedThrough: "-1", uploadedThrough: "-1", }, @@ -785,6 +799,8 @@ describe("useStreamEvents", () => { expiresAt: null, name: "prisma-wal", nextOffset: "5", + observability: null, + profile: null, sealedThrough: "-1", uploadedThrough: "-1", }, @@ -892,6 +908,8 @@ describe("useStreamEvents", () => { expiresAt: null, name: "prisma-wal", nextOffset: "6", + observability: null, + profile: null, sealedThrough: "-1", uploadedThrough: "-1", }, @@ -943,6 +961,8 @@ describe("useStreamEvents", () => { expiresAt: null, name: "prisma-wal", nextOffset: "6", + observability: null, + profile: null, sealedThrough: "-1", uploadedThrough: "-1", }, @@ -1014,6 +1034,8 @@ describe("useStreamEvents", () => { expiresAt: null, name: "prisma-wal", nextOffset: "3", + observability: null, + profile: null, sealedThrough: "-1", uploadedThrough: "-1", } satisfies StudioStream; diff --git a/ui/hooks/use-stream-observe-request.test.tsx b/ui/hooks/use-stream-observe-request.test.tsx new file mode 100644 index 00000000..4cab273d --- /dev/null +++ b/ui/hooks/use-stream-observe-request.test.tsx @@ -0,0 +1,620 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + getObserveLookupForStreamEvent, + normalizeObserveRequestResponse, + parseStreamObserveParam, + resolveObserveStreams, + serializeStreamObserveParam, + type StudioObserveLookup, + useStreamObserveRequest, +} from "./use-stream-observe-request"; + +const useStudioMock = vi.fn< + () => { + streamsUrl?: string; + } +>(); + +vi.mock("../studio/context", () => ({ + useStudio: () => useStudioMock(), +})); + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const OBSERVE_RESPONSE_FIXTURE = { + coverage: { + events: { + complete: true, + hits: 1, + index_families_used: [], + limit_reached: false, + searched: true, + timed_out: false, + total: { relation: "eq", value: 1 }, + }, + traces: { + complete: false, + hits: 3, + index_families_used: [], + limit_reached: false, + searched: true, + timed_out: false, + total: { relation: "eq", value: 3 }, + }, + warnings: ["trace search coverage incomplete"], + }, + evlog: { + matches: [ + { + offset: "0000000000000000000G000000", + source: { message: "Payment failed" }, + }, + ], + primary: { + duration: 234, + fix: "Retry with a different card.", + level: "error", + message: "Payment failed", + method: "POST", + path: "/api/checkout", + requestId: "req_8f2k", + service: "checkout", + spanId: "086e83747d0e381e", + status: 402, + timestamp: "2026-06-11T14:20:00.000Z", + traceId: "5b8efff798038103d269b633813fc60c", + why: "Card declined by issuer", + }, + stream: "app-events", + }, + lookup: { + requestId: "req_8f2k", + spanId: null, + traceId: "5b8efff798038103d269b633813fc60c", + }, + summary: { + duration: 234, + endTime: "2026-06-11T14:20:00.234Z", + environment: "production", + error: { + fix: "Retry with a different card.", + isError: true, + link: null, + message: "card declined", + type: null, + why: "Card declined by issuer", + }, + level: "error", + method: "POST", + path: "/api/checkout", + route: "/api/checkout", + service: "checkout", + startTime: "2026-06-11T14:20:00.000Z", + status: 402, + title: "Payment failed", + }, + timeline: [ + { + duration: 234, + ids: { + requestId: "req_8f2k", + spanId: "086e83747d0e381e", + traceId: "5b8efff798038103d269b633813fc60c", + }, + kind: "evlog.event", + service: "checkout", + severity: "error", + source: { + offset: "0000000000000000000G000000", + profile: "evlog", + stream: "app-events", + }, + time: "2026-06-11T14:20:00.000Z", + title: "Payment failed", + }, + { + duration: 234, + ids: { + parentSpanId: null, + spanId: "086e83747d0e381e", + traceId: "5b8efff798038103d269b633813fc60c", + }, + kind: "otel.span.start", + service: "checkout", + severity: "error", + source: { profile: "otel-traces", stream: "app-traces" }, + time: "2026-06-11T14:20:00.000Z", + title: "POST /api/checkout", + }, + ], + trace: { + criticalPath: ["086e83747d0e381e", "22dd83747d0e3822"], + duplicateSpans: 1, + errors: [ + { + message: "Card declined by issuer", + name: "POST payments /charges", + service: "payments", + spanId: "22dd83747d0e3822", + time: "2026-06-11T14:20:00.041Z", + type: "CardDeclinedError", + }, + ], + missingParents: [], + partial: false, + rootSpanId: "086e83747d0e381e", + serviceMap: [ + { + count: 1, + errorCount: 1, + from: "checkout", + latency: { count: 1, max: 151, min: 151, sum: 151 }, + to: "payments", + }, + ], + spans: [ + { name: "POST /api/checkout", spanId: "086e83747d0e381e" }, + { name: "POST payments /charges", spanId: "22dd83747d0e3822" }, + ], + stream: "app-traces", + traceId: "5b8efff798038103d269b633813fc60c", + tree: [ + { + children: [ + { + children: [], + depth: 1, + duration: 151, + endTime: "2026-06-11T14:20:00.192Z", + kind: "client", + name: "POST payments /charges", + parentSpanId: "086e83747d0e381e", + service: "payments", + spanId: "22dd83747d0e3822", + startTime: "2026-06-11T14:20:00.041Z", + statusCode: "error", + }, + ], + depth: 0, + duration: 234, + endTime: "2026-06-11T14:20:00.234Z", + kind: "server", + name: "POST /api/checkout", + parentSpanId: null, + service: "checkout", + spanId: "086e83747d0e381e", + startTime: "2026-06-11T14:20:00.000Z", + statusCode: "error", + }, + ], + }, +}; + +async function flush(): Promise { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +async function waitFor(assertion: () => boolean): Promise { + const timeoutMs = 2000; + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + if (assertion()) { + return; + } + + await flush(); + } + + throw new Error("Timed out waiting for observe request state"); +} + +function renderHarness(args: { + eventsStream: string | null; + lookup: StudioObserveLookup | null; + tracesStream: string | null; +}) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + let latestState: ReturnType | undefined; + + function Harness() { + latestState = useStreamObserveRequest(args); + + return null; + } + + act(() => { + root.render( + + + , + ); + }); + + return { + cleanup() { + act(() => { + root.unmount(); + }); + queryClient.clear(); + container.remove(); + }, + getLatestState() { + return latestState; + }, + }; +} + +describe("stream observe param serialization", () => { + it("round-trips request, trace, and span lookups", () => { + const lookups: StudioObserveLookup[] = [ + { kind: "requestId", value: "req_8f2k" }, + { kind: "traceId", value: "5b8efff798038103d269b633813fc60c" }, + { kind: "spanId", value: "086e83747d0e381e" }, + ]; + + for (const lookup of lookups) { + expect( + parseStreamObserveParam(serializeStreamObserveParam(lookup)), + ).toEqual(lookup); + } + + expect(serializeStreamObserveParam(lookups[0]!)).toBe("req:req_8f2k"); + }); + + it("rejects malformed params", () => { + expect(parseStreamObserveParam(null)).toBeNull(); + expect(parseStreamObserveParam("")).toBeNull(); + expect(parseStreamObserveParam("req:")).toBeNull(); + expect(parseStreamObserveParam(":req_8f2k")).toBeNull(); + expect(parseStreamObserveParam("unknown:req_8f2k")).toBeNull(); + expect(parseStreamObserveParam("req_8f2k")).toBeNull(); + }); +}); + +describe("resolveObserveStreams", () => { + it("uses the active evlog stream and explicit trace counterpart", () => { + expect( + resolveObserveStreams({ + activeStreamName: "app-events", + activeStreamProfile: "evlog", + observability: { + request: { + eventsStream: "app-events", + tracesStream: "app-traces", + }, + }, + }), + ).toEqual({ + eventsStream: "app-events", + tracesStream: "app-traces", + }); + }); + + it("uses the active trace stream and explicit evlog counterpart", () => { + expect( + resolveObserveStreams({ + activeStreamName: "app-traces", + activeStreamProfile: "otel-traces", + observability: { + request: { + eventsStream: "app-events", + tracesStream: "app-traces", + }, + }, + }), + ).toEqual({ + eventsStream: "app-events", + tracesStream: "app-traces", + }); + }); + + it("returns no streams for non-observability profiles", () => { + expect( + resolveObserveStreams({ + activeStreamName: "prisma-wal", + activeStreamProfile: "state-protocol", + observability: { + request: { + eventsStream: "app-events", + tracesStream: "app-traces", + }, + }, + }), + ).toEqual({ + eventsStream: null, + tracesStream: null, + }); + }); + + it("keeps only the active side when no explicit counterpart is declared", () => { + expect( + resolveObserveStreams({ + activeStreamName: "app-events", + activeStreamProfile: "evlog", + observability: null, + }), + ).toEqual({ + eventsStream: "app-events", + tracesStream: null, + }); + }); + + it("does not use unrelated descriptor sides as the active stream", () => { + expect( + resolveObserveStreams({ + activeStreamName: "app-traces", + activeStreamProfile: "otel-traces", + observability: { + request: { + eventsStream: "other-events", + tracesStream: "other-traces", + }, + }, + }), + ).toEqual({ + eventsStream: "other-events", + tracesStream: "app-traces", + }); + }); +}); + +describe("getObserveLookupForStreamEvent", () => { + it("prefers the request id for evlog events", () => { + const result = getObserveLookupForStreamEvent({ + body: { + requestId: "req_8f2k", + spanId: "086e83747d0e381e", + traceId: "5b8efff798038103d269b633813fc60c", + }, + profile: "evlog", + }); + + expect(result.lookup).toEqual({ kind: "requestId", value: "req_8f2k" }); + expect(result.ids.traceId).toBe("5b8efff798038103d269b633813fc60c"); + }); + + it("falls back to the trace id for evlog events without a request id", () => { + const result = getObserveLookupForStreamEvent({ + body: { traceId: "5b8efff798038103d269b633813fc60c" }, + profile: "evlog", + }); + + expect(result.lookup).toEqual({ + kind: "traceId", + value: "5b8efff798038103d269b633813fc60c", + }); + }); + + it("prefers the trace id for otel span records", () => { + const result = getObserveLookupForStreamEvent({ + body: { + requestId: "req_8f2k", + spanId: "086e83747d0e381e", + traceId: "5b8efff798038103d269b633813fc60c", + }, + profile: "otel-traces", + }); + + expect(result.lookup).toEqual({ + kind: "traceId", + value: "5b8efff798038103d269b633813fc60c", + }); + }); + + it("returns no lookup for non-observability profiles or unusable bodies", () => { + expect( + getObserveLookupForStreamEvent({ + body: { requestId: "req_8f2k" }, + profile: "state-protocol", + }).lookup, + ).toBeNull(); + expect( + getObserveLookupForStreamEvent({ + body: "not an object", + profile: "evlog", + }).lookup, + ).toBeNull(); + expect( + getObserveLookupForStreamEvent({ + body: { message: "no ids" }, + profile: "evlog", + }).lookup, + ).toBeNull(); + }); +}); + +describe("normalizeObserveRequestResponse", () => { + it("normalizes the full response shape", () => { + const result = normalizeObserveRequestResponse(OBSERVE_RESPONSE_FIXTURE); + + expect(result.summary.title).toBe("Payment failed"); + expect(result.summary.isError).toBe(true); + expect(result.summary.errorWhy).toBe("Card declined by issuer"); + expect(result.lookup.traceId).toBe("5b8efff798038103d269b633813fc60c"); + expect(result.evlog?.primary?.fix).toBe("Retry with a different card."); + expect(result.evlog?.matchCount).toBe(1); + expect(result.trace?.tree).toHaveLength(1); + expect(result.trace?.tree[0]?.children[0]?.depth).toBe(1); + expect(result.trace?.spanCount).toBe(2); + expect(result.trace?.spansById.get("22dd83747d0e3822")).toMatchObject({ + name: "POST payments /charges", + }); + expect(result.trace?.criticalPath).toEqual([ + "086e83747d0e381e", + "22dd83747d0e3822", + ]); + expect(result.trace?.duplicateSpans).toBe(1); + expect(result.trace?.errors[0]?.type).toBe("CardDeclinedError"); + expect(result.trace?.serviceMap[0]).toEqual({ + count: 1, + errorCount: 1, + from: "checkout", + to: "payments", + }); + expect(result.timeline).toHaveLength(2); + expect(result.timeline[0]?.kind).toBe("evlog.event"); + expect(result.coverage.events?.complete).toBe(true); + expect(result.coverage.traces?.complete).toBe(false); + expect(result.coverage.warnings).toEqual([ + "trace search coverage incomplete", + ]); + }); + + it("tolerates missing sections", () => { + const result = normalizeObserveRequestResponse({}); + + expect(result.summary.title).toBe("Request"); + expect(result.evlog).toBeNull(); + expect(result.trace).toBeNull(); + expect(result.timeline).toEqual([]); + expect(result.coverage.events).toBeNull(); + expect(result.coverage.warnings).toEqual([]); + }); +}); + +describe("useStreamObserveRequest", () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + useStudioMock.mockReset(); + useStudioMock.mockReturnValue({ + streamsUrl: "/api/streams", + }); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.clearAllMocks(); + document.body.innerHTML = ""; + }); + + it("posts the lookup to the observe endpoint and normalizes the result", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify(OBSERVE_RESPONSE_FIXTURE), { + headers: { "content-type": "application/json" }, + }), + ); + const harness = renderHarness({ + eventsStream: "app-events", + lookup: { kind: "requestId", value: "req_8f2k" }, + tracesStream: "app-traces", + }); + + await waitFor(() => harness.getLatestState()?.result != null); + + expect(fetchMock).toHaveBeenCalledTimes(1); + + const [url, init] = fetchMock.mock.calls[0]!; + + expect(url).toBe("/api/streams/v1/observe/request"); + expect(init?.method).toBe("POST"); + expect(JSON.parse(String(init?.body))).toEqual({ + include: { + events: true, + timeline: true, + trace: true, + }, + limits: { + events: 50, + spans: 2000, + }, + lookup: { + requestId: "req_8f2k", + }, + streams: { + events: "app-events", + traces: "app-traces", + }, + }); + expect(harness.getLatestState()?.result?.summary.title).toBe( + "Payment failed", + ); + + harness.cleanup(); + fetchMock.mockRestore(); + }); + + it("omits unavailable streams from the request and include flags", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify(OBSERVE_RESPONSE_FIXTURE), { + headers: { "content-type": "application/json" }, + }), + ); + const harness = renderHarness({ + eventsStream: null, + lookup: { kind: "traceId", value: "5b8efff798038103d269b633813fc60c" }, + tracesStream: "app-traces", + }); + + await waitFor(() => harness.getLatestState()?.result != null); + + const [, init] = fetchMock.mock.calls[0]!; + + expect(JSON.parse(String(init?.body))).toMatchObject({ + include: { + events: false, + trace: true, + }, + lookup: { + traceId: "5b8efff798038103d269b633813fc60c", + }, + streams: { + traces: "app-traces", + }, + }); + + harness.cleanup(); + fetchMock.mockRestore(); + }); + + it("stays idle without a lookup and reports request failures", async () => { + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response("nope", { status: 502 })); + const idleHarness = renderHarness({ + eventsStream: "app-events", + lookup: null, + tracesStream: "app-traces", + }); + + await flush(); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(idleHarness.getLatestState()?.result).toBeNull(); + idleHarness.cleanup(); + + const failingHarness = renderHarness({ + eventsStream: "app-events", + lookup: { kind: "requestId", value: "req_8f2k" }, + tracesStream: "app-traces", + }); + + await waitFor(() => failingHarness.getLatestState()?.isError === true); + + expect(failingHarness.getLatestState()?.error?.message).toContain("502"); + + failingHarness.cleanup(); + fetchMock.mockRestore(); + }); +}); diff --git a/ui/hooks/use-stream-observe-request.ts b/ui/hooks/use-stream-observe-request.ts new file mode 100644 index 00000000..c53b1251 --- /dev/null +++ b/ui/hooks/use-stream-observe-request.ts @@ -0,0 +1,693 @@ +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; + +import { useStudio } from "../studio/context"; +import type { StudioStreamObservability } from "./use-streams"; + +export const STREAM_PROFILE_EVLOG = "evlog"; +export const STREAM_PROFILE_OTEL_TRACES = "otel-traces"; + +const OBSERVE_EVENTS_LIMIT = 50; +const OBSERVE_SPANS_LIMIT = 2000; + +const OBSERVE_PARAM_PREFIXES = { + requestId: "req", + spanId: "span", + traceId: "trace", +} as const; + +export type StudioObserveLookupKind = keyof typeof OBSERVE_PARAM_PREFIXES; + +export interface StudioObserveLookup { + kind: StudioObserveLookupKind; + value: string; +} + +export interface StudioObserveSummary { + duration: number | null; + endTime: string | null; + environment: string | null; + errorFix: string | null; + errorLink: string | null; + errorMessage: string | null; + errorWhy: string | null; + isError: boolean; + level: string | null; + method: string | null; + path: string | null; + route: string | null; + service: string | null; + startTime: string | null; + status: number | null; + title: string; +} + +export interface StudioObserveEvlogEvent { + duration: number | null; + fix: string | null; + level: string | null; + link: string | null; + message: string | null; + method: string | null; + path: string | null; + raw: unknown; + requestId: string | null; + service: string | null; + spanId: string | null; + status: number | null; + timestamp: string | null; + traceId: string | null; + why: string | null; +} + +export interface StudioObserveEvlog { + matchCount: number; + primary: StudioObserveEvlogEvent | null; + stream: string; +} + +export interface StudioObserveTraceTreeNode { + children: StudioObserveTraceTreeNode[]; + depth: number; + duration: number | null; + endTime: string | null; + kind: string; + name: string; + parentSpanId: string | null; + service: string | null; + spanId: string; + startTime: string; + statusCode: string; +} + +export interface StudioObserveTraceError { + message: string | null; + name: string; + service: string | null; + spanId: string; + time: string | null; + type: string | null; +} + +export interface StudioObserveServiceEdge { + count: number; + errorCount: number; + from: string; + to: string; +} + +export interface StudioObserveTrace { + criticalPath: string[]; + duplicateSpans: number; + errors: StudioObserveTraceError[]; + missingParents: string[]; + partial: boolean; + rootSpanId: string | null; + serviceMap: StudioObserveServiceEdge[]; + spanCount: number; + spansById: Map; + stream: string; + traceId: string | null; + tree: StudioObserveTraceTreeNode[]; +} + +export interface StudioObserveTimelineItem { + duration: number | null; + id: string; + kind: string; + service: string | null; + severity: string; + sourceProfile: string | null; + sourceStream: string | null; + spanId: string | null; + time: string; + title: string; +} + +export interface StudioObserveCoverageSide { + complete: boolean; + hits: number; + limitReached: boolean; + searched: boolean; + timedOut: boolean; +} + +export interface StudioObserveCoverage { + events: StudioObserveCoverageSide | null; + traces: StudioObserveCoverageSide | null; + warnings: string[]; +} + +export interface StudioObserveRequestResult { + coverage: StudioObserveCoverage; + evlog: StudioObserveEvlog | null; + lookup: { + requestId: string | null; + spanId: string | null; + traceId: string | null; + }; + summary: StudioObserveSummary; + timeline: StudioObserveTimelineItem[]; + trace: StudioObserveTrace | null; +} + +export interface UseStreamObserveRequestArgs { + eventsStream: string | null; + lookup: StudioObserveLookup | null; + tracesStream: string | null; +} + +export function serializeStreamObserveParam( + lookup: StudioObserveLookup, +): string { + return `${OBSERVE_PARAM_PREFIXES[lookup.kind]}:${lookup.value}`; +} + +export function parseStreamObserveParam( + value: string | null | undefined, +): StudioObserveLookup | null { + const trimmed = value?.trim(); + + if (!trimmed) { + return null; + } + + const separatorIndex = trimmed.indexOf(":"); + + if (separatorIndex <= 0 || separatorIndex >= trimmed.length - 1) { + return null; + } + + const prefix = trimmed.slice(0, separatorIndex).toLowerCase(); + const lookupValue = trimmed.slice(separatorIndex + 1).trim(); + + if (!lookupValue) { + return null; + } + + for (const [kind, candidatePrefix] of Object.entries( + OBSERVE_PARAM_PREFIXES, + )) { + if (candidatePrefix === prefix) { + return { + kind: kind as StudioObserveLookupKind, + value: lookupValue, + }; + } + } + + return null; +} + +export function isObservabilityStreamProfile( + profile: string | null | undefined, +): profile is typeof STREAM_PROFILE_EVLOG | typeof STREAM_PROFILE_OTEL_TRACES { + return ( + profile === STREAM_PROFILE_EVLOG || profile === STREAM_PROFILE_OTEL_TRACES + ); +} + +export function resolveObserveStreams(args: { + activeStreamName: string; + activeStreamProfile: string | null | undefined; + observability: StudioStreamObservability | null | undefined; +}): { + eventsStream: string | null; + tracesStream: string | null; +} { + const { activeStreamName, activeStreamProfile, observability } = args; + const requestPair = observability?.request ?? null; + + if (activeStreamProfile === STREAM_PROFILE_EVLOG) { + return { + eventsStream: activeStreamName, + tracesStream: requestPair?.tracesStream ?? null, + }; + } + + if (activeStreamProfile === STREAM_PROFILE_OTEL_TRACES) { + return { + eventsStream: requestPair?.eventsStream ?? null, + tracesStream: activeStreamName, + }; + } + + return { + eventsStream: null, + tracesStream: null, + }; +} + +export interface StreamEventObserveIds { + requestId: string | null; + spanId: string | null; + traceId: string | null; +} + +export function getObserveLookupForStreamEvent(args: { + body: unknown; + profile: string | null | undefined; +}): { + ids: StreamEventObserveIds; + lookup: StudioObserveLookup | null; +} { + const ids: StreamEventObserveIds = { + requestId: null, + spanId: null, + traceId: null, + }; + + if ( + !isObservabilityStreamProfile(args.profile) || + typeof args.body !== "object" || + args.body === null + ) { + return { ids, lookup: null }; + } + + const body = args.body as Record; + + ids.requestId = parseNullableString(body.requestId); + ids.spanId = parseNullableString(body.spanId); + ids.traceId = parseNullableString(body.traceId); + + if (args.profile === STREAM_PROFILE_EVLOG) { + if (ids.requestId) { + return { ids, lookup: { kind: "requestId", value: ids.requestId } }; + } + + if (ids.traceId) { + return { ids, lookup: { kind: "traceId", value: ids.traceId } }; + } + + return { ids, lookup: null }; + } + + if (ids.traceId) { + return { ids, lookup: { kind: "traceId", value: ids.traceId } }; + } + + if (ids.spanId) { + return { ids, lookup: { kind: "spanId", value: ids.spanId } }; + } + + return { ids, lookup: null }; +} + +function parseNullableString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function parseNullableNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function parseRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +function createObserveRequestUrl(streamsUrl: string | undefined): string { + const trimmedStreamsUrl = streamsUrl?.trim(); + + if (!trimmedStreamsUrl) { + return ""; + } + + const suffix = "/v1/observe/request"; + + try { + const url = new URL(trimmedStreamsUrl); + const pathname = url.pathname.replace(/\/+$/, ""); + + url.pathname = `${pathname}${suffix}`; + url.search = ""; + url.hash = ""; + + return url.toString(); + } catch { + const pathname = trimmedStreamsUrl + .replace(/[?#].*$/, "") + .replace(/\/+$/, ""); + + return `${pathname}${suffix}`; + } +} + +function normalizeSummary(value: unknown): StudioObserveSummary { + const summary = parseRecord(value) ?? {}; + const error = parseRecord(summary.error) ?? {}; + + return { + duration: parseNullableNumber(summary.duration), + endTime: parseNullableString(summary.endTime), + environment: parseNullableString(summary.environment), + errorFix: parseNullableString(error.fix), + errorLink: parseNullableString(error.link), + errorMessage: parseNullableString(error.message), + errorWhy: parseNullableString(error.why), + isError: error.isError === true, + level: parseNullableString(summary.level), + method: parseNullableString(summary.method), + path: parseNullableString(summary.path), + route: parseNullableString(summary.route), + service: parseNullableString(summary.service), + startTime: parseNullableString(summary.startTime), + status: parseNullableNumber(summary.status), + title: parseNullableString(summary.title) ?? "Request", + }; +} + +function normalizeEvlogEvent(value: unknown): StudioObserveEvlogEvent | null { + const event = parseRecord(value); + + if (!event) { + return null; + } + + return { + duration: parseNullableNumber(event.duration), + fix: parseNullableString(event.fix), + level: parseNullableString(event.level), + link: parseNullableString(event.link), + message: parseNullableString(event.message), + method: parseNullableString(event.method), + path: parseNullableString(event.path), + raw: value, + requestId: parseNullableString(event.requestId), + service: parseNullableString(event.service), + spanId: parseNullableString(event.spanId), + status: parseNullableNumber(event.status), + timestamp: parseNullableString(event.timestamp), + traceId: parseNullableString(event.traceId), + why: parseNullableString(event.why), + }; +} + +function normalizeEvlog(value: unknown): StudioObserveEvlog | null { + const evlog = parseRecord(value); + + if (!evlog) { + return null; + } + + const matches = Array.isArray(evlog.matches) ? evlog.matches : []; + + return { + matchCount: matches.length, + primary: normalizeEvlogEvent(evlog.primary), + stream: parseNullableString(evlog.stream) ?? "", + }; +} + +function normalizeTraceTreeNode( + value: unknown, + depth: number, +): StudioObserveTraceTreeNode | null { + const node = parseRecord(value); + const spanId = parseNullableString(node?.spanId); + + if (!node || !spanId) { + return null; + } + + const children = Array.isArray(node.children) + ? node.children + .map((child) => normalizeTraceTreeNode(child, depth + 1)) + .filter((child): child is StudioObserveTraceTreeNode => child !== null) + : []; + + return { + children, + depth, + duration: parseNullableNumber(node.duration), + endTime: parseNullableString(node.endTime), + kind: parseNullableString(node.kind) ?? "unspecified", + name: parseNullableString(node.name) ?? spanId, + parentSpanId: parseNullableString(node.parentSpanId), + service: parseNullableString(node.service), + spanId, + startTime: parseNullableString(node.startTime) ?? "", + statusCode: parseNullableString(node.statusCode) ?? "unset", + }; +} + +function normalizeTrace(value: unknown): StudioObserveTrace | null { + const trace = parseRecord(value); + + if (!trace) { + return null; + } + + const spans = Array.isArray(trace.spans) ? trace.spans : []; + const spansById = new Map(); + + for (const span of spans) { + const spanRecord = parseRecord(span); + const spanId = parseNullableString(spanRecord?.spanId); + + if (spanId && !spansById.has(spanId)) { + spansById.set(spanId, span); + } + } + + const tree = Array.isArray(trace.tree) + ? trace.tree + .map((node) => normalizeTraceTreeNode(node, 0)) + .filter((node): node is StudioObserveTraceTreeNode => node !== null) + : []; + const serviceMap = Array.isArray(trace.serviceMap) + ? trace.serviceMap + .map((edge): StudioObserveServiceEdge | null => { + const edgeRecord = parseRecord(edge); + const from = parseNullableString(edgeRecord?.from); + const to = parseNullableString(edgeRecord?.to); + + if (!from || !to) { + return null; + } + + return { + count: parseNullableNumber(edgeRecord?.count) ?? 0, + errorCount: parseNullableNumber(edgeRecord?.errorCount) ?? 0, + from, + to, + }; + }) + .filter((edge): edge is StudioObserveServiceEdge => edge !== null) + : []; + const errors = Array.isArray(trace.errors) + ? trace.errors + .map((error): StudioObserveTraceError | null => { + const errorRecord = parseRecord(error); + const spanId = parseNullableString(errorRecord?.spanId); + + if (!spanId) { + return null; + } + + return { + message: parseNullableString(errorRecord?.message), + name: parseNullableString(errorRecord?.name) ?? spanId, + service: parseNullableString(errorRecord?.service), + spanId, + time: parseNullableString(errorRecord?.time), + type: parseNullableString(errorRecord?.type), + }; + }) + .filter((error): error is StudioObserveTraceError => error !== null) + : []; + const missingParents = Array.isArray(trace.missingParents) + ? trace.missingParents.filter( + (parent): parent is string => typeof parent === "string", + ) + : []; + const criticalPath = Array.isArray(trace.criticalPath) + ? trace.criticalPath.filter( + (spanId): spanId is string => typeof spanId === "string", + ) + : []; + + return { + criticalPath, + duplicateSpans: parseNullableNumber(trace.duplicateSpans) ?? 0, + errors, + missingParents, + partial: trace.partial === true, + rootSpanId: parseNullableString(trace.rootSpanId), + serviceMap, + spanCount: spansById.size, + spansById, + stream: parseNullableString(trace.stream) ?? "", + traceId: parseNullableString(trace.traceId), + tree, + }; +} + +function normalizeTimeline(value: unknown): StudioObserveTimelineItem[] { + if (!Array.isArray(value)) { + return []; + } + + const items: StudioObserveTimelineItem[] = []; + + for (const [index, item] of value.entries()) { + const itemRecord = parseRecord(item); + const time = parseNullableString(itemRecord?.time); + const kind = parseNullableString(itemRecord?.kind); + + if (!itemRecord || !time || !kind) { + continue; + } + + const ids = parseRecord(itemRecord.ids) ?? {}; + const source = parseRecord(itemRecord.source) ?? {}; + + items.push({ + duration: parseNullableNumber(itemRecord.duration), + id: `${index}:${kind}:${time}`, + kind, + service: parseNullableString(itemRecord.service), + severity: parseNullableString(itemRecord.severity) ?? "info", + sourceProfile: parseNullableString(source.profile), + sourceStream: parseNullableString(source.stream), + spanId: parseNullableString(ids.spanId), + time, + title: parseNullableString(itemRecord.title) ?? kind, + }); + } + + return items; +} + +function normalizeCoverageSide( + value: unknown, +): StudioObserveCoverageSide | null { + const side = parseRecord(value); + + if (!side || side.searched !== true) { + return null; + } + + return { + complete: side.complete === true, + hits: parseNullableNumber(side.hits) ?? 0, + limitReached: side.limit_reached === true, + searched: true, + timedOut: side.timed_out === true, + }; +} + +function normalizeCoverage(value: unknown): StudioObserveCoverage { + const coverage = parseRecord(value) ?? {}; + const warnings = Array.isArray(coverage.warnings) + ? coverage.warnings.filter( + (warning): warning is string => typeof warning === "string", + ) + : []; + + return { + events: normalizeCoverageSide(coverage.events), + traces: normalizeCoverageSide(coverage.traces), + warnings, + }; +} + +export function normalizeObserveRequestResponse( + payload: unknown, +): StudioObserveRequestResult { + const response = parseRecord(payload) ?? {}; + const lookup = parseRecord(response.lookup) ?? {}; + + return { + coverage: normalizeCoverage(response.coverage), + evlog: normalizeEvlog(response.evlog), + lookup: { + requestId: parseNullableString(lookup.requestId), + spanId: parseNullableString(lookup.spanId), + traceId: parseNullableString(lookup.traceId), + }, + summary: normalizeSummary(response.summary), + timeline: normalizeTimeline(response.timeline), + trace: normalizeTrace(response.trace), + }; +} + +export function useStreamObserveRequest(args: UseStreamObserveRequestArgs) { + const { eventsStream, lookup, tracesStream } = args; + const { streamsUrl } = useStudio(); + const observeUrl = useMemo( + () => createObserveRequestUrl(streamsUrl), + [streamsUrl], + ); + const isEnabled = + observeUrl.length > 0 && + lookup !== null && + (eventsStream !== null || tracesStream !== null); + + const query = useQuery({ + enabled: isEnabled, + queryFn: async ({ signal }) => { + if (!lookup) { + throw new Error("Missing observe lookup"); + } + + const response = await fetch(observeUrl, { + body: JSON.stringify({ + include: { + events: eventsStream !== null, + timeline: true, + trace: tracesStream !== null, + }, + limits: { + events: OBSERVE_EVENTS_LIMIT, + spans: OBSERVE_SPANS_LIMIT, + }, + lookup: { + [lookup.kind]: lookup.value, + }, + streams: { + ...(eventsStream !== null ? { events: eventsStream } : {}), + ...(tracesStream !== null ? { traces: tracesStream } : {}), + }, + }), + headers: { + "content-type": "application/json", + }, + method: "POST", + signal, + }); + + if (!response.ok) { + throw new Error( + `Failed loading request details (${response.status} ${response.statusText})`, + ); + } + + return normalizeObserveRequestResponse(await response.json()); + }, + queryKey: [ + "stream-observe-request", + observeUrl, + "events", + eventsStream ?? "", + "traces", + tracesStream ?? "", + "lookup", + lookup?.kind ?? "", + lookup?.value ?? "", + ], + refetchInterval: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + retry: false, + retryOnMount: false, + staleTime: Infinity, + }); + + return { + ...query, + result: query.data ?? null, + }; +} diff --git a/ui/hooks/use-streams.test.tsx b/ui/hooks/use-streams.test.tsx index 1a02ce1c..2ac1e2ec 100644 --- a/ui/hooks/use-streams.test.tsx +++ b/ui/hooks/use-streams.test.tsx @@ -119,6 +119,13 @@ describe("useStreams", () => { expires_at: null, name: "prisma-wal", next_offset: "0", + observability: { + request: { + events_stream: "app-events", + traces_stream: "app-traces", + }, + }, + profile: "state-protocol", sealed_through: "0", uploaded_through: "0", }, @@ -154,6 +161,20 @@ describe("useStreams", () => { expect( harness.getLatestState()?.streams.map((stream) => stream.name), ).toEqual(["audit-log", "prisma-wal"]); + expect( + harness.getLatestState()?.streams.map((stream) => stream.profile), + ).toEqual([null, "state-protocol"]); + expect( + harness.getLatestState()?.streams.map((stream) => stream.observability), + ).toEqual([ + null, + { + request: { + eventsStream: "app-events", + tracesStream: "app-traces", + }, + }, + ]); expect(harness.getLatestState()?.hasStreamsServer).toBe(true); harness.cleanup(); diff --git a/ui/hooks/use-streams.ts b/ui/hooks/use-streams.ts index b9c930d7..6d6be598 100644 --- a/ui/hooks/use-streams.ts +++ b/ui/hooks/use-streams.ts @@ -11,16 +11,36 @@ interface StreamsApiItem { expires_at: string | null; name: string; next_offset: string; + observability?: StreamObservabilityApiPayload | null; + profile?: string | null; sealed_through: string; uploaded_through: string; } +interface StreamObservabilityApiPayload { + request?: { + events_stream?: unknown; + traces_stream?: unknown; + } | null; +} + +export interface StudioStreamRequestObservability { + eventsStream: string; + tracesStream: string; +} + +export interface StudioStreamObservability { + request: StudioStreamRequestObservability | null; +} + export interface StudioStream { createdAt: string; epoch: number; expiresAt: string | null; name: string; nextOffset: string; + observability: StudioStreamObservability | null; + profile: string | null; sealedThrough: string; uploadedThrough: string; } @@ -42,11 +62,50 @@ function isStreamsApiItem(value: unknown): value is StreamsApiItem { (item.expires_at === null || typeof item.expires_at === "string") && typeof item.name === "string" && typeof item.next_offset === "string" && + (item.observability == null || + (typeof item.observability === "object" && + !Array.isArray(item.observability))) && + (item.profile == null || typeof item.profile === "string") && typeof item.sealed_through === "string" && typeof item.uploaded_through === "string" ); } +function parseNullableString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; +} + +export function normalizeStreamObservability( + value: unknown, +): StudioStreamObservability | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return null; + } + + const observability = value as StreamObservabilityApiPayload; + const request = observability.request; + + if (typeof request !== "object" || request === null) { + return null; + } + + const eventsStream = parseNullableString(request.events_stream); + const tracesStream = parseNullableString(request.traces_stream); + + if (!eventsStream || !tracesStream) { + return null; + } + + return { + request: { + eventsStream, + tracesStream, + }, + }; +} + function createStreamsListUrl(streamsUrl: string | undefined): string { const trimmed = streamsUrl?.trim(); @@ -117,6 +176,8 @@ export function useStreams(args?: UseStreamsArgs) { expiresAt: stream.expires_at, name: stream.name, nextOffset: stream.next_offset, + observability: normalizeStreamObservability(stream.observability), + profile: stream.profile ?? null, sealedThrough: stream.sealed_through, uploadedThrough: stream.uploaded_through, })), diff --git a/ui/studio/views/stream/StreamObserveEventSection.tsx b/ui/studio/views/stream/StreamObserveEventSection.tsx new file mode 100644 index 00000000..b281f91b --- /dev/null +++ b/ui/studio/views/stream/StreamObserveEventSection.tsx @@ -0,0 +1,97 @@ +import { Badge } from "@/ui/components/ui/badge"; + +import type { StudioObserveEvlog } from "../../../hooks/use-stream-observe-request"; +import { formatDurationMs } from "./StreamObserveShared"; + +export function EventSection(props: { + eventsStream: string | null; + evlog: StudioObserveEvlog | null; +}) { + if (!props.eventsStream) { + return ( +
+ No evlog stream is available for request events. +
+ ); + } + + const event = props.evlog?.primary ?? null; + + if (!event) { + return ( +
+ No evlog event was found for this request. +
+ ); + } + + return ( +
+
+ {event.level ? ( + + {event.level} + + ) : null} + {event.method && event.path ? ( + + {event.method} {event.path} + + ) : null} + {event.status != null ? ( + {event.status} + ) : null} + {event.duration != null ? ( + {formatDurationMs(event.duration)} + ) : null} +
+ + {event.message ? ( +

{event.message}

+ ) : null} + + {event.why || event.fix || event.link ? ( +
+ {event.why ? ( +
+ Why + {event.why} +
+ ) : null} + {event.fix ? ( +
+ Fix + {event.fix} +
+ ) : null} + {event.link ? ( + + {event.link} + + ) : null} +
+ ) : null} + + {props.evlog && props.evlog.matchCount > 1 ? ( +

+ {props.evlog.matchCount} events matched this lookup; showing the best + match. +

+ ) : null} + +
+        {JSON.stringify(event.raw, null, 2)}
+      
+
+ ); +} diff --git a/ui/studio/views/stream/StreamObserveShared.tsx b/ui/studio/views/stream/StreamObserveShared.tsx new file mode 100644 index 00000000..a9a8c611 --- /dev/null +++ b/ui/studio/views/stream/StreamObserveShared.tsx @@ -0,0 +1,92 @@ +import { Badge } from "@/ui/components/ui/badge"; + +import type { StudioObserveTraceTreeNode } from "../../../hooks/use-stream-observe-request"; + +export function formatDurationMs(durationMs: number | null): string { + if (durationMs == null || !Number.isFinite(durationMs)) { + return "-"; + } + + if (durationMs < 1) { + return "<1 ms"; + } + + if (durationMs < 1_000) { + return `${Math.round(durationMs)} ms`; + } + + return `${(durationMs / 1_000).toFixed(durationMs < 10_000 ? 2 : 1)} s`; +} + +export function formatTimestamp(isoTimestamp: string | null): string { + if (!isoTimestamp) { + return "-"; + } + + const date = new Date(isoTimestamp); + + if (Number.isNaN(date.getTime())) { + return isoTimestamp; + } + + return date.toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "medium", + }); +} + +export function formatShortId(id: string): string { + return id.length <= 14 ? id : `${id.slice(0, 6)}...${id.slice(-4)}`; +} + +export function formatOffsetMs(offsetMs: number): string { + if (!Number.isFinite(offsetMs)) { + return ""; + } + + const rounded = Math.round(offsetMs); + + return rounded >= 0 ? `+${rounded} ms` : `${rounded} ms`; +} + +export function flattenTraceTree( + nodes: StudioObserveTraceTreeNode[], +): StudioObserveTraceTreeNode[] { + const rows: StudioObserveTraceTreeNode[] = []; + const walk = (node: StudioObserveTraceTreeNode) => { + rows.push(node); + + for (const child of node.children) { + walk(child); + } + }; + + for (const node of nodes) { + walk(node); + } + + return rows; +} + +export function parseTimeMs(isoTimestamp: string | null): number | null { + if (!isoTimestamp) { + return null; + } + + const parsed = Date.parse(isoTimestamp); + + return Number.isNaN(parsed) ? null : parsed; +} + +export function IdChip(props: { label: string; value: string }) { + return ( + + {props.label} + {formatShortId(props.value)} + + ); +} diff --git a/ui/studio/views/stream/StreamObserveSheet.test.tsx b/ui/studio/views/stream/StreamObserveSheet.test.tsx new file mode 100644 index 00000000..0b4afbd6 --- /dev/null +++ b/ui/studio/views/stream/StreamObserveSheet.test.tsx @@ -0,0 +1,507 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { StudioObserveLookup } from "../../../hooks/use-stream-observe-request"; +import { StreamObserveSheet } from "./StreamObserveSheet"; + +const useStudioMock = vi.fn< + () => { + streamsUrl?: string; + } +>(); + +vi.mock("../../../studio/context", () => ({ + useStudio: () => useStudioMock(), +})); + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const OBSERVE_RESPONSE = { + coverage: { + events: { + complete: true, + hits: 1, + limit_reached: false, + searched: true, + timed_out: false, + }, + traces: { + complete: true, + hits: 2, + limit_reached: false, + searched: true, + timed_out: false, + }, + warnings: ["missing parent spans: 1"], + }, + evlog: { + matches: [ + { + offset: "0000000000000000000G000000", + source: { message: "Payment failed" }, + }, + ], + primary: { + duration: 234, + fix: "Retry with a different card.", + level: "error", + message: "Payment failed", + method: "POST", + path: "/api/checkout", + requestId: "req_8f2k", + service: "checkout", + status: 402, + timestamp: "2026-06-11T14:20:00.000Z", + traceId: "5b8efff798038103d269b633813fc60c", + why: "Card declined by issuer", + }, + stream: "app-events", + }, + lookup: { + requestId: "req_8f2k", + spanId: null, + traceId: "5b8efff798038103d269b633813fc60c", + }, + summary: { + duration: 234, + endTime: "2026-06-11T14:20:00.234Z", + environment: "production", + error: { + fix: "Retry with a different card.", + isError: true, + link: null, + message: "card declined", + type: null, + why: "Card declined by issuer", + }, + level: "error", + method: "POST", + path: "/api/checkout", + route: "/api/checkout", + service: "checkout", + startTime: "2026-06-11T14:20:00.000Z", + status: 402, + title: "Payment failed", + }, + timeline: [ + { + duration: 234, + ids: { + requestId: "req_8f2k", + spanId: "086e83747d0e381e", + traceId: "5b8efff798038103d269b633813fc60c", + }, + kind: "evlog.event", + service: "checkout", + severity: "error", + source: { + offset: "0000000000000000000G000000", + profile: "evlog", + stream: "app-events", + }, + time: "2026-06-11T14:20:00.000Z", + title: "Payment failed", + }, + { + duration: 234, + ids: { + parentSpanId: null, + spanId: "086e83747d0e381e", + traceId: "5b8efff798038103d269b633813fc60c", + }, + kind: "otel.span.start", + service: "checkout", + severity: "error", + source: { profile: "otel-traces", stream: "app-traces" }, + time: "2026-06-11T14:20:00.000Z", + title: "POST /api/checkout", + }, + { + ids: { + spanId: "086e83747d0e381e", + traceId: "5b8efff798038103d269b633813fc60c", + }, + kind: "otel.span.end", + service: "checkout", + severity: "error", + source: { profile: "otel-traces", stream: "app-traces" }, + time: "2026-06-11T14:20:00.234Z", + title: "POST /api/checkout", + }, + ], + trace: { + criticalPath: ["086e83747d0e381e"], + duplicateSpans: 0, + errors: [ + { + message: "Card declined by issuer", + name: "POST payments /charges", + service: "payments", + spanId: "22dd83747d0e3822", + time: "2026-06-11T14:20:00.041Z", + type: "CardDeclinedError", + }, + ], + missingParents: ["aaaa83747d0eaaaa"], + partial: true, + rootSpanId: "086e83747d0e381e", + serviceMap: [ + { + count: 2, + errorCount: 1, + from: "checkout", + to: "payments", + }, + ], + spans: [ + { + attributes: { "request.id": "req_8f2k" }, + name: "POST /api/checkout", + spanId: "086e83747d0e381e", + }, + { + name: "POST payments /charges", + spanId: "22dd83747d0e3822", + }, + ], + stream: "app-traces", + traceId: "5b8efff798038103d269b633813fc60c", + tree: [ + { + children: [ + { + children: [], + depth: 1, + duration: 151, + endTime: "2026-06-11T14:20:00.192Z", + kind: "client", + name: "POST payments /charges", + parentSpanId: "086e83747d0e381e", + service: "payments", + spanId: "22dd83747d0e3822", + startTime: "2026-06-11T14:20:00.041Z", + statusCode: "error", + }, + ], + depth: 0, + duration: 234, + endTime: "2026-06-11T14:20:00.234Z", + kind: "server", + name: "POST /api/checkout", + parentSpanId: null, + service: "checkout", + spanId: "086e83747d0e381e", + startTime: "2026-06-11T14:20:00.000Z", + statusCode: "error", + }, + ], + }, +}; + +interface RenderedSheet { + cleanup: () => void; + container: HTMLElement; + onClose: ReturnType; + rerender: (lookup: StudioObserveLookup | null) => void; +} + +function renderSheet(args: { + eventsStream?: string | null; + lookup: StudioObserveLookup | null; + tracesStream?: string | null; +}): RenderedSheet { + const container = document.createElement("div"); + document.body.appendChild(container); + const root: Root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + const onClose = vi.fn(); + + const render = (lookup: StudioObserveLookup | null) => { + act(() => { + root.render( + + + , + ); + }); + }; + + render(args.lookup); + + return { + cleanup() { + act(() => { + root.unmount(); + }); + queryClient.clear(); + container.remove(); + }, + container, + onClose, + rerender: render, + }; +} + +async function flush(): Promise { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +async function waitForSelector(selector: string): Promise { + const timeoutMs = 2000; + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + const element = document.querySelector(selector); + + if (element) { + return element; + } + + await flush(); + } + + throw new Error(`Timed out waiting for ${selector}`); +} + +function click(element: HTMLElement): void { + act(() => { + element.click(); + }); +} + +describe("StreamObserveSheet", () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + useStudioMock.mockReset(); + useStudioMock.mockReturnValue({ + streamsUrl: "/api/streams", + }); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify(OBSERVE_RESPONSE), { + headers: { "content-type": "application/json" }, + }), + ); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + document.body.innerHTML = ""; + }); + + it("renders the request summary, warnings, and timeline by default", async () => { + const sheet = renderSheet({ + lookup: { kind: "requestId", value: "req_8f2k" }, + }); + + const sheetElement = await waitForSelector( + '[data-testid="stream-observe-sheet"]', + ); + + await waitForSelector('[data-testid="stream-observe-timeline"]'); + + expect(sheetElement.textContent).toContain("Payment failed"); + expect(sheetElement.textContent).toContain("POST /api/checkout"); + expect(sheetElement.textContent).toContain("402"); + + const warnings = await waitForSelector( + '[data-testid="stream-observe-warnings"]', + ); + + expect(warnings.textContent).toContain("missing parent spans: 1"); + + const timelineItems = document.querySelectorAll( + '[data-testid="stream-observe-timeline-item"]', + ); + + // The span-end timeline item is hidden to keep the list readable. + expect(timelineItems).toHaveLength(2); + expect(timelineItems[0]?.textContent).toContain("Payment failed"); + expect(timelineItems[1]?.textContent).toContain("POST /api/checkout"); + + sheet.cleanup(); + }); + + it("renders the trace waterfall with span expansion, errors, and service calls", async () => { + const sheet = renderSheet({ + lookup: { kind: "requestId", value: "req_8f2k" }, + }); + + await waitForSelector('[data-testid="stream-observe-timeline"]'); + click( + await waitForSelector('[data-testid="stream-observe-section-trace"]'), + ); + + const waterfall = await waitForSelector( + '[data-testid="stream-observe-waterfall"]', + ); + + expect(waterfall.textContent).toContain("2 spans"); + expect(waterfall.textContent).toContain("partial"); + expect(waterfall.textContent).toContain("missing parents"); + + const spanRows = document.querySelectorAll( + '[data-testid^="stream-observe-span-row-"]', + ); + + expect(spanRows).toHaveLength(2); + + click( + await waitForSelector( + '[data-testid="stream-observe-span-row-22dd83747d0e3822"]', + ), + ); + + const spanDetails = await waitForSelector( + '[data-testid="stream-observe-span-details"]', + ); + + expect(spanDetails.textContent).toContain("POST payments /charges"); + expect(waterfall.textContent).toContain("CardDeclinedError"); + expect(waterfall.textContent).toContain("checkout -> payments"); + expect(waterfall.textContent).toContain("2 calls, 1 error"); + + sheet.cleanup(); + }); + + it("renders the evlog event with root-cause fields", async () => { + const sheet = renderSheet({ + lookup: { kind: "requestId", value: "req_8f2k" }, + }); + + await waitForSelector('[data-testid="stream-observe-timeline"]'); + click( + await waitForSelector('[data-testid="stream-observe-section-event"]'), + ); + + const eventPanel = await waitForSelector( + '[data-testid="stream-observe-event"]', + ); + const rootCause = await waitForSelector( + '[data-testid="stream-observe-root-cause"]', + ); + + expect(rootCause.textContent).toContain("Card declined by issuer"); + expect(rootCause.textContent).toContain("Retry with a different card."); + expect(eventPanel.textContent).toContain('"requestId": "req_8f2k"'); + + sheet.cleanup(); + }); + + it("explains a missing trace stream instead of rendering an empty waterfall", async () => { + const sheet = renderSheet({ + lookup: { kind: "requestId", value: "req_8f2k" }, + tracesStream: null, + }); + + await waitForSelector('[data-testid="stream-observe-timeline"]'); + click( + await waitForSelector('[data-testid="stream-observe-section-trace"]'), + ); + await flush(); + + expect( + document.querySelector('[data-testid="stream-observe-waterfall"]'), + ).toBeNull(); + expect( + document.querySelector('[data-testid="stream-observe-sheet"]') + ?.textContent, + ).toContain("No otel-traces stream is available"); + + sheet.cleanup(); + }); + + it("renders an unavailable state when no observe streams are resolved", async () => { + const sheet = renderSheet({ + eventsStream: null, + lookup: { kind: "requestId", value: "req_8f2k" }, + tracesStream: null, + }); + + const sheetElement = await waitForSelector( + '[data-testid="stream-observe-sheet"]', + ); + click( + await waitForSelector('[data-testid="stream-observe-section-trace"]'), + ); + await flush(); + + expect( + document.querySelector('[data-testid="stream-observe-waterfall"]'), + ).toBeNull(); + expect(sheetElement.textContent).toContain( + "Request observability is unavailable", + ); + expect(sheetElement.textContent).toContain( + "No evlog or otel-traces stream is available", + ); + expect( + document.querySelector( + '[data-testid="stream-observe-refresh"]', + )?.disabled, + ).toBe(true); + expect(globalThis.fetch).not.toHaveBeenCalled(); + + sheet.cleanup(); + }); + + it("stays closed without a lookup and closes through the sheet dismissal", async () => { + const sheet = renderSheet({ lookup: null }); + + await flush(); + + expect( + document.querySelector('[data-testid="stream-observe-sheet"]'), + ).toBeNull(); + + sheet.rerender({ + kind: "traceId", + value: "5b8efff798038103d269b633813fc60c", + }); + await waitForSelector('[data-testid="stream-observe-sheet"]'); + + const closeButton = document.querySelector( + '[data-testid="stream-observe-sheet"] button[type="button"]', + ); + const radixClose = Array.from( + document.querySelectorAll( + '[data-testid="stream-observe-sheet"] button', + ), + ).find((button) => button.textContent?.includes("Close")); + + expect(closeButton).not.toBeNull(); + expect(radixClose).toBeDefined(); + + if (radixClose) { + click(radixClose); + } + + expect(sheet.onClose).toHaveBeenCalledTimes(1); + + sheet.cleanup(); + }); +}); diff --git a/ui/studio/views/stream/StreamObserveSheet.tsx b/ui/studio/views/stream/StreamObserveSheet.tsx new file mode 100644 index 00000000..b3b12790 --- /dev/null +++ b/ui/studio/views/stream/StreamObserveSheet.tsx @@ -0,0 +1,302 @@ +import { RefreshCw, TriangleAlert } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { Badge } from "@/ui/components/ui/badge"; +import { Button } from "@/ui/components/ui/button"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/ui/components/ui/sheet"; +import { Skeleton } from "@/ui/components/ui/skeleton"; +import { ToggleGroup, ToggleGroupItem } from "@/ui/components/ui/toggle-group"; +import { cn } from "@/ui/lib/utils"; + +import { + type StudioObserveLookup, + type StudioObserveRequestResult, + useStreamObserveRequest, +} from "../../../hooks/use-stream-observe-request"; +import { EventSection } from "./StreamObserveEventSection"; +import { + formatDurationMs, + formatTimestamp, + IdChip, + parseTimeMs, +} from "./StreamObserveShared"; +import { TimelineSection } from "./StreamObserveTimelineSection"; +import { TraceSection } from "./StreamObserveTraceSection"; + +type ObserveSection = "event" | "timeline" | "trace"; + +const OBSERVE_SECTION_OPTIONS = [ + { label: "Timeline", value: "timeline" }, + { label: "Trace", value: "trace" }, + { label: "Event", value: "event" }, +] as const satisfies ReadonlyArray<{ + label: string; + value: ObserveSection; +}>; + +export interface StreamObserveSheetProps { + eventsStream: string | null; + lookup: StudioObserveLookup | null; + onClose: () => void; + tracesStream: string | null; +} + +function SummaryBadges(props: { result: StudioObserveRequestResult }) { + const { summary } = props.result; + + return ( +
+ {summary.status != null ? ( + + {summary.status} + + ) : null} + {summary.level ? ( + + {summary.level} + + ) : null} + {summary.duration != null ? ( + {formatDurationMs(summary.duration)} + ) : null} + {summary.service ? ( + {summary.service} + ) : null} + {summary.environment ? ( + {summary.environment} + ) : null} +
+ ); +} + +function CoverageWarnings(props: { warnings: string[] }) { + if (props.warnings.length === 0) { + return null; + } + + return ( +
+ {props.warnings.map((warning) => ( +
+ + {warning} +
+ ))} +
+ ); +} + +function ObserveLoadingState() { + return ( +
+ {Array.from({ length: 6 }, (_, index) => ( + + ))} +
+ ); +} + +export function StreamObserveSheet(props: StreamObserveSheetProps) { + const { eventsStream, lookup, onClose, tracesStream } = props; + const [section, setSection] = useState("timeline"); + const lookupIdentity = lookup ? `${lookup.kind}:${lookup.value}` : null; + const hasObserveSource = eventsStream !== null || tracesStream !== null; + + useEffect(() => { + setSection("timeline"); + }, [lookupIdentity]); + + const { error, isError, isFetching, isLoading, refetch, result } = + useStreamObserveRequest({ + eventsStream, + lookup, + tracesStream, + }); + const summaryStartTimeMs = parseTimeMs(result?.summary.startTime ?? null); + + return ( + { + if (!open) { + onClose(); + } + }} + > + + {lookup ? ( + <> + + + {result?.summary.title ?? "Request details"} + + + {result?.summary.method && result?.summary.path + ? `${result.summary.method} ${result.summary.path}` + : `Lookup by ${lookup.kind}`} + {result?.summary.startTime + ? ` | ${formatTimestamp(result.summary.startTime)}` + : ""} + + {result ? : null} +
+ {result?.lookup.requestId ? ( + + ) : null} + {result?.lookup.traceId ? ( + + ) : null} + {result?.lookup.spanId ? ( + + ) : null} + {!result ? ( + + ) : null} +
+
+ + {result ? ( + + ) : null} + +
+ { + if ( + value === "timeline" || + value === "trace" || + value === "event" + ) { + setSection(value); + } + }} + type="single" + value={section} + > + {OBSERVE_SECTION_OPTIONS.map((option, index) => { + return ( + + {option.label} + + ); + })} + + +
+ +
+ {!hasObserveSource ? ( +
+ + Request observability is unavailable + + + No evlog or otel-traces stream is available for this lookup. + +
+ ) : isLoading ? ( + + ) : isError ? ( +
+ + {error instanceof Error + ? error.message + : "Request details are unavailable right now."} + + +
+ ) : result ? ( + <> + {section === "timeline" ? ( + + ) : null} + {section === "trace" ? ( + + ) : null} + {section === "event" ? ( + + ) : null} + + ) : null} +
+ +
+ Sources: {eventsStream ? `events ${eventsStream}` : null} + {eventsStream && tracesStream ? " | " : null} + {tracesStream ? `traces ${tracesStream}` : null} +
+ + ) : null} +
+
+ ); +} diff --git a/ui/studio/views/stream/StreamObserveTimelineSection.tsx b/ui/studio/views/stream/StreamObserveTimelineSection.tsx new file mode 100644 index 00000000..cab5a16c --- /dev/null +++ b/ui/studio/views/stream/StreamObserveTimelineSection.tsx @@ -0,0 +1,90 @@ +import { Badge } from "@/ui/components/ui/badge"; +import { cn } from "@/ui/lib/utils"; + +import type { StudioObserveTimelineItem } from "../../../hooks/use-stream-observe-request"; +import { + formatDurationMs, + formatOffsetMs, + formatTimestamp, + parseTimeMs, +} from "./StreamObserveShared"; + +const TIMELINE_KIND_LABELS: Record = { + "evlog.event": "event", + "otel.exception": "exception", + "otel.span.end": "span end", + "otel.span.event": "span event", + "otel.span.start": "span", +}; + +export function TimelineSection(props: { + startTimeMs: number | null; + timeline: StudioObserveTimelineItem[]; +}) { + const visibleItems = props.timeline.filter( + (item) => item.kind !== "otel.span.end", + ); + + if (visibleItems.length === 0) { + return ( +
+ No timeline items were found for this request. +
+ ); + } + + const baseTimeMs = + props.startTimeMs ?? parseTimeMs(visibleItems[0]?.time ?? null); + + return ( +
+ {visibleItems.map((item) => { + const itemTimeMs = parseTimeMs(item.time); + const offsetMs = + baseTimeMs != null && itemTimeMs != null + ? itemTimeMs - baseTimeMs + : null; + const isException = + item.kind === "otel.exception" || item.severity === "error"; + + return ( +
+ + {offsetMs != null ? formatOffsetMs(offsetMs) : "-"} + + + {TIMELINE_KIND_LABELS[item.kind] ?? item.kind} + + + {item.title} + + {item.service ? ( + + {item.service} + + ) : null} + + {item.duration != null ? formatDurationMs(item.duration) : ""} + +
+ ); + })} +
+ ); +} diff --git a/ui/studio/views/stream/StreamObserveTraceSection.tsx b/ui/studio/views/stream/StreamObserveTraceSection.tsx new file mode 100644 index 00000000..a4ffec89 --- /dev/null +++ b/ui/studio/views/stream/StreamObserveTraceSection.tsx @@ -0,0 +1,245 @@ +import { useMemo, useState } from "react"; + +import { Badge } from "@/ui/components/ui/badge"; +import { cn } from "@/ui/lib/utils"; + +import type { StudioObserveTrace } from "../../../hooks/use-stream-observe-request"; +import { + flattenTraceTree, + formatDurationMs, + formatShortId, + formatTimestamp, + IdChip, + parseTimeMs, +} from "./StreamObserveShared"; + +export function TraceSection(props: { + trace: StudioObserveTrace | null; + tracesStream: string | null; +}) { + const [expandedSpanId, setExpandedSpanId] = useState(null); + const { trace } = props; + const rows = useMemo( + () => (trace ? flattenTraceTree(trace.tree) : []), + [trace], + ); + const traceWindow = useMemo(() => { + let startMs: number | null = null; + let endMs: number | null = null; + + for (const row of rows) { + const rowStartMs = parseTimeMs(row.startTime); + const rowEndMs = + parseTimeMs(row.endTime) ?? + (rowStartMs != null && row.duration != null + ? rowStartMs + row.duration + : rowStartMs); + + if (rowStartMs != null && (startMs == null || rowStartMs < startMs)) { + startMs = rowStartMs; + } + + if (rowEndMs != null && (endMs == null || rowEndMs > endMs)) { + endMs = rowEndMs; + } + } + + return startMs != null && endMs != null && endMs > startMs + ? { durationMs: endMs - startMs, startMs } + : null; + }, [rows]); + + if (!props.tracesStream) { + return ( +
+ No otel-traces stream is available for span correlation. +
+ ); + } + + if (!trace || rows.length === 0) { + return ( +
+ No trace spans were found for this request. +
+ ); + } + + const criticalPathSpanIds = new Set(trace.criticalPath); + + return ( +
+
+ + {trace.spanCount} {trace.spanCount === 1 ? "span" : "spans"} + + {trace.partial ? partial : null} + {trace.duplicateSpans > 0 ? ( + - {trace.duplicateSpans} duplicates deduplicated + ) : null} + {trace.missingParents.length > 0 ? ( + + - missing parents:{" "} + {trace.missingParents.map(formatShortId).join(", ")} + + ) : null} +
+ +
+ {rows.map((row) => { + const rowStartMs = parseTimeMs(row.startTime); + const barLeftPercent = + traceWindow && rowStartMs != null + ? ((rowStartMs - traceWindow.startMs) / traceWindow.durationMs) * + 100 + : 0; + const barWidthPercent = + traceWindow && row.duration != null + ? (row.duration / traceWindow.durationMs) * 100 + : 0; + const isError = row.statusCode === "error"; + const isExpanded = expandedSpanId === row.spanId; + const spanSource = trace.spansById.get(row.spanId); + + return ( +
+ + + {isExpanded ? ( +
+
+ + {row.statusCode} + + {row.kind} + + + {formatTimestamp(row.startTime)} + +
+
+                    {JSON.stringify(spanSource ?? row, null, 2)}
+                  
+
+ ) : null} +
+ ); + })} +
+ + {trace.errors.length > 0 ? ( +
+ + Errors + + {trace.errors.map((error) => ( +
+
+ {error.type ?? "error"} + {error.service ? ( + {error.service} + ) : null} + + {error.name} + +
+ {error.message ? ( +

+ {error.message} +

+ ) : null} +
+ ))} +
+ ) : null} + + {trace.serviceMap.length > 0 ? ( +
+ + Service calls + +
+ {trace.serviceMap.map((edge) => ( + ${edge.to}`} + className="gap-1 font-normal" + variant="outline" + > + + {edge.from} -> {edge.to} + + + {edge.count} {edge.count === 1 ? "call" : "calls"} + {edge.errorCount > 0 + ? `, ${edge.errorCount} ${edge.errorCount === 1 ? "error" : "errors"}` + : ""} + + + ))} +
+
+ ) : null} +
+ ); +} diff --git a/ui/studio/views/stream/StreamView.test.tsx b/ui/studio/views/stream/StreamView.test.tsx index 5b1f3b7e..1b3e39d2 100644 --- a/ui/studio/views/stream/StreamView.test.tsx +++ b/ui/studio/views/stream/StreamView.test.tsx @@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { StudioStreamDetails } from "../../../hooks/use-stream-details"; +import type { StudioStream } from "../../../hooks/use-streams"; import { StreamView } from "./StreamView"; interface MockNavigationState { @@ -10,6 +11,7 @@ interface MockNavigationState { streamAggregationRangeParam: string | null; streamAggregationsParam: string | null; streamFollowParam: string | null; + streamObserveParam: string | null; streamRoutingKeyParam: string | null; streamParam: string | null; } @@ -21,6 +23,7 @@ const { useStreamDetailsMock, useStreamEventsMock, useStreamEventSearchMock, + useStreamObserveRequestMock, useStreamsMock, } = vi.hoisted(() => ({ useNavigationMock: vi.fn<() => Partial>(), @@ -157,19 +160,25 @@ const { } >(), useStreamEventSearchMock: vi.fn(), + useStreamObserveRequestMock: vi.fn< + (args: { + eventsStream: string | null; + lookup: { kind: string; value: string } | null; + tracesStream: string | null; + }) => { + error: Error | null; + isError: boolean; + isFetching: boolean; + isLoading: boolean; + refetch: () => Promise; + result: null; + } + >(), useStreamsMock: vi.fn< (args?: { refreshIntervalMs?: number }) => { isError: boolean; isLoading: boolean; - streams: Array<{ - createdAt: string; - epoch: number; - expiresAt: string | null; - name: string; - nextOffset: string; - sealedThrough: string; - uploadedThrough: string; - }>; + streams: StudioStream[]; } >(), })); @@ -179,6 +188,10 @@ const navigationStateValues = new Map< keyof MockNavigationState, string | null >(); +const navigationStateSetters = new Map< + keyof MockNavigationState, + (value: string | null) => void +>(); vi.mock("../../../hooks/use-navigation", async () => { const React = await vi.importActual("react"); @@ -205,6 +218,10 @@ vi.mock("../../../hooks/use-navigation", async () => { return Promise.resolve(new URLSearchParams()); }; + navigationStateSetters.set(key, (nextValue) => { + navigationStateValues.set(key, nextValue); + setValue(nextValue); + }); return [value, setSharedValue] as const; } @@ -219,6 +236,8 @@ vi.mock("../../../hooks/use-navigation", async () => { useMockNavigationParamState("streamAggregationsParam"); const [streamFollowParam, setStreamFollowParam] = useMockNavigationParamState("streamFollowParam"); + const [streamObserveParam, setStreamObserveParam] = + useMockNavigationParamState("streamObserveParam"); const [streamRoutingKeyParam, setStreamRoutingKeyParam] = useMockNavigationParamState("streamRoutingKeyParam"); const [streamParam, setStreamParam] = @@ -230,11 +249,13 @@ vi.mock("../../../hooks/use-navigation", async () => { setStreamAggregationRangeParam, setStreamAggregationsParam, setStreamFollowParam, + setStreamObserveParam, setStreamRoutingKeyParam, setStreamParam, streamAggregationRangeParam, streamAggregationsParam, streamFollowParam, + streamObserveParam, streamRoutingKeyParam, streamParam, }; @@ -308,6 +329,22 @@ vi.mock("../../../hooks/use-streams", () => ({ useStreams: (args?: { refreshIntervalMs?: number }) => useStreamsMock(args), })); +vi.mock("../../../hooks/use-stream-observe-request", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../hooks/use-stream-observe-request") + >(); + + return { + ...actual, + useStreamObserveRequest: (args: { + eventsStream: string | null; + lookup: { kind: string; value: string } | null; + tracesStream: string | null; + }) => useStreamObserveRequestMock(args), + }; +}); + vi.mock("./use-stream-event-search", () => ({ useStreamEventSearch: useStreamEventSearchMock, })); @@ -462,6 +499,19 @@ function getNavigationStateValue(key: keyof MockNavigationState) { return navigationStateValues.get(key) ?? null; } +function setNavigationStateValue( + key: keyof MockNavigationState, + value: string | null, +) { + const setter = navigationStateSetters.get(key); + + if (!setter) { + throw new Error(`Navigation state setter ${key} is not mounted`); + } + + setter(value); +} + function createStreamDetails( overrides?: Partial< NonNullable["details"]> @@ -480,8 +530,10 @@ function createStreamDetails( name: "prisma-wal", nextOffset: "2", objectStoreRequests: null, + observability: null, pendingBytes: 128n, pendingRows: 3n, + profile: null, routingKey: null, serverConfiguredLimits: null, search: null, @@ -696,6 +748,7 @@ describe("StreamView", () => { beforeEach(() => { uiStateValues.clear(); navigationStateValues.clear(); + navigationStateSetters.clear(); streamRoutingKeySelectorMock.mockReset(); let currentNextOffset = 2n; let lastPolledNextOffset = currentNextOffset; @@ -711,6 +764,14 @@ describe("StreamView", () => { isLoading: false, streams: [], }); + useStreamObserveRequestMock.mockReturnValue({ + error: null, + isError: false, + isFetching: false, + isLoading: false, + refetch: vi.fn(() => Promise.resolve()), + result: null, + }); useStreamDetailsMock.mockImplementation( (args?: { refreshIntervalMs?: number; streamName?: string | null }) => { if (!args?.streamName) { @@ -5240,4 +5301,278 @@ describe("StreamView", () => { }); container.remove(); }); + + it("opens the URL-backed request observability sheet from an expanded evlog row", () => { + useNavigationMock.mockReturnValue({ + searchParam: null, + streamAggregationRangeParam: null, + streamAggregationsParam: null, + streamFollowParam: "paused", + streamObserveParam: null, + streamParam: "app-events", + }); + useStreamDetailsMock.mockImplementation( + (args?: { streamName?: string | null }) => ({ + details: args?.streamName + ? createStreamDetails({ + name: args.streamName, + observability: { + request: { + eventsStream: "app-events", + tracesStream: "configured-traces", + }, + }, + profile: "evlog", + }) + : null, + }), + ); + useStreamsMock.mockReturnValue({ + isError: false, + isLoading: false, + streams: [ + { + createdAt: "2026-03-24T14:42:38.890Z", + epoch: 0, + expiresAt: null, + name: "app-events", + nextOffset: "2", + observability: null, + profile: "evlog", + sealedThrough: "-1", + uploadedThrough: "-1", + }, + { + createdAt: "2026-03-24T14:42:38.890Z", + epoch: 0, + expiresAt: null, + name: "configured-traces", + nextOffset: "6", + observability: null, + profile: "otel-traces", + sealedThrough: "-1", + uploadedThrough: "-1", + }, + ], + }); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + const eventRow = container.querySelector( + '[data-testid="stream-event-row-2"]', + ); + + expect(eventRow).not.toBeNull(); + + act(() => { + if (eventRow) { + click(eventRow); + } + }); + + const observeButton = container.querySelector( + '[data-testid="stream-event-observe-button"]', + ); + + expect(observeButton).not.toBeNull(); + expect( + container.querySelector('[data-testid="stream-event-observe-bar"]') + ?.textContent, + ).toContain("req_2"); + + act(() => { + if (observeButton) { + click(observeButton); + } + }); + + expect(getNavigationStateValue("streamObserveParam")).toBe("req:req_2"); + expect(useStreamObserveRequestMock).toHaveBeenCalledWith({ + eventsStream: "app-events", + lookup: { + kind: "requestId", + value: "req_2", + }, + tracesStream: "configured-traces", + }); + expect( + document.querySelector('[data-testid="stream-observe-sheet"]'), + ).not.toBeNull(); + + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it("opens the request observability sheet from a URL-backed lookup on mount", () => { + useNavigationMock.mockReturnValue({ + searchParam: null, + streamAggregationRangeParam: null, + streamAggregationsParam: null, + streamFollowParam: "paused", + streamObserveParam: "req:req_2", + streamParam: "app-events", + }); + useStreamDetailsMock.mockImplementation( + (args?: { streamName?: string | null }) => ({ + details: args?.streamName + ? createStreamDetails({ + name: args.streamName, + observability: { + request: { + eventsStream: "app-events", + tracesStream: "configured-traces", + }, + }, + profile: "evlog", + }) + : null, + }), + ); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + expect(getNavigationStateValue("streamObserveParam")).toBe("req:req_2"); + expect(useStreamObserveRequestMock).toHaveBeenCalledWith({ + eventsStream: "app-events", + lookup: { + kind: "requestId", + value: "req_2", + }, + tracesStream: "configured-traces", + }); + expect( + document.querySelector('[data-testid="stream-observe-sheet"]'), + ).not.toBeNull(); + + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it("clears a URL-backed request observability lookup when the selected stream changes", async () => { + useNavigationMock.mockReturnValue({ + searchParam: null, + streamAggregationRangeParam: null, + streamAggregationsParam: null, + streamFollowParam: "paused", + streamObserveParam: "req:req_2", + streamParam: "app-events", + }); + useStreamDetailsMock.mockImplementation( + (args?: { streamName?: string | null }) => ({ + details: args?.streamName + ? createStreamDetails({ + epoch: args.streamName === "app-events" ? 0 : 1, + name: args.streamName, + observability: { + request: { + eventsStream: args.streamName, + tracesStream: "configured-traces", + }, + }, + profile: "evlog", + }) + : null, + }), + ); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + expect(getNavigationStateValue("streamObserveParam")).toBe("req:req_2"); + + useNavigationMock.mockReturnValue({ + searchParam: null, + streamAggregationRangeParam: null, + streamAggregationsParam: null, + streamFollowParam: "paused", + streamObserveParam: "req:req_2", + streamParam: "audit-events", + }); + + await act(async () => { + setNavigationStateValue("streamParam", "audit-events"); + await Promise.resolve(); + }); + + expect(getNavigationStateValue("streamObserveParam")).toBeNull(); + + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it("keeps the request observability affordance hidden for non-observability profiles", () => { + useNavigationMock.mockReturnValue({ + searchParam: null, + streamAggregationRangeParam: null, + streamAggregationsParam: null, + streamFollowParam: "paused", + streamObserveParam: null, + streamParam: "prisma-wal", + }); + useStreamDetailsMock.mockImplementation( + (args?: { streamName?: string | null }) => ({ + details: args?.streamName + ? createStreamDetails({ + name: args.streamName, + profile: "state-protocol", + }) + : null, + }), + ); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + const eventRow = container.querySelector( + '[data-testid="stream-event-row-2"]', + ); + + expect(eventRow).not.toBeNull(); + + act(() => { + if (eventRow) { + click(eventRow); + } + }); + + expect( + container.querySelector('[data-testid="stream-event-observe-bar"]'), + ).toBeNull(); + expect(useStreamsMock).not.toHaveBeenCalled(); + expect(useStreamObserveRequestMock).not.toHaveBeenCalled(); + + act(() => { + root.unmount(); + }); + container.remove(); + }); }); diff --git a/ui/studio/views/stream/StreamView.tsx b/ui/studio/views/stream/StreamView.tsx index 134d10b4..caac05f4 100644 --- a/ui/studio/views/stream/StreamView.tsx +++ b/ui/studio/views/stream/StreamView.tsx @@ -1,4 +1,9 @@ -import { ChartColumn, ChevronsLeft, ChevronsRight } from "lucide-react"; +import { + ChartColumn, + ChartNoAxesGantt, + ChevronsLeft, + ChevronsRight, +} from "lucide-react"; import { useCallback, useEffect, @@ -42,6 +47,15 @@ import { type StudioStreamEventIndexedField, useStreamEvents, } from "../../../hooks/use-stream-events"; +import { + getObserveLookupForStreamEvent, + isObservabilityStreamProfile, + parseStreamObserveParam, + resolveObserveStreams, + serializeStreamObserveParam, + type StudioObserveLookup, +} from "../../../hooks/use-stream-observe-request"; +import type { StudioStreamObservability } from "../../../hooks/use-streams"; import { useUiState } from "../../../hooks/use-ui-state"; import { ExpandableSearchControl } from "../../input/ExpandableSearchControl"; import { StudioHeader } from "../../StudioHeader"; @@ -57,6 +71,7 @@ import { } from "./stream-search-suggestions"; import { StreamAggregationsPanel } from "./StreamAggregationsPanel"; import { StreamDiagnosticsPopover } from "./StreamDiagnosticsPopover"; +import { StreamObserveSheet } from "./StreamObserveSheet"; import { StreamRoutingKeySelector } from "./StreamRoutingKeySelector"; import { useStreamEventSearch } from "./use-stream-event-search"; @@ -517,6 +532,8 @@ function StreamEventRow(props: { event: StudioStreamEvent; expandedEventId: string | null; isNewlyRevealed: boolean; + observeProfile: string | null; + onOpenObserveLookup: (lookup: StudioObserveLookup) => void; onToggle: (eventId: string) => void; searchConfig: StudioStreamSearchConfig | null | undefined; searchQuery: string; @@ -525,11 +542,23 @@ function StreamEventRow(props: { event, expandedEventId, isNewlyRevealed, + observeProfile, + onOpenObserveLookup, onToggle, searchConfig, searchQuery, } = props; const isExpanded = expandedEventId === event.id; + const observe = useMemo( + () => + isExpanded + ? getObserveLookupForStreamEvent({ + body: event.body, + profile: observeProfile, + }) + : null, + [event.body, isExpanded, observeProfile], + ); return (
+ {observe?.lookup ? ( +
+ + {observe.ids.requestId ? ( + + req {observe.ids.requestId} + + ) : null} + {observe.ids.traceId ? ( + + trace {observe.ids.traceId} + + ) : null} +
+ ) : null}
              string | null),
   ) => Promise;
+  setStreamObserveParam: (
+    value: string | null | ((previous: string | null) => string | null),
+  ) => Promise;
   setStreamRoutingKeyParam: (
     value: string | null | ((previous: string | null) => string | null),
   ) => Promise;
   streamAggregationRangeParam: string | null;
+  streamObserveParam: string | null;
   streamRoutingKeyParam: string | null;
 }) {
   const followMode = props.followMode;
@@ -804,7 +877,49 @@ function ActiveStreamView(props: {
     selectedStream.name,
     selectedStreamDetails?.indexStatus?.profile,
   ]);
+  const resolvedStreamProfile = selectedStreamDetails?.profile ?? null;
+  const supportsRequestObservability = isObservabilityStreamProfile(
+    resolvedStreamProfile,
+  );
+  const setStreamObserveParam = props.setStreamObserveParam;
   const selectedStreamIdentity = `${selectedStream.name}:${selectedStream.epoch}`;
+  const lastObserveStreamIdentityRef = useRef(null);
+
+  useEffect(() => {
+    if (lastObserveStreamIdentityRef.current === null) {
+      lastObserveStreamIdentityRef.current = selectedStreamIdentity;
+      return;
+    }
+
+    if (lastObserveStreamIdentityRef.current !== selectedStreamIdentity) {
+      lastObserveStreamIdentityRef.current = selectedStreamIdentity;
+      void setStreamObserveParam(null);
+    }
+  }, [selectedStreamIdentity, setStreamObserveParam]);
+
+  const isObserveLookupScopedToSelectedStream =
+    lastObserveStreamIdentityRef.current === null ||
+    lastObserveStreamIdentityRef.current === selectedStreamIdentity;
+  const observeLookup = useMemo(
+    () =>
+      supportsRequestObservability && isObserveLookupScopedToSelectedStream
+        ? parseStreamObserveParam(props.streamObserveParam)
+        : null,
+    [
+      props.streamObserveParam,
+      isObserveLookupScopedToSelectedStream,
+      supportsRequestObservability,
+    ],
+  );
+  const openObserveLookup = useCallback(
+    (lookup: StudioObserveLookup) => {
+      void setStreamObserveParam(serializeStreamObserveParam(lookup));
+    },
+    [setStreamObserveParam],
+  );
+  const closeObserveSheet = useCallback(() => {
+    void setStreamObserveParam(null);
+  }, [setStreamObserveParam]);
   const streamEventWindowResetKey = `${selectedStreamIdentity ?? "none"}::${selectedRoutingKey}::${effectiveSearchQuery}`;
   const [pageCount, setPageCount] = useState(1);
   const [searchVisibleResultCount, setSearchVisibleResultCount] = useState<
@@ -1818,6 +1933,12 @@ function ActiveStreamView(props: {
                       event={event}
                       expandedEventId={expandedEventId}
                       isNewlyRevealed={recentlyRevealedEventIdSet.has(event.id)}
+                      observeProfile={
+                        supportsRequestObservability
+                          ? resolvedStreamProfile
+                          : null
+                      }
+                      onOpenObserveLookup={openObserveLookup}
                       onToggle={(eventId) => {
                         setExpandedEventId((currentValue) =>
                           currentValue === eventId ? null : eventId,
@@ -1953,10 +2074,47 @@ function ActiveStreamView(props: {
           
+ + {supportsRequestObservability && resolvedStreamProfile ? ( + + ) : null} ); } +function StreamObserveSheetHost(props: { + activeStreamName: string; + activeStreamProfile: string; + lookup: StudioObserveLookup | null; + observability: StudioStreamObservability | null; + onClose: () => void; +}) { + const observeStreams = useMemo( + () => + resolveObserveStreams({ + activeStreamName: props.activeStreamName, + activeStreamProfile: props.activeStreamProfile, + observability: props.observability, + }), + [props.activeStreamName, props.activeStreamProfile, props.observability], + ); + + return ( + + ); +} + export function StreamView(_props: ViewProps) { const { searchParam, @@ -1964,10 +2122,12 @@ export function StreamView(_props: ViewProps) { setStreamAggregationRangeParam, setStreamAggregationsParam, setStreamFollowParam, + setStreamObserveParam, setStreamRoutingKeyParam, streamAggregationRangeParam, streamAggregationsParam, streamFollowParam, + streamObserveParam, streamRoutingKeyParam, streamParam, } = useNavigation(); @@ -2161,8 +2321,10 @@ export function StreamView(_props: ViewProps) { setStreamAggregationRangeParam={setStreamAggregationRangeParam} setStreamAggregationsParam={setStreamAggregationsParam} setStreamFollowParam={setStreamFollowParam} + setStreamObserveParam={setStreamObserveParam} setStreamRoutingKeyParam={setStreamRoutingKeyParam} streamAggregationRangeParam={streamAggregationRangeParam} + streamObserveParam={streamObserveParam} streamRoutingKeyParam={streamRoutingKeyParam} /> ); diff --git a/vitest.config.ts b/vitest.config.ts index beb11eba..5b5ef45d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,7 +1,7 @@ import { fileURLToPath, URL } from "node:url"; import type { Options } from "tsup"; -import { defineConfig } from "vitest/config"; +import { configDefaults, defineConfig } from "vitest/config"; import { default as tsupConfig } from "./tsup.config"; @@ -14,11 +14,23 @@ const resolveAlias = { }, ], }; +const maxWorkers = + process.env.VITEST_MAX_WORKERS ?? (process.env.CI ? "50%" : "2"); +const includeHeavyLocalTests = + process.env.STUDIO_INCLUDE_HEAVY_LOCAL_TESTS === "1"; +const localTestExcludes = includeHeavyLocalTests + ? [] + : [ + "demo/ppg-dev/build-compute.test.ts", + "ui/studio/views/table/ActiveTableView.filtering.test.tsx", + ]; // https://vitest.dev/guide/projects.html#test-projects export default defineConfig({ resolve: resolveAlias, test: { + exclude: [...configDefaults.exclude, ...localTestExcludes], + maxWorkers, projects: [ { resolve: resolveAlias, @@ -27,6 +39,7 @@ export default defineConfig({ TZ: "UTC", }, environment: "node", + exclude: [...configDefaults.exclude, ...localTestExcludes], include: ["checkpoint/**/*.test.ts"], name: "checkpoint", }, @@ -41,6 +54,7 @@ export default defineConfig({ TZ: "UTC", }, environment: "node", + exclude: [...configDefaults.exclude, ...localTestExcludes], fileParallelism: false, include: ["data/**/*.test.ts"], name: "data", @@ -53,6 +67,7 @@ export default defineConfig({ TZ: "UTC", }, environment: "node", + exclude: [...configDefaults.exclude, ...localTestExcludes], include: ["demo/**/*.test.ts"], name: "demo", }, @@ -64,6 +79,7 @@ export default defineConfig({ TZ: "UTC", }, environment: "node", + exclude: [...configDefaults.exclude, ...localTestExcludes], include: ["scripts/**/*.test.ts"], name: "release", }, @@ -72,6 +88,7 @@ export default defineConfig({ resolve: resolveAlias, test: { environment: "happy-dom", // or "jsdom" + exclude: [...configDefaults.exclude, ...localTestExcludes], include: ["ui/**/*.test.{ts,tsx}"], name: "ui", }, @@ -80,6 +97,7 @@ export default defineConfig({ resolve: resolveAlias, test: { environment: "node", + exclude: [...configDefaults.exclude, ...localTestExcludes], include: ["**/*.e2e.{ts,tsx}"], name: "e2e", // browser: {