Skip to content

Commit 0c7e4d1

Browse files
dahliaclaude
andcommitted
Add per-trace log collection to debug dashboard
Collect LogTape log records per OTel trace so that the debug dashboard can display all logs associated with a given trace. Key changes: - Add LogStore in-memory storage keyed by traceId with eviction - Expose a LogTape Sink via the returned federation.sink property - Simplified overload auto-configures LogTape with contextLocalStorage - Add GET /api/logs/:traceId JSON endpoint - Show logs table in trace detail page with level badges - Update docs and changelog Note: Logs are stored in-memory and do not survive process restarts, unlike traces which may be persisted in a KvStore. fedify-dev#561 Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 5820432 commit 0c7e4d1

11 files changed

Lines changed: 455 additions & 16 deletions

File tree

‎CHANGES.md‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,12 @@ To be released.
139139
code, the inbox URL, and the response body, making it easier to
140140
programmatically handle delivery errors. [[#548], [#559]]
141141

142+
- Added `traceId` and `spanId` to LogTape context in federation middleware
143+
so that log records emitted during request handling and queue processing
144+
include the OpenTelemetry trace and span IDs in their properties. This
145+
enables the `@fedify/debugger` dashboard to display per-trace logs.
146+
[[#561]]
147+
142148
[#280]: https://github.com/fedify-dev/fedify/issues/280
143149
[#366]: https://github.com/fedify-dev/fedify/issues/366
144150
[#376]: https://github.com/fedify-dev/fedify/issues/376
@@ -163,6 +169,7 @@ To be released.
163169
[#548]: https://github.com/fedify-dev/fedify/issues/548
164170
[#559]: https://github.com/fedify-dev/fedify/pull/559
165171
[#560]: https://github.com/fedify-dev/fedify/issues/560
172+
[#561]: https://github.com/fedify-dev/fedify/issues/561
166173

167174
### @fedify/cli
168175

@@ -249,14 +256,20 @@ To be released.
249256
- Trace detail page showing activity direction, type, actor, signature
250257
verification details, inbox URL, and expandable activity JSON.
251258
- JSON API endpoint at `/__debug__/api/traces` for programmatic access.
259+
- Added per-trace log collection using LogTape. The returned federation
260+
object now includes a `sink` property (a LogTape `Sink` function)
261+
that captures log records grouped by trace ID. In the simplified
262+
overload (without `exporter`), LogTape is auto-configured.
263+
- Trace detail page now shows a “Logs” section with log level, timestamp,
264+
logger category, and message for each log record in the trace.
265+
- JSON API endpoint at `/__debug__/api/logs/:traceId` for retrieving
266+
log records for a specific trace.
252267
- Added optional `auth` configuration for protecting the debug dashboard
253268
with authentication. Supports three modes: password-only,
254269
username + password, and request-based (e.g., IP filtering).
255270
Each mode supports both static credentials and callback functions.
256271
Uses cookie-based sessions with HMAC-signed tokens.
257272

258-
[#561]: https://github.com/fedify-dev/fedify/issues/561
259-
260273
### @fedify/relay
261274

262275
- Created ActivityPub relay integration as the *@fedify/relay* package.

‎deno.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎docs/manual/debug.md‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,12 @@ automatically:
7676
- Creates a `MemoryKvStore` and `FedifySpanExporter` for trace data storage
7777
- Creates a `BasicTracerProvider` with a `SimpleSpanProcessor`
7878
- Registers it as the global [OpenTelemetry] tracer provider
79+
- Configures [LogTape] to collect logs per trace (using `getConfig()` to
80+
merge with any existing configuration)
7981

8082
This means `createFederation()` will automatically use the tracer provider
81-
without needing an explicit `tracerProvider` option.
83+
without needing an explicit `tracerProvider` option, and logs emitted by
84+
Fedify will be captured and displayed alongside traces in the dashboard.
8285

8386
The `federation` object returned by `createFederationDebugger()` is a drop-in
8487
replacement for the original. You can use it everywhere you would normally use
@@ -90,6 +93,7 @@ the inner federation object (e.g., passing it to framework integrations).
9093
> over a network, as it exposes internal trace data.
9194
9295
[OpenTelemetry]: ./opentelemetry.md
96+
[LogTape]: https://logtape.org/
9397

9498

9599
Configuration
@@ -291,13 +295,24 @@ belonging to a specific trace. For each activity, it displays:
291295
- Whether Linked Data Signatures were verified
292296
- **Activity JSON** (expandable, pretty-printed)
293297

298+
Below the activities section, a **Logs** section shows all [LogTape] log
299+
records captured during the trace. Each log entry displays:
300+
301+
- **Timestamp** (time portion only)
302+
- **Log level** (color-coded: debug, info, warning, error, fatal)
303+
- **Logger category** (e.g., `fedify.federation.http`)
304+
- **Message** with expandable properties
305+
294306
### JSON API
295307

296308
A JSON API endpoint is available at `/__debug__/api/traces` which returns
297309
the list of recent traces in JSON format. This is used by the auto-polling
298310
mechanism on the traces list page, but you can also query it directly for
299311
programmatic access.
300312

313+
A separate endpoint at `/__debug__/api/logs/:traceId` returns the log records
314+
for a specific trace in JSON format.
315+
301316

302317
Using with framework integrations
303318
---------------------------------
@@ -378,8 +393,30 @@ const federation = createFederationDebugger(innerFederation, {
378393
});
379394
~~~~
380395

396+
In this mode, the returned `federation` object has a `sink` property that is
397+
a [LogTape] `Sink` function. You should include it in your LogTape
398+
configuration to enable per-trace log collection:
399+
400+
~~~~ typescript twoslash
401+
// @noErrors: 2345
402+
import { configure } from "@logtape/logtape";
403+
declare const federation: { sink: (record: any) => void };
404+
// ---cut-before---
405+
await configure({
406+
sinks: {
407+
debugger: federation.sink,
408+
// ... other sinks
409+
},
410+
loggers: [
411+
{ category: "fedify", sinks: ["debugger"] },
412+
// ... other loggers
413+
],
414+
});
415+
~~~~
416+
381417
In this mode, you are responsible for:
382418

383419
- Creating and configuring the `BasicTracerProvider`
384420
- Passing `tracerProvider` to `createFederation()`
385421
- Passing the same `exporter` to `createFederationDebugger()`
422+
- Configuring LogTape with `federation.sink` to collect logs per trace

‎packages/debugger/deno.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"version": "2.0.0",
44
"license": "MIT",
55
"imports": {
6+
"@logtape/logtape": "jsr:@logtape/logtape@^2.0.0",
67
"@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0",
78
"@opentelemetry/sdk-trace-base": "npm:@opentelemetry/sdk-trace-base@^2.0.0",
89
"@std/assert": "jsr:@std/assert@^1.0.13"

‎packages/debugger/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"@fedify/fedify": "workspace:^"
3333
},
3434
"dependencies": {
35+
"@logtape/logtape": "catalog:",
3536
"@opentelemetry/api": "catalog:",
3637
"@opentelemetry/sdk-trace-base": "catalog:",
3738
"hono": "catalog:"

‎packages/debugger/src/mod.test.ts‎

Lines changed: 162 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
assertStringIncludes,
77
} from "@std/assert";
88
import { createFederationDebugger } from "./mod.tsx";
9-
import type { FederationDebuggerAuth } from "./mod.tsx";
9+
import type { FederationDebuggerAuth, SerializedLogRecord } from "./mod.tsx";
1010
import type {
1111
Federation,
1212
FederationFetchOptions,
@@ -774,3 +774,164 @@ test("auth password: logout clears session cookie", async () => {
774774
assert(setCookie != null);
775775
assertStringIncludes(setCookie!, "Max-Age=0");
776776
});
777+
778+
// ---------- Sink property tests ----------
779+
780+
test("createFederationDebugger exposes a sink property", () => {
781+
const { federation } = createMockFederation();
782+
const exporter = createMockExporter();
783+
const dbg = createFederationDebugger(federation, { exporter });
784+
assertNotEquals(dbg.sink, null);
785+
assertNotEquals(dbg.sink, undefined);
786+
assertEquals(typeof dbg.sink, "function");
787+
});
788+
789+
test("simplified overload exposes a sink property", () => {
790+
const originalProvider = trace.getTracerProvider();
791+
try {
792+
const { federation } = createMockFederation();
793+
const dbg = createFederationDebugger(federation);
794+
assertNotEquals(dbg.sink, null);
795+
assertEquals(typeof dbg.sink, "function");
796+
} finally {
797+
trace.setGlobalTracerProvider(originalProvider);
798+
}
799+
});
800+
801+
// ---------- Log collection tests ----------
802+
803+
test("sink collects logs by traceId and API returns them", async () => {
804+
const { federation } = createMockFederation();
805+
const exporter = createMockExporter();
806+
const dbg = createFederationDebugger(federation, { exporter });
807+
808+
// Simulate log records with traceId in properties
809+
const traceId = "aaaa1111bbbb2222cccc3333dddd4444";
810+
dbg.sink({
811+
category: ["fedify", "federation", "http"],
812+
level: "info",
813+
message: ["GET ", "/users/alice", ": ", "200"],
814+
rawMessage: "{method} {path}: {status}",
815+
timestamp: Date.now(),
816+
properties: {
817+
traceId,
818+
spanId: "1234567890abcdef",
819+
method: "GET",
820+
path: "/users/alice",
821+
status: 200,
822+
},
823+
});
824+
825+
// Check via API
826+
const request = new Request(
827+
`https://example.com/__debug__/api/logs/${traceId}`,
828+
);
829+
const response = await dbg.fetch(request, { contextData: undefined });
830+
assertEquals(response.status, 200);
831+
assertEquals(response.headers.get("content-type"), "application/json");
832+
const logs = (await response.json()) as SerializedLogRecord[];
833+
assertEquals(logs.length, 1);
834+
assertEquals(logs[0].level, "info");
835+
assertEquals(logs[0].message, "GET /users/alice: 200");
836+
assertEquals(logs[0].category[0], "fedify");
837+
// traceId and spanId should be excluded from properties
838+
assertEquals("traceId" in logs[0].properties, false);
839+
assertEquals("spanId" in logs[0].properties, false);
840+
// Other properties should be preserved
841+
assertEquals(logs[0].properties.method, "GET");
842+
});
843+
844+
test("sink ignores log records without traceId", async () => {
845+
const { federation } = createMockFederation();
846+
const exporter = createMockExporter();
847+
const dbg = createFederationDebugger(federation, { exporter });
848+
849+
// Log without traceId — should be silently ignored
850+
dbg.sink({
851+
category: ["app"],
852+
level: "debug",
853+
message: ["some message"],
854+
rawMessage: "some message",
855+
timestamp: Date.now(),
856+
properties: {},
857+
});
858+
859+
// No logs should be stored for any traceId
860+
const request = new Request(
861+
"https://example.com/__debug__/api/logs/nonexistent",
862+
);
863+
const response = await dbg.fetch(request, { contextData: undefined });
864+
assertEquals(response.status, 200);
865+
const logs = (await response.json()) as SerializedLogRecord[];
866+
assertEquals(logs.length, 0);
867+
});
868+
869+
test("multiple logs for the same trace are grouped", async () => {
870+
const { federation } = createMockFederation();
871+
const exporter = createMockExporter();
872+
const dbg = createFederationDebugger(federation, { exporter });
873+
874+
const traceId = "aaaa1111bbbb2222cccc3333dddd4444";
875+
for (let i = 0; i < 5; i++) {
876+
dbg.sink({
877+
category: ["fedify"],
878+
level: "info",
879+
message: [`log ${i}`],
880+
rawMessage: `log ${i}`,
881+
timestamp: Date.now() + i,
882+
properties: { traceId, spanId: "abcdef1234567890" },
883+
});
884+
}
885+
886+
const request = new Request(
887+
`https://example.com/__debug__/api/logs/${traceId}`,
888+
);
889+
const response = await dbg.fetch(request, { contextData: undefined });
890+
const logs = (await response.json()) as SerializedLogRecord[];
891+
assertEquals(logs.length, 5);
892+
assertEquals(logs[0].message, "log 0");
893+
assertEquals(logs[4].message, "log 4");
894+
});
895+
896+
test("trace detail page shows log records", async () => {
897+
const { federation } = createMockFederation();
898+
const exporter = createMockExporter();
899+
const dbg = createFederationDebugger(federation, { exporter });
900+
901+
const traceId = "aaaa1111bbbb2222cccc3333dddd4444";
902+
dbg.sink({
903+
category: ["fedify", "federation"],
904+
level: "warning",
905+
message: ["Something went wrong"],
906+
rawMessage: "Something went wrong",
907+
timestamp: 1700000000000,
908+
properties: { traceId, spanId: "1234567890abcdef" },
909+
});
910+
911+
const request = new Request(
912+
`https://example.com/__debug__/traces/${traceId}`,
913+
);
914+
const response = await dbg.fetch(request, { contextData: undefined });
915+
assertEquals(response.status, 200);
916+
const html = await response.text();
917+
assertStringIncludes(html, "Logs");
918+
assertStringIncludes(html, "Something went wrong");
919+
assertStringIncludes(html, "warning");
920+
assertStringIncludes(html, "fedify.federation");
921+
// "1" and "log record" are separated by HTML tags
922+
assertStringIncludes(html, "log record");
923+
});
924+
925+
test("trace detail page shows empty log message", async () => {
926+
const { federation } = createMockFederation();
927+
const exporter = createMockExporter();
928+
const dbg = createFederationDebugger(federation, { exporter });
929+
930+
const request = new Request(
931+
"https://example.com/__debug__/traces/0000000000000000",
932+
);
933+
const response = await dbg.fetch(request, { contextData: undefined });
934+
assertEquals(response.status, 200);
935+
const html = await response.text();
936+
assertStringIncludes(html, "No logs captured for this trace.");
937+
});

0 commit comments

Comments
 (0)