diff --git a/.changeset/quiet-evlog-preview.md b/.changeset/quiet-evlog-preview.md new file mode 100644 index 00000000..bac8ba9b --- /dev/null +++ b/.changeset/quiet-evlog-preview.md @@ -0,0 +1,5 @@ +--- +"@prisma/studio-core": patch +--- + +Improve observability stream previews with concise evlog request summaries and otel span summaries. diff --git a/Architecture/stream-event-view.md b/Architecture/stream-event-view.md index 67c15fd2..784b60fa 100644 --- a/Architecture/stream-event-view.md +++ b/Architecture/stream-event-view.md @@ -252,6 +252,8 @@ Summary derivation rules: - key MAY be derived from explicit key/routing-key fields in the decoded payload - indexed fields MAY be derived only from explicit indexed-field payload shapes; do not invent synthetic indexed metadata - 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 +- 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 +- 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 - expanded content SHOULD pretty-print structured JSON payloads - 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 - 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 diff --git a/FEATURES.md b/FEATURES.md index f3d06d19..1a3f6af8 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -74,6 +74,8 @@ When that deep link lands on a `state-protocol` WAL stream, the stream view also Selecting a stream opens a dedicated event log view in the main pane instead of the table grid. 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. +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. +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. 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`. 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. 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. diff --git a/ui/hooks/use-stream-events.test.tsx b/ui/hooks/use-stream-events.test.tsx index 2cf08a6f..88ce86a9 100644 --- a/ui/hooks/use-stream-events.test.tsx +++ b/ui/hooks/use-stream-events.test.tsx @@ -8,6 +8,7 @@ import { createStreamReadUrl, encodeStreamOffset, getStreamEventsWindow, + normalizeStreamEvents, useStreamEvents, } from "./use-stream-events"; import type { StudioStream } from "./use-streams"; @@ -262,6 +263,128 @@ describe("useStreamEvents", () => { ); }); + it("summarizes evlog request previews without showing raw JSON", () => { + const events = normalizeStreamEvents({ + events: [ + { + timestamp: "2026-06-12T17:05:10.935Z", + level: "info", + service: "storefront", + method: "GET", + path: "/product/acme-mug", + status: 200, + duration: 0, + message: "Storefront request", + }, + { + timestamp: "2026-06-12T17:09:18.912Z", + level: "error", + service: "storefront", + method: "POST", + path: "/api/observability/simulate", + status: 500, + message: "Synthetic diagnostic: Trace fanout", + }, + ], + startExclusiveSequence: 0n, + stream: { + epoch: 0, + name: "webshop-production-events", + profile: "evlog", + }, + }); + + expect(events.map((event) => event.preview)).toEqual([ + "GET /product/acme-mug", + "POST /api/observability/simulate 500 Synthetic diagnostic: Trace fanout", + ]); + }); + + it("keeps generic previews for non-evlog streams", () => { + const events = normalizeStreamEvents({ + events: [ + { + method: "GET", + path: "/product/acme-mug", + status: 200, + message: "Storefront request", + }, + ], + startExclusiveSequence: 0n, + stream: { + epoch: 0, + name: "generic-json-events", + profile: null, + }, + }); + + expect(events[0]?.preview).toBe( + '{"method":"GET","path":"/product/acme-mug","status":200,"message":"Storefront request"}', + ); + }); + + it("summarizes otel trace span previews without showing raw JSON", () => { + const events = normalizeStreamEvents({ + events: [ + { + attributes: { + "http.request.method": "POST", + "http.response.status_code": 200, + "url.path": "/api/query-insights/snapshot", + }, + endUnixNano: "1810000003486000000", + kind: "server", + name: "fetchHandler POST", + resource: { + attributes: { + "service.name": "console", + }, + }, + spanId: "086e83747d0e381e", + startUnixNano: "1810000000000000000", + status: { code: "ok", message: null }, + traceId: "5b8efff798038103d269b633813fc60c", + }, + { + attributes: { "url.full": "https://payments.internal/charges" }, + endUnixNano: "1810000000192000000", + events: [ + { + attributes: { + "exception.message": "Card declined by issuer", + "exception.type": "CardDeclinedError", + }, + name: "exception", + timeUnixNano: "1810000000190000000", + }, + ], + kind: "client", + name: "POST payments /charges", + resource: { + attributes: { + "service.name": "payments", + }, + }, + spanId: "22dd83747d0e3822", + startUnixNano: "1810000000041000000", + status: { code: "error", message: "402 from issuer" }, + traceId: "5b8efff798038103d269b633813fc60c", + }, + ], + startExclusiveSequence: 0n, + stream: { + epoch: 0, + name: "webshop-production-traces", + profile: "otel-traces", + }, + }); + + expect(events.map((event) => event.preview)).toEqual([ + "POST /api/query-insights/snapshot | console | 3.49s", + "POST payments /charges | payments | 151ms | error: 402 from issuer", + ]); + }); + it("loads a tail window and normalizes events into newest-first rows", async () => { const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( new Response( diff --git a/ui/hooks/use-stream-events.ts b/ui/hooks/use-stream-events.ts index dade8ada..b9a753d4 100644 --- a/ui/hooks/use-stream-events.ts +++ b/ui/hooks/use-stream-events.ts @@ -9,6 +9,10 @@ import { useCallback, useEffect, useMemo, useRef } from "react"; import { useStudio } from "../studio/context"; import type { StudioStreamSearchConfig } from "./use-stream-details"; +import { + STREAM_PROFILE_EVLOG, + STREAM_PROFILE_OTEL_TRACES, +} from "./use-stream-observe-request"; import type { StudioStream } from "./use-streams"; const OFFSET_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; @@ -53,7 +57,7 @@ export interface NormalizeStreamEventsArgs { events: unknown[]; searchConfig?: StudioStreamSearchConfig | null; startExclusiveSequence: bigint; - stream: Pick; + stream: Pick; } export interface UseStreamEventsArgs { @@ -221,18 +225,318 @@ function compactText(value: string): string { return value.replace(/\s+/g, " ").trim(); } -function createPreview(value: unknown): string { - const preferredValue = - isRecord(value) && "value" in value && value.value !== undefined - ? value.value - : value; - const previewText = compactText(stringifyJson(preferredValue, 0)); +function truncatePreview(value: string): string { + if (value.length <= PREVIEW_CHARACTER_LIMIT) { + return value; + } + + return `${value.slice(0, PREVIEW_CHARACTER_LIMIT - 1)}…`; +} + +function getPreferredPreviewValue(value: unknown): unknown { + return isRecord(value) && "value" in value && value.value !== undefined + ? value.value + : value; +} + +function parsePreviewString(value: unknown): string | null { + const primitive = stringifyPrimitive(value); + const text = primitive === null ? null : compactText(primitive); + + return text && text.length > 0 ? text : null; +} + +function parsePreviewNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + + if (typeof value === "string" && value.trim().length > 0) { + const parsed = Number(value); + + return Number.isFinite(parsed) ? parsed : null; + } + + return null; +} + +function readRecordValue( + value: Record | null, + key: string, +): unknown { + return value ? value[key] : undefined; +} + +function parseRecordString( + value: Record | null, + key: string, +): string | null { + return parsePreviewString(readRecordValue(value, key)); +} + +function shouldShowEvlogRequestMessage(args: { + level: string | null; + message: string | null; + status: number | null; +}): boolean { + if (!args.message) { + return false; + } + + if (args.status !== null && (args.status < 200 || args.status >= 400)) { + return true; + } + + if ( + args.level === "error" || + args.level === "fatal" || + args.level === "warn" || + args.level === "warning" + ) { + return true; + } + + return false; +} + +function createEvlogPreview(value: unknown): string | null { + if (!isRecord(value)) { + return null; + } + + const context = isRecord(value.context) ? value.context : null; + const method = parsePreviewString(value.method)?.toUpperCase() ?? null; + const path = + parsePreviewString(value.path) ?? + parsePreviewString(value.route) ?? + (context ? parsePreviewString(context.route) : null) ?? + (context ? parsePreviewString(context.path) : null); + const statusText = parsePreviewString(value.status); + const status = parsePreviewNumber(value.status); + const message = + parsePreviewString(value.message) ?? + parsePreviewString(value.why) ?? + parsePreviewString(value.fix); + const level = parsePreviewString(value.level)?.toLowerCase() ?? null; + + if (method && path) { + const shouldShowStatus = + statusText !== null && (status === null || status < 200 || status >= 400); + const shouldShowMessage = shouldShowEvlogRequestMessage({ + level, + message, + status, + }); + const preview = [method, path] + .concat(shouldShowStatus ? [statusText] : []) + .concat(shouldShowMessage && message ? [message] : []) + .join(" "); - if (previewText.length <= PREVIEW_CHARACTER_LIMIT) { - return previewText; + return truncatePreview(preview); } - return `${previewText.slice(0, PREVIEW_CHARACTER_LIMIT - 1)}…`; + if (message) { + const prefix = + statusText !== null && (status === null || status < 200 || status >= 400) + ? `${statusText} ` + : ""; + + return truncatePreview(`${prefix}${message}`); + } + + return null; +} + +function formatOtelDurationMs(durationMs: number): string | null { + if (!Number.isFinite(durationMs) || durationMs < 0) { + return null; + } + + if (durationMs > 0 && durationMs < 1) { + return "<1ms"; + } + + if (durationMs < 1000) { + return `${Math.round(durationMs).toString()}ms`; + } + + const seconds = durationMs / 1000; + const precision = seconds < 10 ? 2 : 1; + const formattedSeconds = seconds + .toFixed(precision) + .replace(/\.0+$/, "") + .replace(/(\.\d*[1-9])0+$/, "$1"); + + return `${formattedSeconds}s`; +} + +function parseUnixNano(value: unknown): bigint | null { + if (typeof value === "bigint") { + return value; + } + + if (typeof value === "number" && Number.isFinite(value)) { + return BigInt(Math.trunc(value)); + } + + if (typeof value === "string" && /^\d+$/.test(value)) { + return BigInt(value); + } + + return null; +} + +function getOtelDurationMs(span: Record): number | null { + const explicitDuration = + parsePreviewNumber(span.durationMs) ?? parsePreviewNumber(span.duration); + + if (explicitDuration !== null) { + return explicitDuration; + } + + const startUnixNano = parseUnixNano(span.startUnixNano); + const endUnixNano = parseUnixNano(span.endUnixNano); + + if (startUnixNano === null || endUnixNano === null) { + return null; + } + + const durationNano = endUnixNano - startUnixNano; + + if (durationNano < 0n) { + return null; + } + + return Number(durationNano) / 1_000_000; +} + +function parseUrlPath(value: string): string | null { + try { + return new URL(value).pathname || null; + } catch { + return null; + } +} + +function getOtelSpanDisplayName(args: { + attributes: Record | null; + span: Record; +}): string | null { + const method = + parseRecordString(args.attributes, "http.request.method") ?? + parseRecordString(args.attributes, "http.method"); + const path = + parseRecordString(args.attributes, "url.path") ?? + parseRecordString(args.attributes, "http.route") ?? + parseRecordString(args.attributes, "http.target") ?? + (() => { + const urlFull = parseRecordString(args.attributes, "url.full"); + + return urlFull ? parseUrlPath(urlFull) : null; + })(); + + if (method && path) { + return `${method.toUpperCase()} ${path}`; + } + + return parsePreviewString(args.span.name); +} + +function getOtelServiceName(span: Record): string | null { + const resource = isRecord(span.resource) ? span.resource : null; + const resourceAttributes = + resource && isRecord(resource.attributes) ? resource.attributes : null; + + return ( + parseRecordString(resourceAttributes, "service.name") ?? + parseRecordString(resourceAttributes, "service.namespace") ?? + parsePreviewString(span.service) + ); +} + +function getOtelErrorMessage(span: Record): string | null { + const status = isRecord(span.status) ? span.status : null; + const statusCode = parseRecordString(status, "code")?.toLowerCase() ?? null; + const statusMessage = parseRecordString(status, "message"); + + if ( + statusCode !== null && + statusCode !== "ok" && + statusCode !== "unset" && + statusCode !== "0" + ) { + return statusMessage ?? statusCode; + } + + const events = Array.isArray(span.events) ? span.events : []; + + for (const event of events) { + if (!isRecord(event)) { + continue; + } + + const eventAttributes = isRecord(event.attributes) + ? event.attributes + : null; + const exceptionMessage = parseRecordString( + eventAttributes, + "exception.message", + ); + + if (exceptionMessage) { + return exceptionMessage; + } + } + + return null; +} + +function createOtelTracePreview(value: unknown): string | null { + if (!isRecord(value)) { + return null; + } + + const attributes = isRecord(value.attributes) ? value.attributes : null; + const name = getOtelSpanDisplayName({ attributes, span: value }); + + if (!name) { + return null; + } + + const duration = getOtelDurationMs(value); + const formattedDuration = + duration === null ? null : formatOtelDurationMs(duration); + const errorMessage = getOtelErrorMessage(value); + const parts = [name] + .concat(getOtelServiceName(value) ?? []) + .concat(formattedDuration ?? []) + .concat(errorMessage ? [`error: ${errorMessage}`] : []); + + return truncatePreview(parts.join(" | ")); +} + +function createPreview(value: unknown, profile?: string | null): string { + const preferredValue = getPreferredPreviewValue(value); + + if (profile === STREAM_PROFILE_EVLOG) { + const evlogPreview = createEvlogPreview(preferredValue); + + if (evlogPreview) { + return evlogPreview; + } + } + + if (profile === STREAM_PROFILE_OTEL_TRACES) { + const otelPreview = createOtelTracePreview(preferredValue); + + if (otelPreview) { + return otelPreview; + } + } + + const previewText = compactText(stringifyJson(preferredValue, 0)); + + return truncatePreview(previewText); } function estimateSizeBytes(value: unknown): number { @@ -770,7 +1074,7 @@ export function normalizeStreamEvents( indexedFields: extractIndexedFields(event), key: extractKey(event), offset, - preview: createPreview(event), + preview: createPreview(event, stream.profile), sequence: sequence.toString(), sizeBytes: estimateSizeBytes(event), sortOffset: offset, @@ -782,7 +1086,7 @@ export function normalizeStreamEvents( function normalizeStandaloneRoutingKeyEvents(args: { events: unknown[]; searchConfig?: StudioStreamSearchConfig | null; - stream: Pick; + stream: Pick; }): StudioStreamEvent[] { const { events, searchConfig, stream } = args; @@ -797,7 +1101,7 @@ function normalizeStandaloneRoutingKeyEvents(args: { indexedFields: extractIndexedFields(event), key: extractKey(event), offset, - preview: createPreview(event), + preview: createPreview(event, stream.profile), sequence: sequence.toString(), sizeBytes: estimateSizeBytes(event), sortOffset: offset, @@ -809,7 +1113,7 @@ function normalizeStandaloneRoutingKeyEvents(args: { function normalizeStreamSearchHits(args: { hits: StreamSearchApiHit[]; searchConfig?: StudioStreamSearchConfig | null; - stream: Pick; + stream: Pick; }): StudioStreamEvent[] { const { hits, searchConfig, stream } = args; @@ -823,7 +1127,7 @@ function normalizeStreamSearchHits(args: { indexedFields: extractIndexedFields(hit.source), key: extractKey(hit.source), offset: hit.offset, - preview: createPreview(hit.source), + preview: createPreview(hit.source, stream.profile), sequence: sequence?.toString() ?? hit.offset, sizeBytes: estimateSizeBytes(hit.source), sortOffset: hit.offset,