diff --git a/CHANGES.md b/CHANGES.md index 42690e03a..acd63ec6a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -20,6 +20,18 @@ To be released. [#893]: https://github.com/fedify-dev/fedify/issues/893 [#1187]: https://github.com/fedify-dev/fedify/pull/1187 +### @fedify/debugger + + - Added filtering controls to the debug dashboard. The traces list can + now be narrowed down by activity type, and a trace's log table can be + narrowed down by category, level, or a free-text search of the message. + This makes it easier to find one failed activity or one noisy log + category once a federated app has produced more than a few traces. + [[#896], [#1204]] + +[#896]: https://github.com/fedify-dev/fedify/issues/896 +[#1204]: https://github.com/fedify-dev/fedify/issues/1204 + Version 2.4.0 ------------- diff --git a/changes.d/debugger/debug-dashboard-filters.md b/changes.d/debugger/debug-dashboard-filters.md new file mode 100644 index 000000000..bcf408e25 --- /dev/null +++ b/changes.d/debugger/debug-dashboard-filters.md @@ -0,0 +1,6 @@ + - Added filtering controls to the debug dashboard. The traces list can + now be narrowed down by activity type, and a trace's log table can be + narrowed down by category, level, or a free-text search of the message. + This makes it easier to find one failed activity or one noisy log + category once a federated app has produced more than a few traces. + [[#896], [#1204]] diff --git a/packages/debugger/src/mod.test.ts b/packages/debugger/src/mod.test.ts index 3f131c24c..4eaecb202 100644 --- a/packages/debugger/src/mod.test.ts +++ b/packages/debugger/src/mod.test.ts @@ -17,6 +17,7 @@ import type { TraceActivityRecord, TraceSummary, } from "@fedify/fedify/otel"; +import type { Sink } from "@logtape/logtape"; import { trace } from "@opentelemetry/api"; function createMockExporter( @@ -1251,3 +1252,609 @@ test("trace detail page shows empty log message", async () => { const html = await response.text(); ok(html.includes("No logs captured for this trace.")); }); + +// ---------- Trace list filtering tests ---------- + +function threeTypedTraces(): TraceSummary[] { + return [ + { + traceId: "a".repeat(32), + timestamp: "2026-01-01T00:00:00Z", + activityCount: 1, + activityTypes: ["Create"], + }, + { + traceId: "b".repeat(32), + timestamp: "2026-01-02T00:00:00Z", + activityCount: 1, + activityTypes: ["Follow"], + }, + { + traceId: "c".repeat(32), + timestamp: "2026-01-03T00:00:00Z", + activityCount: 1, + activityTypes: ["Like"], + }, + ]; +} + +test("traces list page filters by a single activity type", async () => { + const { federation } = createMockFederation(); + const { exporter, kv } = createMockExporter(threeTypedTraces()); + const dbg = createFederationDebugger(federation, { exporter, kv }); + const request = new Request("https://example.com/__debug__/?type=Follow"); + const response = await dbg.fetch(request, { contextData: undefined }); + const html = await response.text(); + ok(html.includes("1")); + ok(html.includes("bbbbbbbb")); + ok(!html.includes("aaaaaaaa")); + ok(!html.includes("cccccccc")); +}); + +test("traces list page filters by multiple activity types with OR semantics", async () => { + const { federation } = createMockFederation(); + const { exporter, kv } = createMockExporter(threeTypedTraces()); + const dbg = createFederationDebugger(federation, { exporter, kv }); + const request = new Request( + "https://example.com/__debug__/?type=Create&type=Like", + ); + const response = await dbg.fetch(request, { contextData: undefined }); + const html = await response.text(); + ok(html.includes("2")); + ok(html.includes("aaaaaaaa")); + ok(html.includes("cccccccc")); + ok(!html.includes("bbbbbbbb")); +}); + +test("traces list page shows filter-specific empty message when nothing matches", async () => { + const { federation } = createMockFederation(); + const { exporter, kv } = createMockExporter(threeTypedTraces()); + const dbg = createFederationDebugger(federation, { exporter, kv }); + const request = new Request("https://example.com/__debug__/?type=Nope"); + const response = await dbg.fetch(request, { contextData: undefined }); + const html = await response.text(); + ok(html.includes("No traces match the selected filters.")); + ok(!html.includes("No traces captured yet.")); +}); + +test("traces list page filter form retains the selected checkbox and shows a clear link", async () => { + const { federation } = createMockFederation(); + const { exporter, kv } = createMockExporter(threeTypedTraces()); + const dbg = createFederationDebugger(federation, { exporter, kv }); + const request = new Request("https://example.com/__debug__/?type=Follow"); + const response = await dbg.fetch(request, { contextData: undefined }); + const html = await response.text(); + ok(html.includes('value="Follow" checked')); + ok(!html.includes('value="Create" checked')); + ok(!html.includes('value="Like" checked')); + ok(html.includes("Clear filters")); +}); + +test("traces list page filter form has no clear link when nothing is selected", async () => { + const { federation } = createMockFederation(); + const { exporter, kv } = createMockExporter(threeTypedTraces()); + const dbg = createFederationDebugger(federation, { exporter, kv }); + const request = new Request("https://example.com/__debug__/"); + const response = await dbg.fetch(request, { contextData: undefined }); + const html = await response.text(); + ok(!html.includes("Clear filters")); +}); + +test("JSON traces API filters by type", async () => { + const { federation } = createMockFederation(); + const { exporter, kv } = createMockExporter(threeTypedTraces()); + const dbg = createFederationDebugger(federation, { exporter, kv }); + const request = new Request( + "https://example.com/__debug__/api/traces?type=Follow", + ); + const response = await dbg.fetch(request, { contextData: undefined }); + const body = (await response.json()) as TraceSummary[]; + strictEqual(body.length, 1); + strictEqual(body[0].traceId, "b".repeat(32)); +}); + +// ---------- Poll script tests ---------- + +/** Pulls the inline `'; + const request = new Request( + `https://example.com/__debug__/traces/${traceId}?q=${ + encodeURIComponent(malicious) + }`, + ); + const response = await dbg.fetch(request, { contextData: undefined }); + const html = await response.text(); + ok(!html.includes("")); + ok(html.includes("<script>")); +}); diff --git a/packages/debugger/src/routes.tsx b/packages/debugger/src/routes.tsx index 15e196df5..569608798 100644 --- a/packages/debugger/src/routes.tsx +++ b/packages/debugger/src/routes.tsx @@ -5,7 +5,7 @@ * * @module */ -import type { FedifySpanExporter } from "@fedify/fedify/otel"; +import type { FedifySpanExporter, TraceSummary } from "@fedify/fedify/otel"; import { Hono } from "hono"; import { getCookie } from "hono/cookie"; import { @@ -16,11 +16,108 @@ import { signSession, verifySession, } from "./auth.ts"; -import type { LogStore } from "./log-store.ts"; +import type { LogStore, SerializedLogRecord } from "./log-store.ts"; import { LoginPage } from "./views/login.tsx"; import { TraceDetailPage } from "./views/trace-detail.tsx"; import { TracesListPage } from "./views/traces-list.tsx"; +/** Collects the distinct activity types across all traces, sorted. */ +function distinctActivityTypes(traces: readonly TraceSummary[]): string[] { + const types = new Set(); + for (const trace of traces) { + for (const type of trace.activityTypes) types.add(type); + } + return [...types].sort(); +} + +/** + * Keeps only the traces whose `activityTypes` include at least one of the + * given `types`. An empty `types` list means "no filter"—all traces pass + * through unchanged. + */ +function filterTracesByTypes( + traces: readonly TraceSummary[], + types: readonly string[], +): TraceSummary[] { + if (types.length === 0) return [...traces]; + return traces.filter((trace) => + trace.activityTypes.some((type) => types.includes(type)) + ); +} + +/** + * Computes a string that changes whenever the trace corpus changes in a + * way the traces list page's live-poll script needs to react to. Rather + * than comparing a handful of hand-picked summary fields—which has missed + * a real change each time a new one turned up—this serializes every trace + * that currently matches `selectedTypes` as `id:types:activityCount`, so + * a trace being replaced, an existing trace's activity count going up, or + * its activity types growing are all covered by the same comparison. The + * distinct activity types across *all* traces are tracked separately, + * since a type that does not match the active filter still needs to make + * its checkbox appear. The page embeds this as the poll script's + * starting point, so the very first poll tick is compared against the + * data the page was actually rendered with, rather than treating + * whatever that first tick happens to see as the baseline. + */ +function snapshotOf( + traces: readonly TraceSummary[], + selectedTypes: readonly string[], +): string { + const types = distinctActivityTypes(traces); + const rows = filterTracesByTypes(traces, selectedTypes) + .map((trace) => + `${trace.traceId}:${ + [...trace.activityTypes].sort().join("+") + }:${trace.activityCount}` + ) + .sort(); + return `${types.join(",")}|${rows.join(";")}`; +} + +/** Collects the distinct dot-joined log categories, sorted. */ +function distinctLogCategories( + logs: readonly SerializedLogRecord[], +): string[] { + const categories = new Set(); + for (const log of logs) categories.add(log.category.join(".")); + return [...categories].sort(); +} + +/** + * Criteria for narrowing down a trace's log records on the trace detail + * page. Every present field must match (AND); an absent or empty field + * is not applied. + */ +interface LogFilter { + readonly category?: string; + readonly level?: string; + readonly q?: string; +} + +/** Applies a {@link LogFilter} to a list of log records. */ +function filterLogs( + logs: readonly SerializedLogRecord[], + filter: LogFilter, +): readonly SerializedLogRecord[] { + let filtered = logs; + if (filter.category) { + const category = filter.category; + filtered = filtered.filter((log) => log.category.join(".") === category); + } + if (filter.level) { + const level = filter.level; + filtered = filtered.filter((log) => log.level === level); + } + if (filter.q) { + const needle = filter.q.toLowerCase(); + filtered = filtered.filter((log) => + log.message.toLowerCase().includes(needle) + ); + } + return filtered; +} + export function createDebugApp( pathPrefix: string, exporter: FedifySpanExporter, @@ -133,14 +230,20 @@ export function createDebugApp( app.get("/api/traces", async (c) => { const traces = await exporter.getRecentTraces(); - return c.json(traces); + const types = c.req.queries("type") ?? []; + return c.json(filterTracesByTypes(traces, types)); }); app.get("/api/logs/:traceId", async (c) => { const traceId = c.req.param("traceId"); await logStore.flush(); const logs = await logStore.get(traceId); - return c.json(logs); + const logFilter: LogFilter = { + category: c.req.query("category"), + level: c.req.query("level"), + q: c.req.query("q"), + }; + return c.json(filterLogs(logs, logFilter)); }); app.get("/traces/:traceId", async (c) => { @@ -148,11 +251,21 @@ export function createDebugApp( await logStore.flush(); const activities = await exporter.getActivitiesByTraceId(traceId); const logs = await logStore.get(traceId); + const logFilter: LogFilter = { + category: c.req.query("category"), + level: c.req.query("level"), + q: c.req.query("q"), + }; return c.html( , ); @@ -160,8 +273,15 @@ export function createDebugApp( app.get("/", async (c) => { const traces = await exporter.getRecentTraces(); + const selectedTypes = c.req.queries("type") ?? []; return c.html( - , + , ); }); diff --git a/packages/debugger/src/views/layout.tsx b/packages/debugger/src/views/layout.tsx index 038c07c96..11d20a0f1 100644 --- a/packages/debugger/src/views/layout.tsx +++ b/packages/debugger/src/views/layout.tsx @@ -57,6 +57,13 @@ export const Layout: FC> = ( pre { background: #f6f8fa; padding: 1rem; overflow-x: auto; border-radius: 6px; font-size: 0.8125rem; } .empty { color: #888; font-style: italic; } nav a { margin-right: 0.5rem; } + .filter-form { border: 1px solid #ddd; border-radius: 6px; padding: 0.75rem 1rem; margin-bottom: 1rem; } + .filter-form fieldset { border: none; padding: 0; margin: 0; } + .filter-form legend { font-size: 0.8125rem; color: #666; padding: 0; margin-bottom: 0.5rem; } + .filter-form label { display: inline-flex; align-items: center; gap: 0.3rem; margin: 0 1rem 0.5rem 0; font-size: 0.875rem; } + .filter-form select, .filter-form input[type="text"] { font-size: 0.875rem; padding: 0.25rem 0.4rem; } + .filter-actions { display: flex; align-items: center; gap: 1rem; margin-top: 0.25rem; } + .filter-actions button { font-size: 0.8125rem; padding: 0.3rem 0.75rem; cursor: pointer; } .log-table td { font-size: 0.8125rem; vertical-align: top; } .log-table time { font-family: monospace; white-space: nowrap; } .badge-debug { background: #e8e8e8; color: #666; } @@ -80,6 +87,8 @@ export const Layout: FC> = ( .detail-section h2 { border-bottom-color: #21262d; } pre { background: #161b22; } .empty { color: #9198a1; } + .filter-form { border-color: #30363d; } + .filter-form legend { color: #9198a1; } .badge-debug { background: #21262d; color: #9198a1; } .badge-info { background: #122d42; color: #58a6ff; } .badge-warning { background: #2e2a1f; color: #d29922; } diff --git a/packages/debugger/src/views/trace-detail.tsx b/packages/debugger/src/views/trace-detail.tsx index 2277709a3..711dda71b 100644 --- a/packages/debugger/src/views/trace-detail.tsx +++ b/packages/debugger/src/views/trace-detail.tsx @@ -2,6 +2,7 @@ /** @jsxImportSource hono/jsx */ import type { FC } from "hono/jsx"; import type { TraceActivityRecord } from "@fedify/fedify/otel"; +import { getLogLevels } from "@logtape/logtape"; import type { SerializedLogRecord } from "../mod.tsx"; import { Layout } from "./layout.tsx"; @@ -34,10 +35,38 @@ export interface TraceDetailPageProps { activities: TraceActivityRecord[]; /** - * The list of log records for this trace. + * The list of log records for this trace, already filtered by + * {@link selectedCategory}, {@link selectedLevel}, and + * {@link selectedQuery} when any of them is set. */ logs: readonly SerializedLogRecord[]; + /** + * The total number of log records for this trace, before filtering. + */ + totalLogCount: number; + + /** + * The distinct log categories available to filter by, derived from the + * unfiltered log set for this trace. + */ + availableCategories: readonly string[]; + + /** + * The category currently selected in the filter form, if any. + */ + selectedCategory?: string; + + /** + * The log level currently selected in the filter form, if any. + */ + selectedLevel?: string; + + /** + * The free-text search term currently entered in the filter form, if any. + */ + selectedQuery?: string; + /** * The path prefix for the debug dashboard. */ @@ -48,8 +77,20 @@ export interface TraceDetailPageProps { * The trace detail page of the debug dashboard. */ export const TraceDetailPage: FC = ( - { traceId, activities, logs, pathPrefix }, + { + traceId, + activities, + logs, + totalLogCount, + availableCategories, + selectedCategory, + selectedLevel, + selectedQuery, + pathPrefix, + }, ) => { + const filtered = Boolean(selectedCategory) || Boolean(selectedLevel) || + Boolean(selectedQuery); return (