Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/local-explorer-observability-ui-fixes.md
Original file line number Diff line number Diff line change
@@ -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).
Original file line number Diff line number Diff line change
@@ -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%");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, test } from "vitest";
import {
buildSpanTree,
buildWaterfall,
findInvocationRoot,
formatDuration,
formatLogMessage,
isRunning,
Expand Down Expand Up @@ -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");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
Comment thread
NuroDev marked this conversation as resolved.

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", () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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":
Expand Down Expand Up @@ -93,7 +78,9 @@ export function InvocationLogs({ traceId }: { traceId: string }): JSX.Element {
{log.level ?? "log"}
</span>
<span className="font-mono text-xs break-all text-kumo-default">
{previewMessage(log.message)}
{formatLogMessage(log.message ?? undefined) || (
<span className="text-kumo-subtle italic">(no message)</span>
)}
</span>
</li>
))}
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ export function QuerySyntaxHint({
<li>
<code>db.query.text:orders</code> — any attribute key
</li>
<li>
<code>trace:</code> / <code>span:</code> — look up by id
</li>
<li>Bare words become free-text search.</li>
</>
) : (
Expand All @@ -62,6 +65,9 @@ export function QuerySyntaxHint({
<li>
<code>op:/checkout</code> — filter by operation/route
</li>
<li>
<code>trace:</code> / <code>span:</code> — look up by id
</li>
<li>Bare words search the message and service.</li>
</>
)}
Expand Down
Loading
Loading