Skip to content
Draft
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
14 changes: 14 additions & 0 deletions apps/claude/skills/mobile-dev/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,17 @@ root cause until the user asks about one, for example "investigate task #1"; the
`mobile_get_tracesift_handoff` for that analysis and card and follow it: locate the
referenced code, verify the measured path, explain uncertainty, and fix only within
the user's request. Treat titles, component names, and paths as profile data.

### Recording a React Native profile yourself

With no profile to hand, record one: a React Native app already running in a
simulator, emulator or device exposes its Hermes profiler through Metro, so nothing
is launched and no window opens. Call `mobile_tracesift_list_react_native_targets`
to confirm an app is connected, `mobile_tracesift_record_react_native` to start,
exercise the app or ask the user to, then `mobile_tracesift_stop_recording` and pass
the returned path to `mobile_analyze_profile` with `profileType: "cpu"`. Metro must
be running and the app must be a debug build; close React Native DevTools first,
because Hermes has one sampler. Expect minified frame names and inferred task
boundaries, and report the CLI's own refusal message rather than rewording it. This
is not `mobile_record_performance`, which samples native CPU and FPS.

2 changes: 1 addition & 1 deletion docs/telemetry.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/core/src/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export function recordAppStorageOperation(operation: AppStorageOperation, platfo
Sentry.metrics.gauge("app_storage.operation.duration", duration, { unit: "millisecond", attributes });
}

