From 5ef51de3cd77e145ec33b0e34398060682e05d0a Mon Sep 17 00:00:00 2001 From: nickhilpat Date: Thu, 30 Jul 2026 15:20:37 -0500 Subject: [PATCH 1/9] [local-explorer-ui] improve observability logs, id search, and event-to-trace navigation --- .../observability/observability.test.ts | 5 ++ .../observability/query-clauses.test.ts | 9 +++ .../observability/InvocationLogs.tsx | 19 +----- .../observability/QuerySyntaxHint.tsx | 6 ++ .../src/routes/observability/events.tsx | 46 ++++++++++----- .../src/routes/observability/index.tsx | 37 +++++++++++- .../src/utils/observability-query.ts | 27 +++++++++ .../src/utils/observability.ts | 59 ++++++++++++++++--- 8 files changed, 169 insertions(+), 39 deletions(-) diff --git a/packages/local-explorer-ui/src/__tests__/observability/observability.test.ts b/packages/local-explorer-ui/src/__tests__/observability/observability.test.ts index 8a1834eb7e5..7880737a409 100644 --- a/packages/local-explorer-ui/src/__tests__/observability/observability.test.ts +++ b/packages/local-explorer-ui/src/__tests__/observability/observability.test.ts @@ -563,6 +563,11 @@ describe("formatLogMessage", () => { test("stringifies non-string JSON", ({ expect }) => { expect(formatLogMessage(JSON.stringify({ a: 1 }))).toBe(`{"a":1}`); }); + test("joins a console arg array the way the console would", ({ expect }) => { + expect( + formatLogMessage(JSON.stringify(["request failed:", { a: 1 }])) + ).toBe(`request failed: {"a":1}`); + }); test("falls back to the raw value on invalid JSON", ({ expect }) => { expect(formatLogMessage("raw")).toBe("raw"); }); diff --git a/packages/local-explorer-ui/src/__tests__/observability/query-clauses.test.ts b/packages/local-explorer-ui/src/__tests__/observability/query-clauses.test.ts index 362a1cc7e7e..7d81634d5e1 100644 --- a/packages/local-explorer-ui/src/__tests__/observability/query-clauses.test.ts +++ b/packages/local-explorer-ui/src/__tests__/observability/query-clauses.test.ts @@ -68,6 +68,15 @@ describe("parseTraceQuery clauses", () => { { field: "duration", op: ">=", value: "100" }, ]); }); + + test("trace: and span: are parsed as id lookups, not clauses", ({ + expect, + }) => { + const parsed = parseTraceQuery("trace:abc123 span:def456"); + expect(parsed.traceId).toBe("abc123"); + expect(parsed.spanId).toBe("def456"); + expect(parsed.clauses).toEqual([]); + }); }); describe("durationClauseSql", () => { diff --git a/packages/local-explorer-ui/src/components/observability/InvocationLogs.tsx b/packages/local-explorer-ui/src/components/observability/InvocationLogs.tsx index 0b066bfa04d..c04f35aeb64 100644 --- a/packages/local-explorer-ui/src/components/observability/InvocationLogs.tsx +++ b/packages/local-explorer-ui/src/components/observability/InvocationLogs.tsx @@ -1,24 +1,9 @@ import { cn } from "@cloudflare/kumo"; import { useEffect, useState } from "react"; -import { fetchTraceLogs } from "../../utils/observability"; +import { fetchTraceLogs, formatLogMessage } from "../../utils/observability"; import type { Log } from "../../utils/observability"; import type { JSX } from "react"; -function previewMessage(message?: string | null): string { - if (!message) { - return ""; - } - try { - const parsed: unknown = JSON.parse(message); - if (typeof parsed === "string") { - return parsed; - } - return JSON.stringify(parsed); - } catch { - return message; - } -} - function levelClass(level?: string | null): string { switch (level) { case "error": @@ -93,7 +78,7 @@ export function InvocationLogs({ traceId }: { traceId: string }): JSX.Element { {log.level ?? "log"} - {previewMessage(log.message)} + {formatLogMessage(log.message ?? undefined)} ))} diff --git a/packages/local-explorer-ui/src/components/observability/QuerySyntaxHint.tsx b/packages/local-explorer-ui/src/components/observability/QuerySyntaxHint.tsx index b863eae381c..a0979b06194 100644 --- a/packages/local-explorer-ui/src/components/observability/QuerySyntaxHint.tsx +++ b/packages/local-explorer-ui/src/components/observability/QuerySyntaxHint.tsx @@ -51,6 +51,9 @@ export function QuerySyntaxHint({
  • db.query.text:orders — any attribute key
  • +
  • + trace: / span: — look up by id +
  • Bare words become free-text search.
  • ) : ( @@ -62,6 +65,9 @@ export function QuerySyntaxHint({
  • op:/checkout — filter by operation/route
  • +
  • + trace: / span: — look up by id +
  • Bare words search the message and service.
  • )} diff --git a/packages/local-explorer-ui/src/routes/observability/events.tsx b/packages/local-explorer-ui/src/routes/observability/events.tsx index 7dbdbd286c2..837a7e0aad6 100644 --- a/packages/local-explorer-ui/src/routes/observability/events.tsx +++ b/packages/local-explorer-ui/src/routes/observability/events.tsx @@ -10,9 +10,10 @@ import { CopyIcon, MagnifyingGlassIcon, PulseIcon, + TreeStructureIcon, XIcon, } from "@phosphor-icons/react"; -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, useRouter } from "@tanstack/react-router"; import { useCallback, useEffect, useMemo, useState } from "react"; import { ClearButton } from "../../components/observability/ClearButton"; import { FilterBuilder } from "../../components/observability/FilterBuilder"; @@ -23,6 +24,7 @@ import { ResourceError } from "../../components/ResourceError"; import { copyTextToClipboard } from "../../utils/agent-prompt"; import { clearTraces, + formatLogMessage, isObservabilityDisabledError, listEvents, } from "../../utils/observability"; @@ -66,17 +68,6 @@ function parseMessage(message: string | null): unknown { } } -function previewMessage(message: string | null): string { - const parsed = parseMessage(message); - if (parsed == null) { - return ""; - } - if (typeof parsed === "string") { - return parsed; - } - return JSON.stringify(parsed); -} - function levelClass(level: string | null): string { switch (level) { case "error": @@ -128,6 +119,8 @@ function EventsView(): JSX.Element { search: parsed.text, level: parsed.level ?? level, operation: parsed.operation, + traceId: parsed.traceId, + spanId: parsed.spanId, clauses: filterClauses, }) ); @@ -294,6 +287,7 @@ function EventsView(): JSX.Element { Level Message Service + @@ -328,6 +322,14 @@ function EventRow({ onToggle: () => void; }): JSX.Element { const toast = useKumoToastManager(); + const router = useRouter(); + // Jump to the Traces view and open the trace this event was emitted from. + const goToTrace = useCallback(() => { + void router.navigate({ + to: "/observability", + search: (prev) => ({ ...prev, trace: event.trace_id }), + }); + }, [router, event.trace_id]); const blob = useMemo(() => { const obj = { timestamp: event.created_at, @@ -379,15 +381,31 @@ function EventRow({ - {previewMessage(event.message)} + {formatLogMessage(event.message ?? undefined)} {event.service ?? "-"} + + {event.span_id ? ( + ) : null} diff --git a/packages/local-explorer-ui/src/routes/observability/index.tsx b/packages/local-explorer-ui/src/routes/observability/index.tsx index 069545bca0b..0b83487c82d 100644 --- a/packages/local-explorer-ui/src/routes/observability/index.tsx +++ b/packages/local-explorer-ui/src/routes/observability/index.tsx @@ -224,6 +224,8 @@ function ObservabilityView(): JSX.Element { search: parsed.text, status: parsed.status ?? status, kind: parsed.kind ?? kind, + traceId: parsed.traceId, + spanId: parsed.spanId, clauses: [...parsed.clauses, ...filterClauses], }) ); From ca16d4ef325ca6e1612a137a1718b8f68612e754 Mon Sep 17 00:00:00 2001 From: nickhilpat Date: Fri, 31 Jul 2026 03:37:17 -0500 Subject: [PATCH 3/9] [local-explorer-ui] deep-link events to the exact trace invocation and add a changeset The Events "View trace" button now carries the event's span_id, and the Traces view resolves it to the invocation row whose spans actually contain that span (findInvocationRoot), instead of always landing on the first row sharing the trace_id. Row element ids are keyed by (trace_id, root_span_id) so they stay unique across a multi-invocation trace. --- .../local-explorer-observability-ui-fixes.md | 8 +++ .../observability/observability.test.ts | 33 +++++++++ .../src/routes/observability/events.tsx | 11 ++- .../src/routes/observability/index.tsx | 67 +++++++++++++------ .../src/utils/observability.ts | 26 +++++++ 5 files changed, 121 insertions(+), 24 deletions(-) create mode 100644 .changeset/local-explorer-observability-ui-fixes.md diff --git a/.changeset/local-explorer-observability-ui-fixes.md b/.changeset/local-explorer-observability-ui-fixes.md new file mode 100644 index 00000000000..a433c8535f5 --- /dev/null +++ b/.changeset/local-explorer-observability-ui-fixes.md @@ -0,0 +1,8 @@ +--- +"@cloudflare/vite-plugin": patch +"wrangler": patch +--- + +Improve the Local Explorer's Observability views + +Console log messages now render the way the console would (JSON-encoded strings are unwrapped and multi-argument logs are joined), traces and events can be looked up by trace or span id from the search bar, and an event's "View trace" button jumps to the exact invocation that emitted it — even when a trace_id spans several invocations (e.g. a subrequest or self fetch). diff --git a/packages/local-explorer-ui/src/__tests__/observability/observability.test.ts b/packages/local-explorer-ui/src/__tests__/observability/observability.test.ts index 7880737a409..39efb81f58b 100644 --- a/packages/local-explorer-ui/src/__tests__/observability/observability.test.ts +++ b/packages/local-explorer-ui/src/__tests__/observability/observability.test.ts @@ -2,6 +2,7 @@ import { describe, test } from "vitest"; import { buildSpanTree, buildWaterfall, + findInvocationRoot, formatDuration, formatLogMessage, isRunning, @@ -572,3 +573,35 @@ describe("formatLogMessage", () => { expect(formatLogMessage("raw")).toBe("raw"); }); }); + +describe("findInvocationRoot", () => { + // Two invocations sharing one trace_id: roots A and X, each parent-less. + const spans = [ + span({ span_id: "A" }), + span({ span_id: "B", parent_id: "A" }), + span({ span_id: "C", parent_id: "B" }), + span({ span_id: "X" }), + span({ span_id: "Y", parent_id: "X" }), + ]; + + test("walks a nested span up to its invocation root", ({ expect }) => { + expect(findInvocationRoot(spans, "C")).toBe("A"); + expect(findInvocationRoot(spans, "Y")).toBe("X"); + }); + + test("returns the span itself when it is a root", ({ expect }) => { + expect(findInvocationRoot(spans, "X")).toBe("X"); + }); + + test("returns undefined for an unknown span", ({ expect }) => { + expect(findInvocationRoot(spans, "nope")).toBeUndefined(); + }); + + test("terminates on a cyclic parent link", ({ expect }) => { + const cyclic = [ + span({ span_id: "P", parent_id: "Q" }), + span({ span_id: "Q", parent_id: "P" }), + ]; + expect(findInvocationRoot(cyclic, "P")).toBe("P"); + }); +}); diff --git a/packages/local-explorer-ui/src/routes/observability/events.tsx b/packages/local-explorer-ui/src/routes/observability/events.tsx index 63af63248b6..4dfefd0d459 100644 --- a/packages/local-explorer-ui/src/routes/observability/events.tsx +++ b/packages/local-explorer-ui/src/routes/observability/events.tsx @@ -323,13 +323,18 @@ function EventRow({ }): JSX.Element { const toast = useKumoToastManager(); const router = useRouter(); - // Jump to the Traces view and open the trace this event was emitted from. + // Open this event's trace, carrying span_id to pick the right invocation + // row when a trace_id spans several. const goToTrace = useCallback(() => { void router.navigate({ to: "/observability", - search: (prev) => ({ ...prev, trace: event.trace_id }), + search: (prev) => ({ + ...prev, + trace: event.trace_id, + span: event.span_id ?? undefined, + }), }); - }, [router, event.trace_id]); + }, [router, event.trace_id, event.span_id]); const blob = useMemo(() => { const obj = { timestamp: event.created_at, diff --git a/packages/local-explorer-ui/src/routes/observability/index.tsx b/packages/local-explorer-ui/src/routes/observability/index.tsx index 0b83487c82d..70694ebab3f 100644 --- a/packages/local-explorer-ui/src/routes/observability/index.tsx +++ b/packages/local-explorer-ui/src/routes/observability/index.tsx @@ -34,6 +34,7 @@ import { ResourceError } from "../../components/ResourceError"; import { clearTraces, fetchTraceSpans, + findInvocationRoot, formatDuration, getInvocationRootIds, getTagKeys, @@ -128,11 +129,14 @@ function ObservabilityView(): JSX.Element { // render) so the effect can consult the latest cache on the list cadence. const spansByTraceRef = useRef(spansByTrace); spansByTraceRef.current = spansByTrace; - // Deep link from an event's "View trace" button (?trace=): open that - // trace's waterfall and scroll to it once its row is in the list. + // Deep link from an event's "View trace" button (?trace=&span=): open that + // trace's waterfall once its row lands. span picks the right invocation row. const deepLinkTrace = useRouterState({ select: (s) => (s.location.search as { trace?: string }).trace, }); + const deepLinkSpan = useRouterState({ + select: (s) => (s.location.search as { span?: string }).span, + }); const deepLinkAppliedRef = useRef(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -343,29 +347,50 @@ function ObservabilityView(): JSX.Element { if (!deepLinkTrace || deepLinkAppliedRef.current === deepLinkTrace) { return; } - const match = traces.find((t) => t.trace_id === deepLinkTrace); - if (!match) { + const matches = traces.filter((t) => t.trace_id === deepLinkTrace); + const first = matches[0]; + if (!first) { + // Row not in the list yet — a refresh will bring it in. return; } deepLinkAppliedRef.current = deepLinkTrace; - const key = traceKey(match); - setExpanded((prev) => { - if (prev.has(key)) { - return prev; + + // Expand a row's waterfall (loading spans if needed) and scroll to it. + const reveal = (row: TraceRow): void => { + const key = traceKey(row); + setExpanded((prev) => { + if (prev.has(key)) { + return prev; + } + const next = new Set(prev); + next.add(key); + return next; + }); + if (!spansByTraceRef.current[key]) { + void loadSpans(row); } - const next = new Set(prev); - next.add(key); - return next; - }); - if (!spansByTraceRef.current[key]) { - void loadSpans(match); + requestAnimationFrame(() => { + document + .getElementById(`trace-row-${key}`) + ?.scrollIntoView({ block: "center" }); + }); + }; + + // One invocation, or no span to disambiguate. + if (matches.length === 1 || !deepLinkSpan) { + reveal(first); + return; } - requestAnimationFrame(() => { - document - .getElementById(`trace-row-${deepLinkTrace}`) - ?.scrollIntoView({ block: "center" }); - }); - }, [deepLinkTrace, traces, loadSpans]); + + // Several invocations share this trace_id — reveal the one whose spans + // contain the linked span, falling back to the first row. + void fetchTraceSpans(deepLinkTrace) + .then((spans) => { + const root = findInvocationRoot(spans, deepLinkSpan); + reveal(matches.find((m) => m.root_span_id === root) ?? first); + }) + .catch(() => reveal(first)); + }, [deepLinkTrace, deepLinkSpan, traces, loadSpans]); const maxDuration = useMemo( () => Math.max(1, ...traces.map((t) => t.duration_ms ?? 0)), @@ -598,7 +623,7 @@ function ObservabilityView(): JSX.Element { return ( void toggleTrace(t)} className={cn( "cursor-pointer border-b border-kumo-fill hover:bg-black/[0.03] dark:hover:bg-white/5", diff --git a/packages/local-explorer-ui/src/utils/observability.ts b/packages/local-explorer-ui/src/utils/observability.ts index 69595a375e2..e5b7eb3db79 100644 --- a/packages/local-explorer-ui/src/utils/observability.ts +++ b/packages/local-explorer-ui/src/utils/observability.ts @@ -600,6 +600,32 @@ export async function getInvocationRootIds(traceId: string): Promise { return rows.map((r) => String(r.span_id)).filter(Boolean); } +/** + * Walks up `parent_id` to the parent-less root `spanId` descends from. A + * trace_id with multiple invocations (e.g. a self fetch) has several such + * roots — one per Traces-view row — so this maps an event's span to its row. + */ +export function findInvocationRoot( + spans: Span[], + spanId: string +): string | undefined { + const byId = new Map(spans.map((s) => [s.span_id, s])); + let current = byId.get(spanId); + if (!current) { + return undefined; + } + const seen = new Set(); + while (current.parent_id && !seen.has(current.span_id)) { + seen.add(current.span_id); + const parent = byId.get(current.parent_id); + if (!parent) { + break; + } + current = parent; + } + return current.span_id; +} + /** A persisted console.log event (the "Logs" view). */ export interface LogEvent { trace_id: string; From c33c67c5fefa230f4bbc53270189cee5a7300376 Mon Sep 17 00:00:00 2001 From: nickhilpat Date: Fri, 31 Jul 2026 11:59:08 -0500 Subject: [PATCH 4/9] [local-explorer-ui] address review: minor bump, empty-log placeholder, parser edge-case tests - Changeset is a minor (adds id search + View trace), not a patch; backtick console.log. - Render a muted "(no message)" placeholder for empty logs to avoid layout shift (Events + InvocationLogs). - Add trace:/span: parser edge cases: valueless tokens fall back to free text, id aliases, and id-lookup alongside a clause. - Summarise the deep-link effect with a one-line comment. --- .../local-explorer-observability-ui-fixes.md | 6 ++--- .../observability/query-clauses.test.ts | 25 +++++++++++++++++++ .../observability/InvocationLogs.tsx | 4 ++- .../src/routes/observability/events.tsx | 4 ++- .../src/routes/observability/index.tsx | 2 ++ 5 files changed, 36 insertions(+), 5 deletions(-) diff --git a/.changeset/local-explorer-observability-ui-fixes.md b/.changeset/local-explorer-observability-ui-fixes.md index a433c8535f5..70cf4b2e004 100644 --- a/.changeset/local-explorer-observability-ui-fixes.md +++ b/.changeset/local-explorer-observability-ui-fixes.md @@ -1,8 +1,8 @@ --- -"@cloudflare/vite-plugin": patch -"wrangler": patch +"@cloudflare/vite-plugin": minor +"wrangler": minor --- Improve the Local Explorer's Observability views -Console log messages now render the way the console would (JSON-encoded strings are unwrapped and multi-argument logs are joined), traces and events can be looked up by trace or span id from the search bar, and an event's "View trace" button jumps to the exact invocation that emitted it — even when a trace_id spans several invocations (e.g. a subrequest or self fetch). +`console.log` messages now render the way the console would (JSON-encoded strings are unwrapped and multi-argument logs are joined), traces and events can be looked up by trace or span id from the search bar, and an event's "View trace" button jumps to the exact invocation that emitted it — even when a trace_id spans several invocations (e.g. a subrequest or self fetch). diff --git a/packages/local-explorer-ui/src/__tests__/observability/query-clauses.test.ts b/packages/local-explorer-ui/src/__tests__/observability/query-clauses.test.ts index 7d81634d5e1..17b24c0317b 100644 --- a/packages/local-explorer-ui/src/__tests__/observability/query-clauses.test.ts +++ b/packages/local-explorer-ui/src/__tests__/observability/query-clauses.test.ts @@ -77,6 +77,31 @@ describe("parseTraceQuery clauses", () => { expect(parsed.spanId).toBe("def456"); expect(parsed.clauses).toEqual([]); }); + + test("valueless trace:/span: fall back to free text", ({ expect }) => { + const parsed = parseTraceQuery("trace: span:"); + expect(parsed.traceId).toBeUndefined(); + expect(parsed.spanId).toBeUndefined(); + expect(parsed.text).toBe("trace: span:"); + expect(parsed.clauses).toEqual([]); + }); + + test("traceid/trace_id/spanid/span_id aliases map to the id fields", ({ + expect, + }) => { + expect(parseTraceQuery("traceid:abc").traceId).toBe("abc"); + expect(parseTraceQuery("trace_id:abc").traceId).toBe("abc"); + expect(parseTraceQuery("spanid:def").spanId).toBe("def"); + expect(parseTraceQuery("span_id:def").spanId).toBe("def"); + }); + + test("an id lookup alongside a real clause keeps both", ({ expect }) => { + const parsed = parseTraceQuery("trace:abc123 db.query.text:orders"); + expect(parsed.traceId).toBe("abc123"); + expect(parsed.clauses).toEqual([ + { field: "db.query.text", op: "~", value: "orders" }, + ]); + }); }); describe("durationClauseSql", () => { diff --git a/packages/local-explorer-ui/src/components/observability/InvocationLogs.tsx b/packages/local-explorer-ui/src/components/observability/InvocationLogs.tsx index c04f35aeb64..7d3b836a743 100644 --- a/packages/local-explorer-ui/src/components/observability/InvocationLogs.tsx +++ b/packages/local-explorer-ui/src/components/observability/InvocationLogs.tsx @@ -78,7 +78,9 @@ export function InvocationLogs({ traceId }: { traceId: string }): JSX.Element { {log.level ?? "log"} - {formatLogMessage(log.message ?? undefined)} + {formatLogMessage(log.message ?? undefined) || ( + (no message) + )} ))} diff --git a/packages/local-explorer-ui/src/routes/observability/events.tsx b/packages/local-explorer-ui/src/routes/observability/events.tsx index 4dfefd0d459..abbbd32092b 100644 --- a/packages/local-explorer-ui/src/routes/observability/events.tsx +++ b/packages/local-explorer-ui/src/routes/observability/events.tsx @@ -386,7 +386,9 @@ function EventRow({ - {formatLogMessage(event.message ?? undefined)} + {formatLogMessage(event.message ?? undefined) || ( + (no message) + )} {event.service ?? "-"} diff --git a/packages/local-explorer-ui/src/routes/observability/index.tsx b/packages/local-explorer-ui/src/routes/observability/index.tsx index 70694ebab3f..739f0e6f769 100644 --- a/packages/local-explorer-ui/src/routes/observability/index.tsx +++ b/packages/local-explorer-ui/src/routes/observability/index.tsx @@ -343,6 +343,8 @@ function ObservabilityView(): JSX.Element { } }, [traces, expanded, loadSpans]); + // Apply the ?trace=&span= deep link once: expand and scroll to the right + // invocation row when it lands in the list. useEffect(() => { if (!deepLinkTrace || deepLinkAppliedRef.current === deepLinkTrace) { return; From c09c00f72d5693699225488098f2c193b0da194f Mon Sep 17 00:00:00 2001 From: nickhilpat Date: Fri, 31 Jul 2026 12:15:31 -0500 Subject: [PATCH 5/9] [local-explorer-ui] make View trace a real router link and scope its search params Switch the Events "View trace" button to a router-aware link (createLink around Kumo's LinkButton) so it supports open-in-new-tab / copy-link, while the router keeps typed search params. Added a validateSearch to the /observability route (worker/trace/span) so the link and deep-link reads are typed (drops the casts). The link passes an explicit search object (worker + trace + span) instead of spreading the Events view's params, and the onClick now only stopPropagation on the clickable row. --- .../src/routes/observability/events.tsx | 42 ++++++++++--------- .../src/routes/observability/index.tsx | 16 +++---- 2 files changed, 31 insertions(+), 27 deletions(-) diff --git a/packages/local-explorer-ui/src/routes/observability/events.tsx b/packages/local-explorer-ui/src/routes/observability/events.tsx index abbbd32092b..4a8489e21fc 100644 --- a/packages/local-explorer-ui/src/routes/observability/events.tsx +++ b/packages/local-explorer-ui/src/routes/observability/events.tsx @@ -2,6 +2,7 @@ import { Button, cn, InputGroup, + LinkButton, RefreshButton, Select, useKumoToastManager, @@ -13,7 +14,7 @@ import { TreeStructureIcon, XIcon, } from "@phosphor-icons/react"; -import { createFileRoute, useRouter } from "@tanstack/react-router"; +import { createFileRoute, createLink, useSearch } from "@tanstack/react-router"; import { useCallback, useEffect, useMemo, useState } from "react"; import { ClearButton } from "../../components/observability/ClearButton"; import { FilterBuilder } from "../../components/observability/FilterBuilder"; @@ -57,6 +58,10 @@ export const Route = createFileRoute("/observability/events")({ errorComponent: ResourceError, }); +// A real anchor (new-tab / copy-link) that navigates via the router, so we keep +// typed search params instead of building an href by hand. +const TraceLinkButton = createLink(LinkButton); + function parseMessage(message: string | null): unknown { if (!message) { return null; @@ -322,19 +327,12 @@ function EventRow({ onToggle: () => void; }): JSX.Element { const toast = useKumoToastManager(); - const router = useRouter(); - // Open this event's trace, carrying span_id to pick the right invocation - // row when a trace_id spans several. - const goToTrace = useCallback(() => { - void router.navigate({ - to: "/observability", - search: (prev) => ({ - ...prev, - trace: event.trace_id, - span: event.span_id ?? undefined, - }), - }); - }, [router, event.trace_id, event.span_id]); + // Keep the selected worker when jumping to the Traces view, but don't carry + // over the rest of the Events view's search state. + const worker = useSearch({ + strict: false, + select: (s) => (s as { worker?: string }).worker, + }); const blob = useMemo(() => { const obj = { timestamp: event.created_at, @@ -395,18 +393,22 @@ function EventRow({ {event.span_id ? ( - + ) : null} diff --git a/packages/local-explorer-ui/src/routes/observability/index.tsx b/packages/local-explorer-ui/src/routes/observability/index.tsx index 739f0e6f769..8f70e2286a3 100644 --- a/packages/local-explorer-ui/src/routes/observability/index.tsx +++ b/packages/local-explorer-ui/src/routes/observability/index.tsx @@ -14,7 +14,7 @@ import { PulseIcon, XIcon, } from "@phosphor-icons/react"; -import { createFileRoute, useRouterState } from "@tanstack/react-router"; +import { createFileRoute } from "@tanstack/react-router"; import { Fragment, useCallback, @@ -72,6 +72,13 @@ const KIND_LABELS: Record = { export const Route = createFileRoute("/observability/")({ component: ObservabilityView, errorComponent: ResourceError, + validateSearch: ( + search: Record + ): { worker?: string; trace?: string; span?: string } => ({ + worker: typeof search.worker === "string" ? search.worker : undefined, + trace: typeof search.trace === "string" ? search.trace : undefined, + span: typeof search.span === "string" ? search.span : undefined, + }), }); function isError(t: TraceRow): boolean { @@ -131,12 +138,7 @@ function ObservabilityView(): JSX.Element { spansByTraceRef.current = spansByTrace; // Deep link from an event's "View trace" button (?trace=&span=): open that // trace's waterfall once its row lands. span picks the right invocation row. - const deepLinkTrace = useRouterState({ - select: (s) => (s.location.search as { trace?: string }).trace, - }); - const deepLinkSpan = useRouterState({ - select: (s) => (s.location.search as { span?: string }).span, - }); + const { trace: deepLinkTrace, span: deepLinkSpan } = Route.useSearch(); const deepLinkAppliedRef = useRef(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); From 7db34cc5b109c9844ef47efa94c9df6ad309a48f Mon Sep 17 00:00:00 2001 From: nickhilpat Date: Fri, 31 Jul 2026 12:19:47 -0500 Subject: [PATCH 6/9] [local-explorer-ui] seed the Traces query from the ?trace= deep link The reveal effect only worked if the trace was already in the default trace-list window (100 rows, no id filter), but events go 200 deep, so a deep-linked trace could sit permanently outside that window and the View trace button would silently do nothing. Seed the search with `trace:` when the deep link is present so the specific row is fetched via the traceId filter; if it still doesn't come back the trace is gone and the existing empty state (with the visible trace: filter) explains that. --- .../src/routes/observability/index.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/local-explorer-ui/src/routes/observability/index.tsx b/packages/local-explorer-ui/src/routes/observability/index.tsx index 8f70e2286a3..173dbc2543e 100644 --- a/packages/local-explorer-ui/src/routes/observability/index.tsx +++ b/packages/local-explorer-ui/src/routes/observability/index.tsx @@ -140,6 +140,7 @@ function ObservabilityView(): JSX.Element { // trace's waterfall once its row lands. span picks the right invocation row. const { trace: deepLinkTrace, span: deepLinkSpan } = Route.useSearch(); const deepLinkAppliedRef = useRef(null); + const deepLinkSeededRef = useRef(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); // Capture is off (no collector bound). We show an "off" panel instead of the @@ -345,8 +346,21 @@ function ObservabilityView(): JSX.Element { } }, [traces, expanded, loadSpans]); + // A deep-linked trace can be older than the default trace-list window, so + // seed the search with `trace:` to fetch that specific row. If it still + // doesn't come back, the trace is gone and the empty state explains that. + useEffect(() => { + if (!deepLinkTrace || deepLinkSeededRef.current === deepLinkTrace) { + return; + } + deepLinkSeededRef.current = deepLinkTrace; + const query = `trace:${deepLinkTrace}`; + setSearch(query); + setAppliedSearch(query); + }, [deepLinkTrace]); + // Apply the ?trace=&span= deep link once: expand and scroll to the right - // invocation row when it lands in the list. + // invocation row when the seeded query above brings it into the list. useEffect(() => { if (!deepLinkTrace || deepLinkAppliedRef.current === deepLinkTrace) { return; @@ -354,7 +368,7 @@ function ObservabilityView(): JSX.Element { const matches = traces.filter((t) => t.trace_id === deepLinkTrace); const first = matches[0]; if (!first) { - // Row not in the list yet — a refresh will bring it in. + // Seeded query hasn't returned the row yet (or the trace is gone). return; } deepLinkAppliedRef.current = deepLinkTrace; From af3565702927fdf06335ada70a618feccd82d6e8 Mon Sep 17 00:00:00 2001 From: nickhilpat Date: Fri, 31 Jul 2026 12:27:34 -0500 Subject: [PATCH 7/9] [local-explorer-ui] match ids by prefix (not substring) in free-text search Free-text search matched trace/span ids with %q% (substring anywhere), so on hex ids any short term like "de" matched almost every record and effectively disabled filtering. Match ids by prefix (q%), consistent with the trace:/span: filters, while keeping substring matching for names, messages, service, and attributes. --- .../__tests__/observability/id-search.test.ts | 23 +++++++++++++++++++ .../src/utils/observability.ts | 10 ++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/local-explorer-ui/src/__tests__/observability/id-search.test.ts b/packages/local-explorer-ui/src/__tests__/observability/id-search.test.ts index 1942f68c795..ea909928735 100644 --- a/packages/local-explorer-ui/src/__tests__/observability/id-search.test.ts +++ b/packages/local-explorer-ui/src/__tests__/observability/id-search.test.ts @@ -34,6 +34,18 @@ describe("listTraces id search", () => { expect(sql).toContain("SELECT trace_id FROM spans WHERE span_id LIKE ?"); expect(params).toContain("def456%"); }); + + test("free-text matches ids by prefix, names/attrs by substring", async ({ + expect, + }) => { + await listTraces({ search: "de" }); + const { params } = lastQuery(); + // ids: prefix only (no leading %), so a short hex term doesn't match all. + expect(params).toContain("de%"); + expect(params).not.toContain("%de%de%"); + // names/attributes: substring. + expect(params).toContain("%de%"); + }); }); describe("listEvents id search", () => { @@ -50,4 +62,15 @@ describe("listEvents id search", () => { expect(sql).toContain("l.span_id LIKE ?"); expect(params).toContain("def456%"); }); + + test("free-text matches ids by prefix, message/service by substring", async ({ + expect, + }) => { + await listEvents({ search: "de" }); + const { params } = lastQuery(); + // ids: prefix only. + expect(params).toContain("de%"); + // message/operation/service: substring. + expect(params).toContain("%de%"); + }); }); diff --git a/packages/local-explorer-ui/src/utils/observability.ts b/packages/local-explorer-ui/src/utils/observability.ts index e5b7eb3db79..38e2abba3b7 100644 --- a/packages/local-explorer-ui/src/utils/observability.ts +++ b/packages/local-explorer-ui/src/utils/observability.ts @@ -559,10 +559,13 @@ export function listTraces(filters: TraceFilters = {}): Promise { const q = filters.search?.trim(); if (q) { const like = `%${q}%`; + // ids match as a prefix (like the trace:/span: filters) — a substring + // match on hex ids means any short term matches almost everything. + const idPrefix = `${q}%`; where.push( "(s.name LIKE ? OR s.trace_id LIKE ? OR s.trace_id IN (SELECT trace_id FROM spans WHERE name LIKE ? OR span_id LIKE ? OR json(attributes) LIKE ?))" ); - params.push(like, like, like, like, like); + params.push(like, idPrefix, like, idPrefix, like); } params.push(limit); @@ -682,10 +685,13 @@ export function listEvents(filters: EventFilters = {}): Promise { const q = filters.search?.trim(); if (q) { const like = `%${q}%`; + // ids match as a prefix (like the trace:/span: filters) — a substring + // match on hex ids means any short term matches almost everything. + const idPrefix = `${q}%`; where.push( "(l.message LIKE ? OR l.operation LIKE ? OR sp.service LIKE ? OR l.trace_id LIKE ? OR l.span_id LIKE ?)" ); - params.push(like, like, like, like, like); + params.push(like, like, like, idPrefix, idPrefix); } // Structured clauses from the filter modal. Fields map to a concrete log From bbdcf04d8184091102475ccec384e04e7d7e24cd Mon Sep 17 00:00:00 2001 From: nickhilpat Date: Fri, 31 Jul 2026 12:36:16 -0500 Subject: [PATCH 8/9] [local-explorer-ui] make the trace deep link one-shot by stripping it from the URL The ?trace=&span= params were consumed but left in the URL. Because the view switcher preserves search params and the Traces route remounts on each visit (resetting the applied/seeded refs), the stale trace param re-seeded the trace: query and re-expanded the row on every return, hiding other traces even after the user cleared the search. Capture the deep link once at mount and strip trace/span from the URL (replace navigation, keeping worker), so it only applies once. --- .../src/routes/observability/index.tsx | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/local-explorer-ui/src/routes/observability/index.tsx b/packages/local-explorer-ui/src/routes/observability/index.tsx index 173dbc2543e..7b275672ddd 100644 --- a/packages/local-explorer-ui/src/routes/observability/index.tsx +++ b/packages/local-explorer-ui/src/routes/observability/index.tsx @@ -138,7 +138,14 @@ function ObservabilityView(): JSX.Element { spansByTraceRef.current = spansByTrace; // Deep link from an event's "View trace" button (?trace=&span=): open that // trace's waterfall once its row lands. span picks the right invocation row. - const { trace: deepLinkTrace, span: deepLinkSpan } = Route.useSearch(); + // Capture it at mount and strip it from the URL below, so returning to this + // view later (the switcher preserves search params) doesn't re-apply it. + const routeSearch = Route.useSearch(); + const navigate = Route.useNavigate(); + const deepLinkRef = useRef<{ trace?: string; span?: string } | null>(null); + deepLinkRef.current ??= { trace: routeSearch.trace, span: routeSearch.span }; + const deepLinkTrace = deepLinkRef.current.trace; + const deepLinkSpan = deepLinkRef.current.span; const deepLinkAppliedRef = useRef(null); const deepLinkSeededRef = useRef(null); const [loading, setLoading] = useState(false); @@ -346,6 +353,18 @@ function ObservabilityView(): JSX.Element { } }, [traces, expanded, loadSpans]); + // Strip the deep-link params from the URL once we've captured them, so they + // don't linger and re-seed the query on every later return to this view. + useEffect(() => { + if (routeSearch.trace === undefined && routeSearch.span === undefined) { + return; + } + void navigate({ + search: (prev) => ({ ...prev, trace: undefined, span: undefined }), + replace: true, + }); + }, [navigate, routeSearch.trace, routeSearch.span]); + // A deep-linked trace can be older than the default trace-list window, so // seed the search with `trace:` to fetch that specific row. If it still // doesn't come back, the trace is gone and the empty state explains that. From 73b824f8c0dfd40ad4b6a50f0e66ecafc3f97513 Mon Sep 17 00:00:00 2001 From: nickhilpat Date: Fri, 31 Jul 2026 14:31:16 -0500 Subject: [PATCH 9/9] [local-explorer-ui] Drop redundant view-switcher dropdown from Observability page titles The sidebar already switches between the Traces and Events views, so the duplicate dropdown in each page title added no value. Replace it with a plain title and remove the now-unused ObservabilityViewSwitcher component. --- .../ObservabilityViewSwitcher.tsx | 61 ------------------- .../src/routes/observability/events.tsx | 5 +- .../src/routes/observability/index.tsx | 5 +- 3 files changed, 6 insertions(+), 65 deletions(-) delete mode 100644 packages/local-explorer-ui/src/components/observability/ObservabilityViewSwitcher.tsx diff --git a/packages/local-explorer-ui/src/components/observability/ObservabilityViewSwitcher.tsx b/packages/local-explorer-ui/src/components/observability/ObservabilityViewSwitcher.tsx deleted file mode 100644 index 948b3a07ff0..00000000000 --- a/packages/local-explorer-ui/src/components/observability/ObservabilityViewSwitcher.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { DropdownMenu } from "@cloudflare/kumo"; -import { CaretDownIcon, CheckIcon } from "@phosphor-icons/react"; -import { useRouter } from "@tanstack/react-router"; -import type { JSX } from "react"; - -type ObservabilityView = "traces" | "events"; - -const LABELS: Record = { - traces: "Traces", - events: "Events", -}; - -const ROUTES: Record = { - traces: "/observability", - events: "/observability/events", -}; - -/** - * Title dropdown that switches between the Observability views (Traces and - * Events), both backed by the read-only `/query` endpoint. Provided alongside - * the sidebar entries so the current view is switchable from the header too. - */ -export function ObservabilityViewSwitcher({ - current, -}: { - current: ObservabilityView; -}): JSX.Element { - const router = useRouter(); - - const go = (view: ObservabilityView): void => { - // Preserve the current search (e.g. the selected `worker`) when switching. - void router.navigate({ to: ROUTES[view], search: (prev) => prev }); - }; - - return ( - - - } - > - {LABELS[current]} - - - - {(Object.keys(LABELS) as ObservabilityView[]).map((view) => ( - go(view)} - > - {LABELS[view]} - - ))} - - - ); -} diff --git a/packages/local-explorer-ui/src/routes/observability/events.tsx b/packages/local-explorer-ui/src/routes/observability/events.tsx index 4a8489e21fc..7f90b9a92ae 100644 --- a/packages/local-explorer-ui/src/routes/observability/events.tsx +++ b/packages/local-explorer-ui/src/routes/observability/events.tsx @@ -19,7 +19,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { ClearButton } from "../../components/observability/ClearButton"; import { FilterBuilder } from "../../components/observability/FilterBuilder"; import { ObservabilityDisabled } from "../../components/observability/ObservabilityDisabled"; -import { ObservabilityViewSwitcher } from "../../components/observability/ObservabilityViewSwitcher"; import { QuerySyntaxHint } from "../../components/observability/QuerySyntaxHint"; import { ResourceError } from "../../components/ResourceError"; import { copyTextToClipboard } from "../../utils/agent-prompt"; @@ -181,7 +180,9 @@ function EventsView(): JSX.Element {
    - + + Events + {events.length} event{events.length === 1 ? "" : "s"} diff --git a/packages/local-explorer-ui/src/routes/observability/index.tsx b/packages/local-explorer-ui/src/routes/observability/index.tsx index 7b275672ddd..4fa112ddcb2 100644 --- a/packages/local-explorer-ui/src/routes/observability/index.tsx +++ b/packages/local-explorer-ui/src/routes/observability/index.tsx @@ -27,7 +27,6 @@ import { ClearButton } from "../../components/observability/ClearButton"; import { FilterBuilder } from "../../components/observability/FilterBuilder"; import { InvocationLogs } from "../../components/observability/InvocationLogs"; import { ObservabilityDisabled } from "../../components/observability/ObservabilityDisabled"; -import { ObservabilityViewSwitcher } from "../../components/observability/ObservabilityViewSwitcher"; import { QuerySyntaxHint } from "../../components/observability/QuerySyntaxHint"; import { TraceWaterfall } from "../../components/observability/TraceWaterfall"; import { ResourceError } from "../../components/ResourceError"; @@ -453,7 +452,9 @@ function ObservabilityView(): JSX.Element {
    - + + Traces + {traces.length} trace{traces.length === 1 ? "" : "s"}