@slack/radar-mcp 1.6.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -45,9 +45,10 @@ Opens `http://localhost:8100` automatically. Set `SLACK_RADAR_WEB_PORT` to use a
45
45
  * **RTM** — real-time websocket events
46
46
  * **Clogs** — analytics events, click one to see its payload
47
47
  * **Logs** — device logcat, filtered to the Slack app (switch app or clear the filter)
48
+ * **Database** — read-only browser for the on-device SQLite stores (pick a database, then a table, or run a read-only query). See the data-handling note below.
48
49
  * **Custom** — a tab Claude builds on request. Ask a Claude Code session in your terminal for a view the pre-built tabs do not cover (e.g. "graph calls per second and flag the slow ones"); it authors the view via the `open_radar_dashboard` tool and it appears here. Persists until you refresh the page.
49
50
 
50
- Each tab has a counter, rate, sparkline, graph, a filter / sort / pause feed, and a payload inspector. Switching tabs replays what you have already seen this session (the browser caches it, bounded by count and age) so the view does not reset to empty. ADB forwarding and device activation are handled for you; a reconnect control re-arms after a device sleep.
51
+ Each tab has a rate, sparkline, graph, a filter / sort / pause feed, and a payload inspector. Switching tabs replays what you have already seen this session (the browser caches it, bounded by count and age) so the view does not reset to empty. ADB forwarding and device activation are handled for you; a reconnect control re-arms after a device sleep. The dashboard follows your system light/dark setting by default; the theme toggle in the header overrides it and is remembered.
51
52
 
52
53
  ### Screen overlay
53
54
 
@@ -89,7 +90,7 @@ Radar starts **disabled** each session. All tools except `ping` return an error
89
90
  | `wait_for_events` | Block until matching events arrive via SSE stream. Idle-timeout based, not polling. |
90
91
  | `search` | Full-text search across network, RTM, and clog buffers. |
91
92
  | `get_app_state` | Current Activity/Fragment state via `dumpsys`. Shows visible screen, parameters, tab state. |
92
- | `get_recent_logs` | Logcat entries filtered by tag. For Timber debug logs (`FKDebug`, `SlackRadar`, etc.). |
93
+ | `get_recent_logs` | Recent device logs, optionally filtered by tag (omit `tag` for all lines). Reads the app's own debug log buffer first, falling back to logcat; the result's `source` field (`app_buffer` / `logcat_capture` / `logcat_ring`) names which one served it. For Timber debug logs (`FKDebug`, `SlackRadar`, etc.). |
93
94
  | `screenshot` | Capture device screen as PNG. 720px default, `full_res=true` for full resolution. |
94
95
  | `record_screen` | Record short screen video (MP4). Default 5s, max 30s. |
95
96
  | `clear_buffers` | Clear all captured network calls and RTM events from device and local buffers. |
@@ -146,7 +147,7 @@ This tool captures unredacted network traffic from your debug Slack build, inclu
146
147
  * RTM WebSocket payloads (every sent and received message)
147
148
  * Analytics (clog) events serialized as JSON
148
149
  * Screenshots and short screen recordings on demand
