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 ? ( - + = { 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 { @@ -128,6 +135,18 @@ 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=&span=): open that + // trace's waterfall once its row lands. span picks the right invocation row. + // 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); const [error, setError] = useState(null); // Capture is off (no collector bound). We show an "off" panel instead of the @@ -218,6 +237,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], }) ); @@ -331,6 +352,82 @@ 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. + 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 the seeded query above brings it into the list. + useEffect(() => { + if (!deepLinkTrace || deepLinkAppliedRef.current === deepLinkTrace) { + return; + } + const matches = traces.filter((t) => t.trace_id === deepLinkTrace); + const first = matches[0]; + if (!first) { + // Seeded query hasn't returned the row yet (or the trace is gone). + return; + } + deepLinkAppliedRef.current = deepLinkTrace; + + // 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); + } + requestAnimationFrame(() => { + document + .getElementById(`trace-row-${key}`) + ?.scrollIntoView({ block: "center" }); + }); + }; + + // One invocation, or no span to disambiguate. + if (matches.length === 1 || !deepLinkSpan) { + reveal(first); + return; + } + + // 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)), [traces] @@ -355,7 +452,9 @@ function ObservabilityView(): JSX.Element { - + + Traces + {traces.length} trace{traces.length === 1 ? "" : "s"} @@ -562,6 +661,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-query.ts b/packages/local-explorer-ui/src/utils/observability-query.ts index d10235ab171..3a4f854cfe9 100644 --- a/packages/local-explorer-ui/src/utils/observability-query.ts +++ b/packages/local-explorer-ui/src/utils/observability-query.ts @@ -4,6 +4,7 @@ * Supported (AND-only): * status:error|success kind:d1|http|fetch|kv|r2|do * dur:>100 dur:<=50 : (e.g. db.query.text:orders) + * trace: span: (look up by id) * level:error op:/checkout (Logs) * Any bare words (or quoted "phrases") become free-text search. */ @@ -140,6 +141,8 @@ export interface ParsedTraceQuery { text: string; status?: "success" | "error"; kind?: string; + traceId?: string; + spanId?: string; clauses: QueryClause[]; } @@ -167,6 +170,16 @@ export function parseTraceQuery(input: string): ParsedTraceQuery { case "type": result.kind = kv.value.toLowerCase(); break; + case "trace": + case "traceid": + case "trace_id": + result.traceId = kv.value; + break; + case "span": + case "spanid": + case "span_id": + result.spanId = kv.value; + break; case "dur": case "duration": { const { op, value } = splitComparator(kv.value); @@ -187,6 +200,8 @@ export interface ParsedEventQuery { text: string; level?: string; operation?: string; + traceId?: string; + spanId?: string; } export function parseEventQuery(input: string): ParsedEventQuery { @@ -207,6 +222,18 @@ export function parseEventQuery(input: string): ParsedEventQuery { kv.key === "route" ) { result.operation = kv.value; + } else if ( + kv.key === "trace" || + kv.key === "traceid" || + kv.key === "trace_id" + ) { + result.traceId = kv.value; + } else if ( + kv.key === "span" || + kv.key === "spanid" || + kv.key === "span_id" + ) { + result.spanId = kv.value; } else { free.push(token); } diff --git a/packages/local-explorer-ui/src/utils/observability.ts b/packages/local-explorer-ui/src/utils/observability.ts index d39ce2703a2..38e2abba3b7 100644 --- a/packages/local-explorer-ui/src/utils/observability.ts +++ b/packages/local-explorer-ui/src/utils/observability.ts @@ -268,14 +268,30 @@ export function spanIsError( return Number.isFinite(code) && code >= 400; } +/** + * console.log is captured as a JSON-encoded array of its arguments, so render + * it the way the console would — strings verbatim, everything else as JSON, + * space-joined — rather than dumping the raw array. + */ +function formatLogValue(value: unknown): string { + if (typeof value === "string") { + return value; + } + if (Array.isArray(value)) { + return value + .map((arg) => (typeof arg === "string" ? arg : JSON.stringify(arg))) + .join(" "); + } + return JSON.stringify(value); +} + /** Parse a JSON-encoded log message back to a display string. */ export function formatLogMessage(message?: string): string { if (message === undefined) { return ""; } try { - const value = JSON.parse(message); - return typeof value === "string" ? value : JSON.stringify(value); + return formatLogValue(JSON.parse(message)); } catch { return message; } @@ -312,8 +328,12 @@ export interface TraceRow { /** Filters for the trace list — a simpler version of the dashboard query builder. */ export interface TraceFilters { - /** free-text: matches operation name, any span name, or any span attribute. */ + /** free-text: matches operation name, any span name/id, trace id, or attribute. */ search?: string; + /** exact/prefix match on the trace id. */ + traceId?: string; + /** match traces containing a span with this id (prefix). */ + spanId?: string; /** "all" | "success" | "error" */ status?: "all" | "success" | "error"; /** "all" or a span kind: http | fetch | d1 | kv | r2 | do */ @@ -494,6 +514,17 @@ export function listTraces(filters: TraceFilters = {}): Promise { params.push(filters.kind); } + if (filters.traceId) { + where.push("s.trace_id LIKE ?"); + params.push(`${filters.traceId}%`); + } + if (filters.spanId) { + where.push( + "s.trace_id IN (SELECT trace_id FROM spans WHERE span_id LIKE ?)" + ); + params.push(`${filters.spanId}%`); + } + if (filters.tagKey && filters.tagKey !== "all") { const v = filters.tagValue?.trim(); if (v) { @@ -528,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 IN (SELECT trace_id FROM spans WHERE name LIKE ? OR json(attributes) LIKE ?))" + "(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); + params.push(like, idPrefix, like, idPrefix, like); } params.push(limit); @@ -569,6 +603,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; @@ -586,6 +646,10 @@ export interface LogEvent { export interface EventFilters { search?: string; + /** prefix match on the event's trace id. */ + traceId?: string; + /** prefix match on the emitting span id. */ + spanId?: string; /** "all" | debug | info | log | warn | error */ level?: string; /** substring match on the emitting operation/route. */ @@ -610,11 +674,24 @@ export function listEvents(filters: EventFilters = {}): Promise { where.push("l.operation LIKE ?"); params.push(`%${op}%`); } + if (filters.traceId) { + where.push("l.trace_id LIKE ?"); + params.push(`${filters.traceId}%`); + } + if (filters.spanId) { + where.push("l.span_id LIKE ?"); + params.push(`${filters.spanId}%`); + } const q = filters.search?.trim(); if (q) { const like = `%${q}%`; - where.push("(l.message LIKE ? OR l.operation LIKE ? OR sp.service LIKE ?)"); - params.push(like, like, like); + // 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, idPrefix, idPrefix); } // Structured clauses from the filter modal. Fields map to a concrete log
db.query.text:orders
trace:
span:
op:/checkout