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
28 changes: 25 additions & 3 deletions PRIVACY.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Mobile Dev Privacy Policy

Last updated: 23 October 2026
Last updated: 3 November 2026

## 1. Who we are and what this policy covers

Expand Down Expand Up @@ -62,8 +62,8 @@ Use test data, scope requests to the relevant app, and review content before
sharing it. Do not provide passwords, API keys, payment-card data, health records,
or government identifiers through these workflows.

Mobile Dev does not upload this development content to Callstack's Sentry projects
as telemetry. This exclusion does not prevent a tool result from reaching the
Mobile Dev does not upload this development content to Callstack's Sentry or
Mixpanel projects as telemetry. This exclusion does not prevent a tool result from reaching the
host or content you deliberately email to support from reaching Callstack.

### Diagnostics and usage telemetry
Expand Down Expand Up @@ -94,6 +94,21 @@ and local home paths from error text. Session Replay, minidumps, and profiling
are disabled in the configured Sentry integrations. Scrubbing is a safeguard,
not a reason to put secrets into your test data.

### Product analytics

Release builds send basic product analytics to Mixpanel so we can see how many
installations use Mobile Dev and which tools they use most. Events record that a
host session started or that a tool was called, with the tool's name, whether it
succeeded, the host (Codex, Claude, Cursor, the Mac app or other), the plugin
version, the operating system type, a timestamp, and the random installation ID
described above. Frequent polling and input tools are not recorded.

Analytics events never contain MCP arguments or results, app or device content,
device names or identifiers, project paths, or account details. They go to
Mixpanel's EU ingestion endpoint with IP collection turned off, so Mixpanel does
not store your IP address or derive a location from it. We do not create user
profiles, combine analytics with other data about you, or use them for advertising.

### Updates and support

