Skip to content
Merged
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
41 changes: 37 additions & 4 deletions docs/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,9 @@ Since 0.1.147, app control tools (`mobile_list_apps`, `mobile_launch_app`,
message, because command output can contain bundle IDs, URLs, device serials, and
local paths. Expected outcomes such as a missing app, an unhandled URL, an
unrequested permission, or a stopped device count only through
`server.error.outcome`. Extracted app icons and Android labels stay in the server's
`server.error.outcome`. Since 0.1.170 these reports and outcome counts also carry
the device command tags described below, and a simulator that is shutting down
counts as a stopped device. Extracted app icons and Android labels stay in the server's
memory cache and are returned only to the panel.

The device popover's App tab records `ui.app_tools.list`, `.icons`, `.launch`,
Expand Down Expand Up @@ -322,9 +324,14 @@ Since 0.1.152, `mobile_app_language` and `mobile_set_app_language` use the same
app control coverage under `app_control.language` and `app_control.set_language`.
Language tags are not sent. The device settings tools report unexpected Baguette or
serve-emu failures under `device_settings.read` or `device_settings.<setting>` with a
fixed message. Android throttling runs through a local proxy on the Mac, so its adb
failures report under `device_settings.network`; throttling a device that already
uses its own proxy counts only through `server.error.outcome`. When the plugin first
fixed message. Since 0.1.170 they carry the device command tags described below,
and a failure on a device that stopped or went away during the request counts as
`device_unavailable` through `server.error.outcome` instead of a report. The check
reads the device list without starting a backend, giving Baguette two seconds; when
Baguette or adb cannot list devices, the failure is reported as before. Android throttling runs through a local
proxy on the Mac, so its adb failures report under `device_settings.network`;
throttling a device that already uses its own proxy counts only through
`server.error.outcome`. When the plugin first
starts an Android backend, it clears a throttle left by a plugin process that exited;
unexpected adb failures there report under `device_settings.network_recovery`, and a
device that went away counts only through `server.error.outcome`. Proxy
Expand All @@ -335,6 +342,32 @@ records `ui.device_settings.change` round trips in milliseconds and counts
`ui.device_settings.failure`, with the same surface and device-change exclusions as
the App tab. Setting values, throttle presets, and device IDs are not sent.

Device setting and app control reports keep their fixed messages, so their Sentry
issues keep grouping as before. Since 0.1.170 they add these device command tags,
which their `server.error.outcome` counts also carry:

- `device_platform`: `ios` or `android`, from the tool's target.
- `device_command`: the program and subcommand or backend route that failed, such
as `simctl_launch`, `simctl_privacy`, `adb_shell_am`, `adb_install`, `plutil`,
`baguette_interface`, `baguette_start`, `serve_emu_night_mode`, or
`serve_emu_start`. A Baguette start that fails on its device list or its own
process names `baguette_simulators` or `baguette_serve` instead of
`baguette_start`. It is absent when the failure did not come from a named
command, such as a reply that failed validation. Labels come from code; anything
else is sent as `other`.
- `device_command_failure`: `exit`, `signal`, `deadline`, `cancelled`,
`output_limit`, `missing_executable`, `system_error`, `connection_failed`,
`http_status`, `rejected`, `invalid_response`, `unexpected_output`, or `unknown`.
The value comes from error codes, HTTP statuses, and error types. Messages and
output are never read.
- `device_command_status`: the exit status (0 to 255) for `exit`, or the HTTP
status for `http_status`.
- `device_command_signal`: the signal name for `signal`, or `other`.

Arguments, output, replies, bundle IDs, URLs, serials, and paths are not sent.
serve-emu startup failures under `device_settings.*` keep their `android_startup_*`
tags.

Saved chart cards use the `recording` surface and view. Existing readiness,
interaction and frame-pacing coverage is preserved. `ui.recording.process` and
`ui.recording.derive` measure result validation and chart/summary processing;
Expand Down
9 changes: 5 additions & 4 deletions packages/core/src/app-control/android.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import { join } from "node:path";
import { foregroundAndroidPackage } from "../device-apps/foreground.ts";
import { runningDeviceApps } from "../device-apps/apps.ts";
import { ExpectedOperationError } from "@mobile-dev/shared/error-reporting.ts";
import { annotateDeviceCommand } from "@mobile-dev/shared/device-command-diagnostics.ts";
import { platformPermissions } from "@mobile-dev/shared/app-control.ts";
import type { AppDetails, AppPermission, AppPermissionEntry, InstalledApp, PermissionState } from "@mobile-dev/shared/app-control.ts";
import { commandError, commandOutput, runAdb, withTemporaryDirectory } from "./commands.ts";
import { commandError, commandLabel, commandOutput, runAdb, withTemporaryDirectory } from "./commands.ts";
import type { Commands } from "./commands.ts";
import { collectDetails, pngDataUrl, rankAppLinks, requireArtifact, requireSupportedPermission, urlSchemePattern } from "./driver.ts";
import type { AppDetailsCache, AppDriver } from "./driver.ts";
Expand Down Expand Up @@ -130,7 +131,7 @@ export function parseAndroidAppLocales(output: string): string | null {
if (/Unknown package/.test(output)) throw new ExpectedOperationError("invalid_input", "The app is not installed on this device.");
if (localeUnavailable.test(output)) throw new ExpectedOperationError("unsupported_platform", "Per-app languages need Android 13 or later.");
const list = /are \[([^\]]*)\]/.exec(output);
if (!list) throw new Error("Reading the app language failed: unexpected cmd locale output.");
if (!list) throw annotateDeviceCommand(new Error("Reading the app language failed: unexpected cmd locale output."), "adb_shell_cmd", { failure: "unexpected_output" });
return list[1].split(",").map(entry => entry.trim()).find(Boolean) ?? null;
}

