diff --git a/.changeset/local-explorer-observability-ui-fixes.md b/.changeset/local-explorer-observability-ui-fixes.md new file mode 100644 index 00000000000..70cf4b2e004 --- /dev/null +++ b/.changeset/local-explorer-observability-ui-fixes.md @@ -0,0 +1,8 @@ +--- +"@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). 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 new file mode 100644 index 00000000000..ea909928735 --- /dev/null +++ b/packages/local-explorer-ui/src/__tests__/observability/id-search.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, test, vi } from "vitest"; + +// Capture the SQL/params sent to the read-only query endpoint so we can assert +// that the id-search filters produce the expected predicates. +const { post } = vi.hoisted(() => ({ post: vi.fn() })); + +vi.mock("../../api", () => ({ + observabilityQuery: (opts: { body: { sql: string; params: unknown[] } }) => { + post(opts.body); + return Promise.resolve({ data: { result: { columns: [], rows: [] } } }); + }, + observabilityClear: () => Promise.resolve({}), +})); + +const { listEvents, listTraces } = await import("../../utils/observability"); + +function lastQuery(): { sql: string; params: unknown[] } { + return post.mock.calls.at(-1)?.[0] as { sql: string; params: unknown[] }; +} + +beforeEach(() => post.mockClear()); + +describe("listTraces id search", () => { + test("traceId matches the trace by id prefix", async ({ expect }) => { + await listTraces({ traceId: "abc123" }); + const { sql, params } = lastQuery(); + expect(sql).toContain("s.trace_id LIKE ?"); + expect(params).toContain("abc123%"); + }); + + test("spanId matches traces containing the span", async ({ expect }) => { + await listTraces({ spanId: "def456" }); + const { sql, params } = lastQuery(); + 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", () => { + test("traceId matches the event's trace by id prefix", async ({ expect }) => { + await listEvents({ traceId: "abc123" }); + const { sql, params } = lastQuery(); + expect(sql).toContain("l.trace_id LIKE ?"); + expect(params).toContain("abc123%"); + }); + + test("spanId matches the emitting span by id prefix", async ({ expect }) => { + await listEvents({ spanId: "def456" }); + const { sql, params } = lastQuery(); + 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/__tests__/observability/observability.test.ts b/packages/local-explorer-ui/src/__tests__/observability/observability.test.ts index 8a1834eb7e5..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, @@ -563,7 +564,44 @@ 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"); }); }); + +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/__tests__/observability/query-clauses.test.ts b/packages/local-explorer-ui/src/__tests__/observability/query-clauses.test.ts index 362a1cc7e7e..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 @@ -68,6 +68,40 @@ 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([]); + }); + + 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 0b066bfa04d..7d3b836a743 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,9 @@ export function InvocationLogs({ traceId }: { traceId: string }): JSX.Element { {log.level ?? "log"} - {previewMessage(log.message)} + {formatLogMessage(log.message ?? undefined) || ( + (no message) + )} ))} 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/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..7f90b9a92ae 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, @@ -10,19 +11,20 @@ import { CopyIcon, MagnifyingGlassIcon, PulseIcon, + TreeStructureIcon, XIcon, } from "@phosphor-icons/react"; -import { createFileRoute } 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"; 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"; import { clearTraces, + formatLogMessage, isObservabilityDisabledError, listEvents, } from "../../utils/observability"; @@ -55,6 +57,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; @@ -66,17 +72,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 +123,8 @@ function EventsView(): JSX.Element { search: parsed.text, level: parsed.level ?? level, operation: parsed.operation, + traceId: parsed.traceId, + spanId: parsed.spanId, clauses: filterClauses, }) ); @@ -183,7 +180,9 @@ function EventsView(): JSX.Element {
    - + + Events + {events.length} event{events.length === 1 ? "" : "s"} @@ -294,6 +293,7 @@ function EventsView(): JSX.Element { Level Message Service + Trace @@ -328,6 +328,12 @@ function EventRow({ onToggle: () => void; }): JSX.Element { const toast = useKumoToastManager(); + // 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, @@ -379,15 +385,37 @@ function EventRow({ - {previewMessage(event.message)} + {formatLogMessage(event.message ?? undefined) || ( + (no message) + )} {event.service ?? "-"} + + {event.span_id ? ( + e.stopPropagation()} + > + View trace + + ) : null} + {isOpen ? ( - +