149
- * Logcat entries filtered by tag
150
+ * Device logs (the app's own debug buffer, with a logcat fallback), optionally filtered by tag
150
151
 
151
152
  Auth tokens and session cookies will appear in captured data. Captured data flows to your MCP client (Claude Code), which may send relevant portions to the configured AI provider per its own data handling policy.
152
153
 
@@ -0,0 +1 @@
1
+ export declare function buildApiPath(name: string, args: Record<string, unknown>): string | null;
@@ -0,0 +1,108 @@
1
+ // Maps an MCP tool name + its args to the device HTTP path the MCP server proxies it to.
2
+ // Extracted from index.ts (where it was nested inside runMcpServer, so unreachable by a unit
3
+ // test) into its own module so the device-route contract for EVERY proxied tool -- network,
4
+ // rtm, clog, trace, search, and the detail-by-id variants -- is pinned by a table test
5
+ // (tests/api-paths.test.mjs). Pure: no I/O, no device, just string building.
6
+ //
7
+ // The arg shapes here are the query-param subset each tool sends; they intentionally duplicate
8
+ // the (wider) handler arg interfaces in index.ts rather than couple this pure module to them.
9
+ // Build the device path for a detail-by-id tool. The MCP SDK casts args without enforcing the
10
+ // JSON Schema (no zod), so `id` can arrive as a string carrying "/" or ".." even though the
11
+ // schema says number. All four detail tools (network/rtm/clog/trace) read the device's OWN
12
+ // forwarded server with no shell/fs, so the blast radius is small, but an un-encoded id is still
13
+ // a path-traversal smell. Accept only a non-negative integer id; anything else returns null
14
+ // (the caller reports "unknown tool"/no path rather than firing a malformed device request).
15
+ function detailPath(resource, id) {
16
+ let n;
17
+ if (typeof id === "number") {
18
+ n = id;
19
+ }
20
+ else if (typeof id === "string" && /^\d+$/.test(id.trim())) {
21
+ // a clean digit string only -- Number("") and Number(" ") are 0 and Number("1/../x") is NaN,
22
+ // but "1e3"/"0x10"/" 5 " would also coerce; the \d+ test rejects all of those outright.
23
+ n = Number(id.trim());
24
+ }
25
+ else {
26
+ return null;
27
+ }
28
+ if (!Number.isInteger(n) || n < 0)
29
+ return null;
30
+ return `/api/${resource}/${n}`;
31
+ }
32
+ export function buildApiPath(name, args) {
33
+ switch (name) {
34
+ case "get_recent_network_calls": {
35
+ const a = args;
36
+ const params = new URLSearchParams();
37
+ if (a?.limit)
38
+ params.set("limit", String(a.limit));
39
+ if (a?.url_filter)
40
+ params.set("url_filter", a.url_filter);
41
+ if (a?.status_code)
42
+ params.set("status_code", String(a.status_code));
43
+ const qs = params.toString();
44
+ return `/api/network${qs ? "?" + qs : ""}`;
45
+ }
46
+ case "get_network_call_detail": {
47
+ const a = args;
48
+ return detailPath("network", a.id);
49
+ }
50
+ case "get_recent_rtm_events": {
51
+ const a = args;
52
+ const params = new URLSearchParams();
53
+ if (a?.limit)
54
+ params.set("limit", String(a.limit));
55
+ if (a?.event_type)
56
+ params.set("event_type", a.event_type);
57
+ if (a?.direction)
58
+ params.set("direction", a.direction);
59
+ if (a?.channel)
60
+ params.set("channel", a.channel);
61
+ const qs = params.toString();
62
+ return `/api/rtm${qs ? "?" + qs : ""}`;
63
+ }
64
+ case "get_rtm_event_detail": {
65
+ const a = args;
66
+ return detailPath("rtm", a.id);
67
+ }
68
+ case "get_recent_clogs": {
69
+ const a = args;
70
+ const params = new URLSearchParams();
71
+ if (a?.limit)
72
+ params.set("limit", String(a.limit));
73
+ if (a?.name)
74
+ params.set("name", a.name);
75
+ const qs = params.toString();
76
+ return `/api/clogs${qs ? "?" + qs : ""}`;
77
+ }
78
+ case "get_clog_detail": {
79
+ const a = args;
80
+ return detailPath("clogs", a.id);
81
+ }
82
+ case "get_recent_traces": {
83
+ const a = args;
84
+ const params = new URLSearchParams();
85
+ // Default the limit when absent: traces can be large and an omitted limit would otherwise
86
+ // rely on an unverified device-side default and stream an unbounded result into the model
87
+ // context. 50 matches the other recent-* tools' practical page size.
88
+ params.set("limit", String(a?.limit && a.limit > 0 ? a.limit : 50));
89
+ if (a?.name)
90
+ params.set("name", a.name);
91
+ return `/api/traces?${params.toString()}`;
92
+ }
93
+ case "get_trace_detail": {
94
+ const a = args;
95
+ return detailPath("traces", a.id);
96
+ }
97
+ case "search": {
98
+ const a = args;
99
+ const params = new URLSearchParams();
100
+ params.set("q", a.query);
101
+ if (a?.limit)
102
+ params.set("limit", String(a.limit));
103
+ return `/api/search?${params.toString()}`;
104
+ }
105
+ default:
106
+ return null;
107
+ }
108
+ }
package/dist/mcp/index.js CHANGED
@@ -12,9 +12,12 @@ import { RADAR_HOST, RADAR_PORT, WEB_PORT_DEFAULT } from "../shared/constants.js
12
12
  import { stopCapture } from "../shared/logcat-capture.js";
13
13
  import { connectStream, destroyStream, waitForEvents, clearLocalBuffers, isStreamConnected, localNetworkBuffer, localRtmBuffer, setTransport, } from "../shared/stream.js";
14
14
  import { buildBufferPath, createSnapshot } from "./snapshot.js";
15
+ import { clampLogLimit, shapeLogsResult } from "./logs-result.js";
15
16
  import { handleListDatabases, handleListTables, handleQueryDatabase, } from "./db-tools.js";
16
17
  import { cleanupPulledDatabases, initializeDatabasePath, resetDbState, } from "../shared/db.js";
17
18
  import { TOOL_DEFINITIONS, SERVER_INSTRUCTIONS, sessionGate } from "./tools.js";
19
+ import { buildApiPath } from "./api-paths.js";
20
+ import { interpretRadarResponse } from "./radar-response.js";
18
21
  // --- Subcommand + bin-name dispatch ---
19
22
  //
20
23
  // One entrypoint, two front doors, chosen so each invocation does the obvious thing:
@@ -198,8 +201,31 @@ async function runMcpServer() {
198
201
  process.once(sig, cleanupPulledDatabases);
199
202
  }
200
203
  let sessionEnabled = false;
204
+ // --- HTTP requests to the on-device radar server ---
205
+ // Host-side ceiling on a proxied device response. radarRequest forwards the device body straight
206
+ // into the model context, so an unbounded response (large trace/clog payloads) could flood it.
207
+ // When the cap trips we stop reading, tear the socket down, and return a truncation marker
208
+ // rather than a partial JSON blob the parser would choke on. 1MB is generous for a point query;
209
+ // bulk exports go through capture_snapshot, not this path.
210
+ const MAX_RADAR_RESPONSE_BYTES = 1024 * 1024;
201
211
  function radarRequest(requestPath) {
202
212
  return new Promise((resolve, reject) => {
213
+ // settled-guard: req.destroy() on the cap path fires "error" (ECONNRESET) AFTER we have
214
+ // already resolved the truncation, so every settle goes through these wrappers and the
215
+ // second one is a no-op. Without this the abort would reject an already-resolved promise.
216
+ let settled = false;
217
+ const done = (r) => {
218
+ if (settled)
219
+ return;
220
+ settled = true;
221
+ resolve(r);
222
+ };
223
+ const fail = (e) => {
224
+ if (settled)
225
+ return;
226
+ settled = true;
227
+ reject(e);
228
+ };
203
229
  const req = http.get({
204
230
  host: RADAR_HOST,
205
231
  port: RADAR_PORT,
@@ -207,22 +233,31 @@ async function runMcpServer() {
207
233
  timeout: 10000,
208
234
  }, (res) => {
209
235
  let data = "";
236
+ let bytes = 0;
210
237
  res.on("data", (chunk) => {
238
+ if (settled)
239
+ return;
240
+ bytes += chunk.length; // byte length, not String.length (UTF-8 over the wire)
241
+ if (bytes > MAX_RADAR_RESPONSE_BYTES) {
242
+ // Resolve the truncation NOW (not on "end" — req.destroy() aborts the response so
243
+ // "end" never fires; it surfaces as an "error" the settled-guard then swallows).
244
+ done({
245
+ error: `Device response exceeded the ${Math.round(MAX_RADAR_RESPONSE_BYTES / 1024)}KB response budget and was dropped to protect the model context. Narrow the query (lower limit, add a name/filter), or use capture_snapshot for a bulk export.`,
246
+ _truncated: true,
247
+ });
248
+ req.destroy();
249
+ return;
250
+ }
211
251
  data += chunk;
212
252
  });
213
253
  res.on("end", () => {
214
- try {
215
- resolve(JSON.parse(data));
216
- }
217
- catch {
218
- resolve({ raw: data });
219
- }
254
+ done(interpretRadarResponse(res.statusCode ?? 0, data));
220
255
  });
221
256
  });
222
- req.on("error", reject);
257
+ req.on("error", fail);
223
258
  req.on("timeout", () => {
224
259
  req.destroy();
225
- reject(new Error("Request timed out"));
260
+ fail(new Error("Request timed out"));
226
261
  });
227
262
  });
228
263
  }
@@ -427,18 +462,14 @@ async function runMcpServer() {
427
462
  }
428
463
  function handleGetRecentLogs(args) {
429
464
  try {
430
- const lines = device.getRecentLogs(args.tag, args.limit || 50, args.grep);
431
- // Surface the capture file path so the model knows which on-disk file
432
- // produced these lines. Helps avoid cross-session staleness (e.g. the
433
- // model remembering an earlier profile's logs after a profile switch).
434
- const sourceFile = device.getCaptureFilePath?.() ?? null;
435
- return textResult({
436
- tag: args.tag,
437
- lines,
438
- count: lines.length,
439
- source_file: sourceFile,
440
- source: sourceFile ? "capture" : "adb_logcat_d",
441
- });
465
+ const tag = args.tag ?? "";
466
+ const limit = clampLogLimit(args.limit);
467
+ const result = device.getRecentLogs(tag, limit, args.grep);
468
+ // shapeLogsResult preserves the source label (app_buffer / logcat_capture / logcat_ring)
469
+ // that getRecentLogs returns, so the caller can tell a true-empty result from a silent
470
+ // fallback to an inferior source (0 lines from app_buffer means the tag is really absent;
471
+ // 0 from logcat_ring may mean the better source was unreachable).
472
+ return textResult(shapeLogsResult(tag, result));
442
473
  }
443
474
  catch (e) {
444
475
  return textResult({ error: `Log query failed: ${e.message}`, lines: [] }, true);
@@ -517,70 +548,8 @@ async function runMcpServer() {
517
548
  }, true);
518
549
  }
519
550
  }
520
- function buildApiPath(name, args) {
521
- switch (name) {
522
- case "get_recent_network_calls": {
523
- const a = args;
524
- const params = new URLSearchParams();
525
- if (a?.limit)
526
- params.set("limit", String(a.limit));
527
- if (a?.url_filter)
528
- params.set("url_filter", a.url_filter);
529
- if (a?.status_code)
530
- params.set("status_code", String(a.status_code));
531
- const qs = params.toString();
532
- return `/api/network${qs ? "?" + qs : ""}`;
533
- }
534
- case "get_network_call_detail": {
535
- const a = args;
536
- return `/api/network/${a.id}`;
537
- }
538
- case "get_recent_rtm_events": {
539
- const a = args;
540
- const params = new URLSearchParams();
541
- if (a?.limit)
542
- params.set("limit", String(a.limit));
543
- if (a?.event_type)
544
- params.set("event_type", a.event_type);
545
- if (a?.direction)
546
- params.set("direction", a.direction);
547
- if (a?.channel)
548
- params.set("channel", a.channel);
549
- const qs = params.toString();
550
- return `/api/rtm${qs ? "?" + qs : ""}`;
551
- }
552
- case "get_rtm_event_detail": {
553
- const a = args;
554
- return `/api/rtm/${a.id}`;
555
- }
556
- case "get_recent_clogs": {
557
- const a = args;
558
- const params = new URLSearchParams();
559
- if (a?.limit)
560
- params.set("limit", String(a.limit));
561
- if (a?.name)
562
- params.set("name", a.name);
563
- const qs = params.toString();
564
- return `/api/clogs${qs ? "?" + qs : ""}`;
565
- }
566
- case "get_clog_detail": {
567
- const a = args;
568
- return `/api/clogs/${a.id}`;
569
- }
570
- case "search": {
571
- const a = args;
572
- const params = new URLSearchParams();
573
- params.set("q", a.query);
574
- if (a?.limit)
575
- params.set("limit", String(a.limit));
576
- return `/api/search?${params.toString()}`;
577
- }
578
- default:
579
- return null;
580
- }
581
- }
582
551
  // --- MCP Server ---
583
- const server = new Server({ name: "slack-radar", version: "1.6.0" }, { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS });
552
+ const server = new Server({ name: "slack-radar", version: "1.8.0" }, { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS });
584
553
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
585
554
  tools: TOOL_DEFINITIONS,
586
555
  }));