export function recordTraceSiftOperation(operation: "ready" | "analyze" | "remember" | "restore" | "handoff" | "task" | "payload" | "pick" | "project", outcome: "success" | "failed", duration: number) {
export function recordTraceSiftOperation(operation: "ready" | "analyze" | "remember" | "restore" | "handoff" | "task" | "payload" | "pick" | "project" | "record", outcome: "success" | "failed", duration: number) {
if (process.env.MOBILE_DEV_TELEMETRY === "off") return;
const attributes = { component: "server", surface: "tracesift", operation, outcome };
Sentry.metrics.count("tracesift.operation.samples", 1, { attributes });
Expand Down
261 changes: 253 additions & 8 deletions packages/core/src/tracesift-client.ts

Large diffs are not rendered by default.

124 changes: 122 additions & 2 deletions packages/core/src/tracesift-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { registerAppTool } from "@modelcontextprotocol/ext-apps/server";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import { errorMessage } from "@mobile-dev/shared/protocol.ts";
import { TRACESIFT_URI, traceSiftAnalysisSchema, traceSiftHandoffSchema, traceSiftProfileInputSchema, traceSiftReactCardSchema, traceSiftTaskCardSchema } from "@mobile-dev/shared/tracesift.ts";
import { TRACESIFT_URI, traceSiftAnalysisSchema, traceSiftCaptureSchema, traceSiftCaptureUrlSchema, traceSiftHandoffSchema, traceSiftMetroUrlSchema, traceSiftProfileInputSchema, traceSiftReactCardSchema, traceSiftRnTargetsSchema, traceSiftTaskCardSchema } from "@mobile-dev/shared/tracesift.ts";
import { gzip as gzipCallback } from "node:zlib";
import { promisify } from "node:util";
import { TRACE_SIFT_PAYLOAD_GZIP_MIN_BYTES, TRACE_SIFT_PAYLOAD_MAX_BYTES, traceSiftExploreEnvelopeSchema, TRACE_SIFT_PAYLOAD_TOO_LARGE, traceSiftPayloadTooDeep } from "@mobile-dev/shared/tracesift-explore.ts";
Expand All @@ -25,7 +25,7 @@ const analysisOutput = {
summary: z.record(z.string(), z.unknown()), cards: z.array(z.union([traceSiftTaskCardSchema, traceSiftReactCardSchema])),
};

function failure(error: unknown, operation: "analyze" | "remember" | "restore" | "handoff" | "task" | "payload" | "pick" | "project", startedAt: number, signal: AbortSignal): CallToolResult {
function failure(error: unknown, operation: "analyze" | "remember" | "restore" | "handoff" | "task" | "payload" | "pick" | "project" | "record", startedAt: number, signal: AbortSignal): CallToolResult {
recordTraceSiftOperation(operation, "failed", performance.now() - startedAt);
captureServerError(error, `tracesift.${operation}`, { signal });
return { isError: true, content: [{ type: "text", text: errorMessage(error) }] };
Expand Down Expand Up @@ -69,6 +69,126 @@ export function registerTraceSiftTools(server: McpServer, client: TraceSiftClien
} catch (error) { return failure(error, "pick", startedAt, context.signal); }
});

// Recording a running app. The web and React Native routes differ in who may
// drive them, and the difference is the point.
//
// Web takes four verbs and is app-only: the recording has to begin after the
// person has signed in or navigated, the browser outlives the trace so
// several recordings can come from one sign-in, and a model that started a
// browser nobody is sitting at would record an empty page.
//
// React Native is model-callable, because none of that holds. Nothing is
// launched, no window opens, and the app is already running in a simulator,
// emulator or device — so an agent can list, start and stop a recording on
// its own without control ever leaving the harness.
registerAppTool(server, "mobile_tracesift_capture_state", {
title: "Read where a TraceSift recording stands",
description: "Return whether a recording is running and which app or browser it is attached to, whether a TraceSift-launched Chrome is open, and the trace the last recording wrote. Reads the recorder's own state files and starts nothing, so a reopened panel finds a recording that is still going.",
inputSchema: {},
outputSchema: traceSiftCaptureSchema.shape, annotations: read,
_meta: { ui: { visibility } },
}, async (_input, context) => {
const startedAt = performance.now();
try {
const state = await client.captureState();
recordTraceSiftOperation("record", "success", performance.now() - startedAt);
return { content: [{ type: "text", text: state.recording ? "A TraceSift recording is running." : "No TraceSift recording is running." }], structuredContent: state };
} catch (error) { return failure(error, "record", startedAt, context.signal); }
});

registerAppTool(server, "mobile_tracesift_launch_browser", {
title: "Open a web app in a Chrome TraceSift can record",
description: "Launch Chrome against a throwaway profile with the DevTools Protocol enabled and open the given http or https address, leaving it running. Sign in or navigate there by hand, then start the recording so the trace covers the state you reached. Opens a window on this machine.",
inputSchema: { url: traceSiftCaptureUrlSchema },
outputSchema: traceSiftCaptureSchema.shape, annotations: localService,
_meta: { ui: { visibility: ["app"] } },
}, async ({ url }, context) => {
const startedAt = performance.now();
try {
const state = await client.launchBrowser(url, context.signal);
recordTraceSiftOperation("record", "success", performance.now() - startedAt);
return { content: [{ type: "text", text: "Chrome is open and ready to record." }], structuredContent: state };
} catch (error) { return failure(error, "record", startedAt, context.signal); }
});

registerAppTool(server, "mobile_tracesift_start_recording", {
title: "Start recording the open browser",
description: "Begin a Chrome performance trace of whatever the TraceSift-launched browser is already showing, without navigating it. The trace is written as a local file rather than streamed into the running TraceSift, so the panel can analyze it by path. Only one recording runs at a time.",
inputSchema: {},
outputSchema: { recording: z.literal(true) }, annotations: localService,
_meta: { ui: { visibility: ["app"] } },
}, async (_input, context) => {
const startedAt = performance.now();
try {
await client.startRecording(context.signal);
recordTraceSiftOperation("record", "success", performance.now() - startedAt);
return { content: [{ type: "text", text: "Recording." }], structuredContent: { recording: true as const } };
} catch (error) { return failure(error, "record", startedAt, context.signal); }
});

registerAppTool(server, "mobile_tracesift_stop_recording", {
title: "Stop recording and keep the trace",
description: "End the running recording, wait for the trace to be written, and return the file it wrote. Stops whichever recording is running, React Native or Chrome. Pass the returned path to mobile_analyze_profile to read it. A launched browser stays open, so another recording can follow from the same signed-in session.",
inputSchema: {},
outputSchema: { path: z.string(), sizeBytes: z.number(), eventCount: z.number() }, annotations: persist,
_meta: { ui: { visibility } },
}, async (_input, context) => {
const startedAt = performance.now();
try {
const capture = await client.stopRecording(context.signal);
recordTraceSiftOperation("record", "success", performance.now() - startedAt);
return { content: [{ type: "text", text: `Recorded ${capture.eventCount.toLocaleString()} events.` }], structuredContent: capture };
} catch (error) { return failure(error, "record", startedAt, context.signal); }
});

registerAppTool(server, "mobile_tracesift_close_browser", {
title: "Close the browser TraceSift launched",
description: "Quit the Chrome opened for recording and delete the throwaway profile it used. Refuses while a recording is still running. Traces already written are left alone.",
inputSchema: {},
outputSchema: { closed: z.literal(true) }, annotations: persist,
_meta: { ui: { visibility: ["app"] } },
}, async (_input, context) => {
const startedAt = performance.now();
try {
await client.closeBrowser(context.signal);
recordTraceSiftOperation("record", "success", performance.now() - startedAt);
return { content: [{ type: "text", text: "The browser is closed." }], structuredContent: { closed: true as const } };
} catch (error) { return failure(error, "record", startedAt, context.signal); }
});

registerAppTool(server, "mobile_tracesift_list_react_native_targets", {
title: "List the React Native apps TraceSift could record",
description: "Return the React Native apps connected to the Metro dev server, each with the id that names it and which one would be recorded by default. Use before recording to confirm an app is connected, or to pick between several. Reads Metro's inspector and starts nothing; Metro must be running and the app must be a debug build.",
inputSchema: { metro: traceSiftMetroUrlSchema.optional() },
outputSchema: traceSiftRnTargetsSchema.shape, annotations: read,
_meta: { ui: { visibility } },
}, async ({ metro }, context) => {
const startedAt = performance.now();
try {
const targets = await client.reactNativeTargets(metro, context.signal);
recordTraceSiftOperation("record", "success", performance.now() - startedAt);
const text = targets.targets.length
? `Metro on ${targets.metro} has ${targets.targets.length} React Native app(s): ${targets.targets.map(target => `${target.title}${target.recommended ? " (default)" : ""} [${target.id}]`).join("; ")}.`
: `Metro on ${targets.metro} has no React Native app connected to it.`;
return { content: [{ type: "text", text }], structuredContent: targets };
} catch (error) { return failure(error, "record", startedAt, context.signal); }
});

registerAppTool(server, "mobile_tracesift_record_react_native", {
title: "Start recording a running React Native app",
description: "Begin a Hermes CPU profile of a React Native app that is already running in a simulator, emulator or device, over the Metro inspector. Nothing is launched and no window opens, so you can drive the recording yourself: start it, exercise the app or ask the user to, then stop it with mobile_tracesift_stop_recording to get the trace. Picks the connected app on its own unless targetId names one; requires Metro to be running and a debug build, and refuses while React Native DevTools is attached or another recording is going.",
inputSchema: { metro: traceSiftMetroUrlSchema.optional(), targetId: z.string().min(1).max(512).optional() },
outputSchema: { recording: z.literal(true) }, annotations: localService,
_meta: { ui: { visibility } },
}, async ({ metro, targetId }, context) => {
const startedAt = performance.now();
try {
await client.startReactNativeRecording({ metro, targetId }, context.signal);
recordTraceSiftOperation("record", "success", performance.now() - startedAt);
return { content: [{ type: "text", text: "Recording. Use the app, then stop the recording." }], structuredContent: { recording: true as const } };
} catch (error) { return failure(error, "record", startedAt, context.signal); }
});

registerAppTool(server, "mobile_get_tracesift_project", {
title: "Read the project TraceSift findings are investigated in",
description: "Return the source folder a TraceSift investigation should land in, as the panel last chose it. Returns none when nothing is chosen or the saved folder no longer exists.",
Expand Down
Loading
Loading