Release builds check GitHub for plugin updates. GitHub receives the network
Expand Down Expand Up @@ -126,6 +141,8 @@ telemetry controls and the right to object.
Callstack. The configured ingestion endpoints are in Sentry's German region.
This alone is not a promise that every support, subprocessor, or administrative
operation takes place exclusively in Germany.
- **Mixpanel:** processes the product analytics described above for Callstack,
in its EU data residency region.
- **OpenAI or another host you use:** receives tool results and content supplied
through that host. Its processing and retention are governed by your relationship
with that provider, not the Sentry opt-out.
Expand All @@ -141,6 +158,7 @@ Where personal data is transferred outside the EEA, applicable transfer
requirements must be met, such as an adequacy decision or appropriate contractual
safeguards. Contact us for information about safeguards applicable to your data.
See also [Sentry's privacy policy](https://sentry.io/privacy/),
[Mixpanel's privacy policy](https://mixpanel.com/legal/privacy-policy/),
[GitHub's privacy statement](https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement),
and [OpenAI's privacy policy](https://openai.com/policies/privacy-policy/).

Expand All @@ -150,6 +168,7 @@ and [OpenAI's privacy policy](https://openai.com/policies/privacy-policy/).
| --- | --- |
| Sentry errors/crashes | 90 days under our Sentry Team plan. |
| Sentry performance spans and Application Metrics | 30 days under our Sentry Team plan. |
| Mixpanel product analytics | Up to 24 months, then deleted. |
| Local live logs | Held in bounded memory buffers. A server log session expires after five minutes without reads or keep-alives; closing the session stops collection. Copies already attached to the host follow the host's retention. |
| Saved performance recordings | Stored under `~/Library/Application Support/mobile-dev/recordings` until you remove them; the recording store has no automatic age-based expiry. |
| TraceSift integration results/references and the chosen project folder | Stored under `~/.tracesift/integrations/mobile-dev` by default, or the corresponding `TRACE_SIFT_HOME` directory, until removed. Original profiles and the TraceSift service's own data are separate. |
Expand All @@ -173,6 +192,9 @@ operating system, and your own backup tools manage their copies separately.
The setting disables new reporting across the integrated JavaScript/native
components and prevents creation of a new installation ID. It does not delete
reports already sent or disable GitHub update checks or host processing.
- **Disable product analytics only:** set `MOBILE_DEV_ANALYTICS=off` (or
`DO_NOT_TRACK=1`) in the same environment and restart. Mixpanel events stop;
Sentry error and performance reporting continues unless you also turn it off.
- **Reset the installation ID:** stop Mobile Dev processes and remove only the
`anonymous-user-id` file listed above. A new ID is generated on the next start
if telemetry remains enabled. This does not delete reports associated with the
Expand Down
21 changes: 21 additions & 0 deletions docs/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,27 @@ so the page receives the same server-owned environment, opt-out and anonymous
identity as the Codex panel. Mac builds are development-only for now; the SwiftUI
shell has no native Sentry SDK.

## Product analytics

Since 0.1.177, release MCP servers send basic product analytics to Mixpanel's EU
project to count installations and feature use. `instrumentProductAnalytics`
(`packages/core/src/analytics.ts`) wraps the `tools/call` handler and reports
`Tool Called` with the tool name and a `success` or `error` outcome; the chat
client's initialization reports `Session Started`. Every event carries only
`host`, `plugin_version`, `os` (`process.platform`), a timestamp, an insert ID and
the server-owned installation ID as `distinct_id`. Tools `isFrequentTool` lists are
skipped. Events are batched (50 per request, at most 500 queued), sent every 30
seconds and when the plugin closes; failed batches are dropped. Requests go to
`api-eu.mixpanel.com/track?ip=0&verbose=1`: Mixpanel neither stores nor geolocates
the IP, and reports rejected batches in its reply. No user profiles are created.

Development builds send nothing, and neither does the iOS mirror service. The
choice is made when the server starts: `MOBILE_DEV_TELEMETRY=off` turns analytics
off along with Sentry; `MOBILE_DEV_ANALYTICS=off` or `DO_NOT_TRACK=1` turns off only
analytics. `analytics.flush` counts each Mixpanel request with `component=server`
and `outcome=success` only when Mixpanel accepted the batch. The panel UI sends
nothing to Mixpanel, and its CSP is unchanged.

## Volume policy

Since 0.1.164, application metrics in the UI, Node servers and native helpers use
Expand Down
109 changes: 109 additions & 0 deletions packages/core/src/analytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { randomUUID } from "node:crypto";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import type { CallToolRequest, CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { PLUGIN_VERSION } from "@mobile-dev/shared/version.ts";
import { isFrequentTool, MIXPANEL_TOKEN, MIXPANEL_TRACK_URL, TELEMETRY_INTERVAL_MS } from "@mobile-dev/shared/telemetry.ts";
import type { TelemetryEnvironment, TelemetryHost } from "@mobile-dev/shared/telemetry.ts";
import { TELEMETRY_ENVIRONMENT } from "./telemetry-environment.ts";
import { getTelemetryIdentity } from "./telemetry-identity.ts";
import { recordAnalyticsFlush, serverTelemetryHost } from "./telemetry.ts";

// Mixpanel takes at most 50 events per request; the queue stays bounded while it is unreachable.
const BATCH_SIZE = 50;
const MAX_QUEUED = 500;

type AnalyticsEvent = { event: string; properties: Record<string, string | number> };
type Send = (url: string, init: RequestInit) => Promise<Response>;

/** Services analytics uses; tests replace them. */
export type AnalyticsOptions = { token?: string; environment?: TelemetryEnvironment; userId?: string; send?: Send };

export type ProductAnalytics = {
/** Counts a connected host session, which gives active installations per host. */
sessionStarted(clientName: string | undefined): void;
/** Sends what is queued and stops sending. */
close(): Promise<void>;
};

const disabled: ProductAnalytics = { sessionStarted() {}, async close() {} };

// The SDK's generic handler schema type does not overlap its own exported schemas, so compare untyped.
function isCallTool(schema: unknown) {
return schema === CallToolRequestSchema;
}

function optedOut() {
return process.env.MOBILE_DEV_TELEMETRY === "off" || process.env.MOBILE_DEV_ANALYTICS === "off" || process.env.DO_NOT_TRACK === "1";
}

async function deliver(send: Send, batch: AnalyticsEvent[]): Promise<"success" | "failed"> {
try {
const response = await send(MIXPANEL_TRACK_URL, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(batch),
signal: AbortSignal.timeout(5000),
});
const reply = await response.json() as { status?: number };
return response.ok && reply.status === 1 ? "success" : "failed";
} catch { return "failed"; }
}

/**
* Sends anonymous product analytics to Mixpanel from release servers: connected host sessions and the
* tools they call. Events carry only the tool name and outcome, host, plugin version, OS and the
* installation ID; never arguments, results, device details, paths or IPs. Install before tools register.
*/
export function instrumentProductAnalytics(server: McpServer, options: AnalyticsOptions = {}): ProductAnalytics {
const { token = MIXPANEL_TOKEN, environment = TELEMETRY_ENVIRONMENT, userId = getTelemetryIdentity()?.userId, send = fetch } = options;
if (token === "" || environment !== "release" || userId === undefined || optedOut()) return disabled;
const distinctId = userId;
const queue: AnalyticsEvent[] = [];
let host: TelemetryHost = "other";
let sending: Promise<void> | undefined;

async function drain() {
while (queue.length > 0) recordAnalyticsFlush(await deliver(send, queue.splice(0, BATCH_SIZE)));
}
function flush() {
sending ??= drain().finally(() => { sending = undefined; });
return sending;
}
function track(event: string, properties: Record<string, string> = {}) {
if (queue.length >= MAX_QUEUED) return;
queue.push({ event, properties: {
...properties, token, distinct_id: distinctId, time: Date.now(), $insert_id: randomUUID(),
host, plugin_version: PLUGIN_VERSION, os: process.platform,
} });
if (queue.length >= BATCH_SIZE) void flush();
}

// Polling and input tools measure pacing, not feature use.
const register = server.server.setRequestHandler.bind(server.server);
server.server.setRequestHandler = (schema, handler) => {
if (isCallTool(schema) === false) return register(schema, handler);
register(schema, async (request, extra) => {
const { name } = (request as CallToolRequest).params;
if (isFrequentTool(name)) return handler(request, extra);
let result: CallToolResult;
try { result = await handler(request, extra) as CallToolResult; }
catch (error) { track("Tool Called", { tool: name, outcome: "error" }); throw error; }
track("Tool Called", { tool: name, outcome: result.isError === true ? "error" : "success" });
return result;
});
};
const timer = setInterval(() => { void flush(); }, TELEMETRY_INTERVAL_MS);
timer.unref();

return {
sessionStarted(clientName) {
host = serverTelemetryHost(clientName);
track("Session Started");
},
async close() {
clearInterval(timer);
await flush();
},
};
}
8 changes: 6 additions & 2 deletions packages/core/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mc
import { PLUGIN_VERSION } from "@mobile-dev/shared/version.ts";
import { SENTRY_ORIGIN } from "@mobile-dev/shared/telemetry.ts";
import { instrumentMcpServer, setServerTelemetryHost } from "./telemetry.ts";
import { instrumentProductAnalytics } from "./analytics.ts";
import { resolveTelemetryEnvironment } from "./telemetry-environment.ts";
import { getTelemetryIdentity } from "./telemetry-identity.ts";
import { registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server";
Expand Down Expand Up @@ -70,10 +71,13 @@ export async function createPlugin(html: string | (() => Promise<UIResource>), o
const fps = new DisplayFpsSessions();
const server = new McpServer({ name: "mobile-dev", version: PLUGIN_VERSION }, { instructions: panel?.instructions ?? INSTRUCTIONS });
instrumentMcpServer(server);
const analytics = instrumentProductAnalytics(server);
addToolIcons(server, { mobile_open_workspace: PHONE_ICONS, mobile_open_simulator: PHONE_ICONS, mobile_choose_devices: PHONE_ICONS });
const extensions = new OpenAIExtensions(server);
server.server.oninitialized = () => {
setServerTelemetryHost(server.server.getClientVersion()?.name);
const clientName = server.server.getClientVersion()?.name;
setServerTelemetryHost(clientName);
analytics.sessionStarted(clientName);
// The device form goes with Mobile Dev's own device views, for hosts that render one.
const form = panel && deviceChoiceFormSupport(server.server.getClientCapabilities());
if (form) registerDeviceChoiceTools(server, extensions, {
Expand Down Expand Up @@ -144,7 +148,7 @@ export async function createPlugin(html: string | (() => Promise<UIResource>), o
// Recordings finish while the collectors they read are still open.
await recordings.close();
try { await Promise.all([logs.close(), cpu.close(), fps.close(), traceSift.close(), closePanel?.()]); }
finally { android.dispose(); await server.close(); }
finally { android.dispose(); await server.close(); await analytics.close(); }
},
};
}
16 changes: 14 additions & 2 deletions packages/core/src/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
import type { McpServer, ResourceTemplate, ResourceMetadata, ReadResourceCallback, ReadResourceTemplateCallback, RegisteredResource, RegisteredResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import { MeasurementWindow, TELEMETRY_HOSTS, TELEMETRY_INTERVAL_MS, TELEMETRY_META_KEY, telemetryHost } from "@mobile-dev/shared/telemetry.ts";
import type { TelemetryHost } from "@mobile-dev/shared/telemetry.ts";
import { ExpectedOperationError, expectedOutcome, isRepeatedFailure } from "@mobile-dev/shared/error-reporting.ts";
import { closeNativeTelemetry } from "./native-telemetry.ts";
import { closeHttpRuntime } from "./http-runtime.ts";
Expand Down Expand Up @@ -70,6 +71,12 @@ export function recordTraceSiftPayload(kind: "task" | "react", bytes: number) {
Sentry.metrics.gauge("tracesift.explore.payload_bytes", bytes, { unit: "byte", attributes });
}

/** Counts each batch sent to Mixpanel by whether Mixpanel accepted it. */
export function recordAnalyticsFlush(outcome: "success" | "failed") {
if (process.env.MOBILE_DEV_TELEMETRY === "off") return;
Sentry.metrics.count("analytics.flush", 1, { attributes: { component: "server", outcome } });
}

export function recordAccessibilityBridgeRestart(outcome: "recovered" | "unavailable" | "failed") {
if (process.env.MOBILE_DEV_TELEMETRY === "off") return;
const attributes = { component: "server", device_platform: "ios", device_kind: "simulator", outcome };
Expand Down Expand Up @@ -286,9 +293,14 @@ const allowedContext: Record<string, readonly string[]> = {
device_platform: ["ios", "android", "mixed"], device_kind: ["physical", "simulator", "emulator", "none"], host: TELEMETRY_HOSTS,
};

/** Attributes server telemetry to the host that launched it: the Mac app sets `MOBILE_DEV_HOST`, other hosts are named by their MCP client. */
/** The host that launched the server: the Mac app sets `MOBILE_DEV_HOST`, other hosts are named by their MCP client. */
export function serverTelemetryHost(clientName: string | undefined): TelemetryHost {
return process.env.MOBILE_DEV_HOST === "mac" ? "mac" : telemetryHost(clientName);
}

/** Attributes server telemetry to the host that launched it. */
export function setServerTelemetryHost(clientName: string | undefined) {
const host = process.env.MOBILE_DEV_HOST === "mac" ? "mac" : telemetryHost(clientName);
const host = serverTelemetryHost(clientName);
Sentry.setTag("host", host);
Sentry.setAttribute("host", host);
}
Expand Down
Loading
Loading