@@ -0,0 +1,9 @@
1
+ import type { LogResult } from "../shared/transport.js";
2
+ export interface LogsToolResult {
3
+ tag: string;
4
+ lines: string[];
5
+ count: number;
6
+ source?: string;
7
+ }
8
+ export declare function clampLogLimit(limit: number | undefined): number;
9
+ export declare function shapeLogsResult(tag: string, result: LogResult): LogsToolResult;
@@ -0,0 +1,15 @@
1
+ // Clamp the requested line count. With tag optional, a no-tag call returns the whole buffer
2
+ // (thousands of lines, ~1.5MB), so an unbounded limit would flood the model context with
3
+ // potentially PII-bearing log lines. 1000 is a generous ceiling for a debug read; default 50.
4
+ export function clampLogLimit(limit) {
5
+ return Math.min(Math.max(limit || 50, 1), 1000);
6
+ }
7
+ // Shape the device result into the tool payload, preserving the source label verbatim.
8
+ export function shapeLogsResult(tag, result) {
9
+ return {
10
+ tag,
11
+ lines: result.lines,
12
+ count: result.lines.length,
13
+ source: result.source,
14
+ };
15
+ }
@@ -0,0 +1,21 @@
1
+ export interface RadarResponse {
2
+ error?: string;
3
+ status?: number;
4
+ stream_connected?: boolean;
5
+ local_network_buffer?: number;
6
+ local_rtm_buffer?: number;
7
+ timeout_minutes?: number;
8
+ raw?: string;
9
+ [key: string]: unknown;
10
+ }
11
+ /**
12
+ * Turn a device HTTP (status, body) into the RadarResponse the tool layer returns. Parse JSON
13
+ * when the body is JSON: this preserves the device's own error bodies (e.g. an in-app 404 sends
14
+ * {"error":"Not Found"}) and every happy-path payload unchanged. The added guard is for a 4xx/5xx
15
+ * whose body is NOT JSON (empty, HTML, a bare string): a build that predates a tool's route, or
16
+ * an upstream proxy error, would otherwise surface as a confusing {raw:""} the caller cannot read.
17
+ * In that case synthesize a clear error naming the status so the model reports something useful
18
+ * (an older debug build can predate a newer tool) instead of an empty blob. A 2xx with a non-JSON
19
+ * body keeps the existing {raw} behavior, a real if unusual success payload.
20
+ */
21
+ export declare function interpretRadarResponse(status: number, body: string): RadarResponse;
@@ -0,0 +1,38 @@
1
+ // The shape a proxied device call resolves to, and how a raw (status, body) becomes one. Split
2
+ // out of index.ts so it is unit-testable: index.ts connects stdio at module load, so importing it
3
+ // from a test would hang.
4
+ /**
5
+ * Turn a device HTTP (status, body) into the RadarResponse the tool layer returns. Parse JSON
6
+ * when the body is JSON: this preserves the device's own error bodies (e.g. an in-app 404 sends
7
+ * {"error":"Not Found"}) and every happy-path payload unchanged. The added guard is for a 4xx/5xx
8
+ * whose body is NOT JSON (empty, HTML, a bare string): a build that predates a tool's route, or
9
+ * an upstream proxy error, would otherwise surface as a confusing {raw:""} the caller cannot read.
10
+ * In that case synthesize a clear error naming the status so the model reports something useful
11
+ * (an older debug build can predate a newer tool) instead of an empty blob. A 2xx with a non-JSON
12
+ * body keeps the existing {raw} behavior, a real if unusual success payload.
13
+ */
14
+ export function interpretRadarResponse(status, body) {
15
+ let parsed;
16
+ try {
17
+ parsed = JSON.parse(body);
18
+ }
19
+ catch {
20
+ if (status >= 400) {
21
+ const bodyPrefix = body.substring(0, 200).trim();
22
+ const bodyDetail = bodyPrefix ? ` Body: ${bodyPrefix}` : "";
23
+ return {
24
+ error: `Device returned HTTP ${status} with a non-JSON body. The route may not exist on this build (an older debug build can predate a newer tool), or an upstream error occurred.${bodyDetail}`,
25
+ status,
26
+ };
27
+ }
28
+ return { raw: body };
29
+ }
30
+ // Device routes return JSON OBJECTS. A bare null/number/array/string is valid JSON but not a
31
+ // RadarResponse, and a downstream `result.error` read on a non-object (esp. null) would throw.
32
+ // Keep it as a raw payload instead of casting a lie. (The old code cast straight to
33
+ // RadarResponse and carried this hole; guarding here closes it.)
34
+ if (typeof parsed !== "object" || parsed === null) {
35
+ return { raw: body };
36
+ }
37
+ return parsed;
38
+ }
@@ -13,6 +13,7 @@ export interface SnapshotResult {
13
13
  network: number;
14
14
  rtm: number;
15
15
  clogs: number;
16
+ traces: number;
16
17
  log_lines: number;
17
18
  log_lines_truncated: boolean;
18
19
  screenshot: boolean;
@@ -21,6 +22,7 @@ export interface SnapshotResult {
21
22
  network: boolean;
22
23
  rtm: boolean;
23
24
  clogs: boolean;
25
+ traces: boolean;
24
26
  };
25
27
  errors: string[];
26
28
  privacy_notice: string;
@@ -35,7 +37,7 @@ export declare function buildSnapshotDir(now?: number, root?: string): string;
35
37
  * intentionally larger than point-query defaults — snapshots are bulk
36
38
  * exports and the device-side ring buffers cap their own responses.
37
39
  */
38
- export declare function buildBufferPath(kind: "network" | "rtm" | "clogs"): string;
40
+ export declare function buildBufferPath(kind: "network" | "rtm" | "clogs" | "traces"): string;
39
41
  export interface LogTailResult {
40
42
  text: string;
41
43
  truncated: boolean;
@@ -60,4 +62,4 @@ export declare function readRecentLogTail(requestedLimit?: number): LogTailResul
60
62
  * snapshot still lands on disk with whatever succeeded; failures are
61
63
  * collected in `result.errors` so the model can tell the user.
62
64
  */
63
- export declare function createSnapshot(device: DeviceTransport, fetchBuffer: (kind: "network" | "rtm" | "clogs") => Promise<unknown>, opts?: SnapshotOptions, now?: number, snapshotRoot?: string): Promise<SnapshotResult>;
65
+ export declare function createSnapshot(device: DeviceTransport, fetchBuffer: (kind: "network" | "rtm" | "clogs" | "traces") => Promise<unknown>, opts?: SnapshotOptions, now?: number, snapshotRoot?: string): Promise<SnapshotResult>;
@@ -10,7 +10,7 @@ import { getCaptureFilePath, readTailBytes } from "../shared/logcat-capture.js";
10
10
  * tool outputs.
11
11
  *
12
12
  * Snapshot contents:
13
- * - `bundle.json` — device-side buffers (net + RTM + clogs) plus metadata
13
+ * - `bundle.json` — device-side buffers (net + RTM + clogs + traces) plus metadata
14
14
  * - `screenshot.png` — current screen (optional)
15
15
  * - `logcat.tail.log` — last N lines of the active capture file (optional)
16
16
  *
@@ -33,19 +33,19 @@ const BUNDLE_README = `Slack Radar snapshot
33
33
  This directory is a point-in-time bundle of Radar state.
34
34
 
35
35
  Files:
36
- - bundle.json: device-side network/RTM/clog buffers, counts, and metadata.
36
+ - bundle.json: device-side network/RTM/clog/trace buffers, counts, and metadata.
37
37
  - logcat.tail.log (optional): last N lines of the on-device logcat capture.
38
38
  - screenshot.png (optional): current screen.
39
39
 
40
40
  Privacy: bundle.json contains raw API request/response bodies (auth headers,
41
- tokens), RTM message content, user IDs, channel IDs, and clog payloads.
42
- Review before sharing externally. This bundle lives on your local disk only;
43
- nothing is uploaded anywhere by Radar.
41
+ tokens), RTM message content, user IDs, channel IDs, clog payloads, and
42
+ performance trace spans. Review before sharing externally. This bundle lives
43
+ on your local disk only; nothing is uploaded anywhere by Radar.
44
44
 
45
45
  To inspect: cat bundle.json | jq .
46
46
  To share: zip -r snapshot.zip . then attach the zip.
47
47
  `;
48
- const PRIVACY_NOTICE = "bundle.json contains raw API request/response bodies (including auth headers), RTM message content, user IDs, and clog payloads. Review before sharing externally.";
48
+ const PRIVACY_NOTICE = "bundle.json contains raw API request/response bodies (including auth headers), RTM message content, user IDs, clog payloads, and performance trace spans. Review before sharing externally.";
49
49
  /**
50
50
  * Resolve the output directory for a snapshot. Exported for tests.
51
51
  */
@@ -137,7 +137,8 @@ export async function createSnapshot(device, fetchBuffer, opts = {}, now = Date.
137
137
  let network = null;
138
138
  let rtm = null;
139
139
  let clogs = null;
140
- const fetched = { network: false, rtm: false, clogs: false };
140
+ let traces = null;
141
+ const fetched = { network: false, rtm: false, clogs: false, traces: false };
141
142
  // Device-side buffers. Failures are individually caught so one failing
142
143
  // buffer does not nuke the whole snapshot. `fetched` distinguishes
143
144
  // "empty because the buffer was empty" from "null because the fetch
@@ -163,6 +164,17 @@ export async function createSnapshot(device, fetchBuffer, opts = {}, now = Date.
163
164
  catch (e) {
164
165
  errors.push(`clog buffer: ${e.message}`);
165
166
  }
167
+ // Traces capture from app start (like clogs), so they are worth
168
+ // bundling. An older debug build without the /api/traces route fails
169
+ // this one fetch and it is recorded in errors[] like any other buffer;
170
+ // the rest of the snapshot still lands.
171
+ try {
172
+ traces = await fetchBuffer("traces");
173
+ fetched.traces = true;
174
+ }
175
+ catch (e) {
176
+ errors.push(`trace buffer: ${e.message}`);
177
+ }
166
178
  // Logcat tail.
167
179
  let logTail = { text: "", truncated: false };
168
180
  if (includeLogs) {
@@ -221,6 +233,7 @@ export async function createSnapshot(device, fetchBuffer, opts = {}, now = Date.
221
233
  network: countItems(network),
222
234
  rtm: countItems(rtm),
223
235
  clogs: countItems(clogs),
236
+ traces: countItems(traces),
224
237
  log_lines: logTail.text
225
238
  ? logTail.text.split("\n").filter(Boolean).length
226
239
  : 0,
@@ -228,12 +241,12 @@ export async function createSnapshot(device, fetchBuffer, opts = {}, now = Date.
228
241
  screenshot: screenshotCaptured,
229
242
  };
230
243
  const bundle = {
231
- version: 1,
244
+ version: 2,
232
245
  timestamp: now,
233
246
  platform: device.platform,
234
247
  active_user_id: device.getActiveUserId(),
235
248
  capture_file: getCaptureFilePath(),
236
- buffers: { network, rtm, clogs },
249
+ buffers: { network, rtm, clogs, traces },
237
250
  counts,
238
251
  fetched,
239
252
  errors,
@@ -285,14 +298,14 @@ export async function createSnapshot(device, fetchBuffer, opts = {}, now = Date.
285
298
  }
286
299
  /**
287
300
  * Count items inside a buffer response. Handles the `{ calls: [...] }`,
288
- * `{ events: [...] }`, `{ clogs: [...] }` shapes the radar HTTP server
289
- * uses. Falls back to 0 on malformed responses.
301
+ * `{ events: [...] }`, `{ clogs: [...] }`, `{ traces: [...] }` shapes the
302
+ * radar HTTP server uses. Falls back to 0 on malformed responses.
290
303
  */
291
304
  function countItems(body) {
292
305
  if (!body || typeof body !== "object")
293
306
  return 0;
294
307
  const b = body;
295
- for (const key of ["calls", "events", "clogs", "items"]) {
308
+ for (const key of ["calls", "events", "clogs", "traces", "items"]) {
296
309
  const v = b[key];
297
310
  if (Array.isArray(v))
298
311
  return v.length;