Skip to content

Commit e1583e5

Browse files
authored
Improve observability stream previews
Render compact evlog and OTEL trace stream previews.
1 parent 63fa63b commit e1583e5

5 files changed

Lines changed: 451 additions & 15 deletions

File tree

.changeset/quiet-evlog-preview.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@prisma/studio-core": patch
3+
---
4+
5+
Improve observability stream previews with concise evlog request summaries and otel span summaries.

Architecture/stream-event-view.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,8 @@ Summary derivation rules:
252252
- key MAY be derived from explicit key/routing-key fields in the decoded payload
253253
- indexed fields MAY be derived only from explicit indexed-field payload shapes; do not invent synthetic indexed metadata
254254
- preview SHOULD prefer the payload's primary content object when one exists (for example a top-level `value` field), otherwise fall back to the full decoded event
255+
- when the active stream profile is `evlog`, preview SHOULD render a compact request summary from request-like fields before falling back to JSON, using `METHOD path` for successful info-level web requests and adding non-success status plus warning/error message context when present
256+
- when the active stream profile is `otel-traces`, preview SHOULD render a compact span summary from OTEL span fields before falling back to JSON, preferring HTTP semantic attributes for request spans and including service, duration, and error status when available
255257
- expanded content SHOULD pretty-print structured JSON payloads
256258
- when stream search is active and a row is expanded, the pretty-printed expanded content SHOULD highlight matching fields and values using the same yellow search treatment used by table search
257259
- unfielded search clauses SHOULD highlight only the matched value text for the configured default fields, not the names of every default field that participated in matching

FEATURES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ When that deep link lands on a `state-protocol` WAL stream, the stream view also
7474

7575
Selecting a stream opens a dedicated event log view in the main pane instead of the table grid.
7676
The view uses TanStack DB-backed infinite scroll to load the newest events first, shows summary columns for time, key, indexed fields, preview text, and payload size, and lets users expand one event at a time to inspect the full formatted content.
77+
For `evlog` streams, the preview column summarizes request-shaped events as readable request lines such as `GET /product/acme-mug`, adding status and diagnostic text for failures or warning/error events while keeping the full JSON available in the expanded row.
78+
For `otel-traces` streams, preview text summarizes spans from semantic OTEL fields, including request path, service, duration, and error status when available, so trace rows can be scanned without opening raw span JSON.
7779
When a stream advertises a search schema with a primary timestamp field, the event log uses that configured timestamp for the row time column before falling back to legacy timestamp field names. This keeps schema-driven streams like GH Archive from showing `Unknown time` even when their canonical timestamp lives under a non-legacy field such as `eventTime`.
7880
The stream chrome now mirrors the table view more closely: the header is reserved for controls, while a fixed footer summary box shows the latest event count and total logical payload bytes in human-readable units.
7981
That footer count uses grouped digits like `12,345 events`, while the byte total stays compact by scaling units such as `MB` and `GB` instead of showing a raw comma-separated byte count.

ui/hooks/use-stream-events.test.tsx

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
createStreamReadUrl,
99
encodeStreamOffset,
1010
getStreamEventsWindow,
11+
normalizeStreamEvents,
1112
useStreamEvents,
1213
} from "./use-stream-events";
1314
import type { StudioStream } from "./use-streams";
@@ -262,6 +263,128 @@ describe("useStreamEvents", () => {
262263
);
263264
});
264265

266+
it("summarizes evlog request previews without showing raw JSON", () => {
267+
const events = normalizeStreamEvents({
268+
events: [
269+
{
270+
timestamp: "2026-06-12T17:05:10.935Z",
271+
level: "info",
272+
service: "storefront",
273+
method: "GET",
274+
path: "/product/acme-mug",
275+
status: 200,
276+
duration: 0,
277+
message: "Storefront request",
278+
},
279+
{
280+
timestamp: "2026-06-12T17:09:18.912Z",
281+
level: "error",
282+
service: "storefront",
283+
method: "POST",
284+
path: "/api/observability/simulate",
285+
status: 500,
286+
message: "Synthetic diagnostic: Trace fanout",
287+
},
288+
],
289+
startExclusiveSequence: 0n,
290+
stream: {
291+
epoch: 0,
292+
name: "webshop-production-events",
293+
profile: "evlog",
294+
},
295+
});
296+
297+
expect(events.map((event) => event.preview)).toEqual([
298+
"GET /product/acme-mug",
299+
"POST /api/observability/simulate 500 Synthetic diagnostic: Trace fanout",
300+
]);
301+
});
302+
303+
it("keeps generic previews for non-evlog streams", () => {
304+
const events = normalizeStreamEvents({
305+
events: [
306+
{
307+
method: "GET",
308+
path: "/product/acme-mug",
309+
status: 200,
310+
message: "Storefront request",
311+
},
312+
],
313+
startExclusiveSequence: 0n,
314+
stream: {
315+
epoch: 0,
316+
name: "generic-json-events",
317+
profile: null,
318+
},
319+
});
320+
321+
expect(events[0]?.preview).toBe(
322+
'{"method":"GET","path":"/product/acme-mug","status":200,"message":"Storefront request"}',
323+
);
324+
});
325+
326+
it("summarizes otel trace span previews without showing raw JSON", () => {
327+
const events = normalizeStreamEvents({
328+
events: [
329+
{
330+
attributes: {
331+
"http.request.method": "POST",
332+
"http.response.status_code": 200,
333+
"url.path": "/api/query-insights/snapshot",
334+
},
335+
endUnixNano: "1810000003486000000",
336+
kind: "server",
337+
name: "fetchHandler POST",
338+
resource: {
339+
attributes: {
340+
"service.name": "console",
341+
},
342+
},
343+
spanId: "086e83747d0e381e",
344+
startUnixNano: "1810000000000000000",
345+
status: { code: "ok", message: null },
346+
traceId: "5b8efff798038103d269b633813fc60c",
347+
},
348+
{
349+
attributes: { "url.full": "https://payments.internal/charges" },
350+
endUnixNano: "1810000000192000000",
351+
events: [
352+
{
353+
attributes: {
354+
"exception.message": "Card declined by issuer",
355+
"exception.type": "CardDeclinedError",
356+
},
357+
name: "exception",
358+
timeUnixNano: "1810000000190000000",
359+
},
360+
],
361+
kind: "client",
362+
name: "POST payments /charges",
363+
resource: {
364+
attributes: {
365+
"service.name": "payments",
366+
},
367+
},
368+
spanId: "22dd83747d0e3822",
369+
startUnixNano: "1810000000041000000",
370+
status: { code: "error", message: "402 from issuer" },
371+
traceId: "5b8efff798038103d269b633813fc60c",
372+
},
373+
],
374+
startExclusiveSequence: 0n,
375+
stream: {
376+
epoch: 0,
377+
name: "webshop-production-traces",
378+
profile: "otel-traces",
379+
},
380+
});
381+
382+
expect(events.map((event) => event.preview)).toEqual([
383+
"POST /api/query-insights/snapshot | console | 3.49s",
384+
"POST payments /charges | payments | 151ms | error: 402 from issuer",
385+
]);
386+
});
387+
265388
it("loads a tail window and normalizes events into newest-first rows", async () => {
266389
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
267390
new Response(

0 commit comments

Comments
 (0)