Expand Down Expand Up @@ -159,7 +160,7 @@ export class AndroidDriver implements AppDriver {
catch (error) {
const failed = commandOutput(error);
if (tolerate?.test(failed)) return failed;
throw rejected(failed) ?? commandError(error, action);
throw rejected(failed) ?? commandError(error, action, commandLabel("adb_shell", command));
}
const failure = rejected(output);
if (failure) throw failure;
Expand Down Expand Up @@ -245,7 +246,7 @@ export class AndroidDriver implements AppDriver {
// set-app-locales reports an unknown package or missing service on stdout with exit status 0.
const output = await this.shell("Changing the app language", `cmd locale set-app-locales ${bundleId}${language === null ? "" : ` --locales ${language}`}; cmd locale get-app-locales ${bundleId}`, 15000, localeUnavailable);
const applied = parseAndroidAppLocales(output);
if (applied?.toLowerCase() !== language?.toLowerCase()) throw new Error("Changing the app language failed: Android kept a different language.");
if (applied?.toLowerCase() !== language?.toLowerCase()) throw annotateDeviceCommand(new Error("Changing the app language failed: Android kept a different language."), "adb_shell_cmd", { failure: "unexpected_output" });
}

async push(): Promise<void> {
Expand Down
15 changes: 11 additions & 4 deletions packages/core/src/app-control/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { access, mkdtemp, readdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ExpectedOperationError, expectedOutcome } from "@mobile-dev/shared/error-reporting.ts";
import { annotateDeviceCommand, classifyCommandFailure } from "@mobile-dev/shared/device-command-diagnostics.ts";
import { adbPath } from "../native-logs.ts";
import { androidSdkRoots } from "../android-sdk.ts";

Expand Down Expand Up @@ -55,23 +56,29 @@ export function commandOutput(error: unknown): string {
return `${stderr}\n${stdout}`.trim();
}

const unavailable = /current state: Shutdown|not booted|Invalid device|device offline|device unauthorized|device '.*' not found|no devices\/emulators found/i;
// A simulator that stopped or is stopping, or an Android device that went offline or away.
const unavailable = /current state: (?:Shutdown|Shutting Down)|not booted|Invalid device|Unable to find device|No device found|device offline|device unauthorized|device (?:'.*' )?not found|no devices\/emulators found/i;

// Names a command for reports by its program and subcommand, which code chooses; later arguments can name apps, URLs and paths.
export const commandLabel = (program: string, command: string) => `${program}_${command.split(" ", 1)[0].replaceAll("-", "_")}`;

// Keep only the readable simctl or adb lines; "Command failed: ..." repeats every argument.
export function commandError(error: unknown, action: string): Error {
export function commandError(error: unknown, action: string, command?: string): Error {
if (expectedOutcome(error) !== undefined) return error as Error;
const output = commandOutput(error);
const message = output.split("\n").map(line => line.trim()).filter(line => line && !/^Underlying error|^An error was encountered/.test(line)).slice(0, 3).join(" ");
const text = `${action} failed${message ? `: ${message}` : "."}`;
if (unavailable.test(output)) return new ExpectedOperationError("device_unavailable", text);
if (error !== null && typeof error === "object" && "code" in error && error.code === "ENOENT") return new ExpectedOperationError("unsupported_platform", `${action} needs ${process.platform === "darwin" ? "Xcode or the Android SDK" : "macOS and Xcode"}.`);
return new Error(text, { cause: error });
const failure = new Error(text, { cause: error });
// Reports name the command and how it ended instead of repeating its output.
return command === undefined ? failure : annotateDeviceCommand(failure, command, classifyCommandFailure(error));
}

// Runs adb against one device, turning failures into readable errors.
export async function runAdb(commands: Commands, serial: string, action: string, args: string[], timeout = 15000): Promise<string> {
try { return await commands.run(await commands.adb(), ["-s", serial, ...args], { timeout }); }
catch (error) { throw commandError(error, action); }
catch (error) { throw commandError(error, action, commandLabel("adb", args[0])); }
}

export async function withTemporaryDirectory<T>(work: (directory: string) => Promise<T>): Promise<T> {
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/app-control/simulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { runningSimulatorApps } from "../device-apps/apps.ts";
import { ExpectedOperationError, expectedOutcome } from "@mobile-dev/shared/error-reporting.ts";
import { bundleIdSchema, platformPermissions } from "@mobile-dev/shared/app-control.ts";
import type { AppDetails, AppPermission, AppPermissionEntry, InstalledApp, PermissionState } from "@mobile-dev/shared/app-control.ts";
import { commandError, commandOutput, withTemporaryDirectory } from "./commands.ts";
import { commandError, commandLabel, commandOutput, withTemporaryDirectory } from "./commands.ts";
import type { Commands } from "./commands.ts";
import { collectDetails, pngDataUrl, rankAppLinks, requireArtifact, requireSupportedPermission, urlSchemePattern } from "./driver.ts";
import type { AppDetailsCache, AppDriver } from "./driver.ts";
Expand Down Expand Up @@ -63,7 +63,7 @@ export class SimulatorDriver implements AppDriver {
catch (error) {
const output = commandOutput(error);
if (tolerate?.test(output)) return "";
const failure = commandError(error, action);
const failure = commandError(error, action, commandLabel("simctl", args[0]));
const match = expectedOutcome(failure) === undefined ? known.find(([pattern]) => pattern.test(output)) : undefined;
throw match ? new ExpectedOperationError("invalid_input", match[1]) : failure;
}
Expand All @@ -74,7 +74,7 @@ export class SimulatorDriver implements AppDriver {
private async listing(): Promise<SimulatorApp[]> {
const listing = await this.simctl("Listing apps", ["listapps", this.deviceId]);
try { return parseSimulatorApps(await this.plutil(["-convert", "json", "-o", "-", "-"], listing)); }
catch (error) { throw commandError(error, "Reading the app list"); }
catch (error) { throw commandError(error, "Reading the app list", "plutil"); }
}

async installed() { return (await this.listing()).map(({ bundleId, name }) => ({ bundleId, name })); }
Expand Down
11 changes: 7 additions & 4 deletions packages/core/src/app-control/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { androidIdSchema } from "../serve-emu.ts";
import { captureServerError } from "../telemetry.ts";
import { errorMessage, udidSchema } from "@mobile-dev/shared/protocol.ts";
import { ExpectedOperationError, expectedOutcome } from "@mobile-dev/shared/error-reporting.ts";
import { ExpectedOperationError } from "@mobile-dev/shared/error-reporting.ts";
import { deviceCommandTags } from "@mobile-dev/shared/device-command-diagnostics.ts";
import { appDetailsBatch, appPermissions, appTargetShape, appUrlSchema, bundleIdSchema, languageTagSchema, permissionStates } from "@mobile-dev/shared/app-control.ts";
import { systemCommands } from "./commands.ts";
import type { Commands } from "./commands.ts";
Expand Down Expand Up @@ -53,9 +54,11 @@ function appTools(server: McpServer, driverFor: DriverFactory, visibility: ("app
const result = await handler(driverFor(parsed), parsed);
return { content: [{ type: "text" as const, text: JSON.stringify(result) }], structuredContent: result };
} catch (error) {
// Command output can contain app IDs, URLs, and device serials, so Sentry receives only the action.
// Expected outcomes carry fixed reasons and are counted without a report.
captureServerError(expectedOutcome(error) === undefined ? new Error(`App ${action} command failed.`) : error, `app_control.${action}`);
// Command output can contain app IDs, URLs, and device serials, so Sentry receives the action and how its
// command failed. Expected outcomes carry fixed reasons and are counted without a report.
captureServerError(error, `app_control.${action}`, {
reportAs: new Error(`App ${action} command failed.`), tags: { device_platform: parsed.platform, ...deviceCommandTags(error) },
});
return { isError: true, content: [{ type: "text" as const, text: errorMessage(error) }] };
}
});
Expand Down
Loading
Loading