diff --git a/PRIVACY.md b/PRIVACY.md index 52bcfba3..d2f8d12e 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -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 @@ -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 @@ -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 @@ -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. @@ -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/). @@ -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. | @@ -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 diff --git a/docs/telemetry.md b/docs/telemetry.md index 251e0afd..4f171878 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -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 diff --git a/packages/core/src/analytics.ts b/packages/core/src/analytics.ts new file mode 100644 index 00000000..5af8d010 --- /dev/null +++ b/packages/core/src/analytics.ts @@ -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 }; +type Send = (url: string, init: RequestInit) => Promise; + +/** 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; +}; + +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 | 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 = {}) { + 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(); + }, + }; +} diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index 8e7d1112..27d206d8 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -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"; @@ -70,10 +71,13 @@ export async function createPlugin(html: string | (() => Promise), 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, { @@ -144,7 +148,7 @@ export async function createPlugin(html: string | (() => Promise), 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(); } }, }; } diff --git a/packages/core/src/telemetry.ts b/packages/core/src/telemetry.ts index c50602ea..2f1465f2 100644 --- a/packages/core/src/telemetry.ts +++ b/packages/core/src/telemetry.ts @@ -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"; @@ -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 }; @@ -286,9 +293,14 @@ const allowedContext: Record = { 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); } diff --git a/packages/core/test/analytics.test.ts b/packages/core/test/analytics.test.ts new file mode 100644 index 00000000..71ba67e6 --- /dev/null +++ b/packages/core/test/analytics.test.ts @@ -0,0 +1,112 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import * as Sentry from "@sentry/node"; +import type { Envelope } from "@sentry/core"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { MIXPANEL_TRACK_URL } from "../../shared/src/telemetry.ts"; +import { instrumentProductAnalytics } from "../src/analytics.ts"; +import type { AnalyticsOptions } from "../src/analytics.ts"; + +type SentEvent = { event: string; properties: Record }; + +function mixpanel(status = 1) { + const requests: { url: string; events: SentEvent[] }[] = []; + const send = async (url: string, init: RequestInit) => { + requests.push({ url, events: JSON.parse(String(init.body)) }); + return Response.json({ error: status === 1 ? "" : "rejected", status }); + }; + return { requests, send, events: () => requests.flatMap(request => request.events) }; +} + +/** A release server with three tools, connected to a client named `clientName`. */ +async function connect(options: AnalyticsOptions, clientName = "claude-code") { + const server = new McpServer({ name: "test", version: "1" }); + const analytics = instrumentProductAnalytics(server, { token: "token", environment: "release", userId: "anon_1", ...options }); + server.server.oninitialized = () => { analytics.sessionStarted(server.server.getClientVersion()?.name); }; + server.registerTool("mobile_logs_session", {}, async () => ({ content: [] })); + server.registerTool("mobile_repair_input", {}, async () => ({ content: [], isError: true })); + server.registerTool("mobile_read_logs", {}, async () => ({ content: [] })); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: clientName, version: "1" }); + await client.connect(clientTransport); + return { client, analytics }; +} + +async function withEnv(key: string, value: string, run: () => Promise) { + const previous = process.env[key]; + process.env[key] = value; + try { await run(); } + finally { + if (previous === undefined) delete process.env[key]; + else process.env[key] = previous; + } +} + +test("product analytics sends sessions and tool calls to Mixpanel's EU endpoint without IPs or content", async () => { + const { requests, send, events } = mixpanel(); + const { client, analytics } = await connect({ send }); + await client.callTool({ name: "mobile_logs_session", arguments: {} }); + await client.callTool({ name: "mobile_repair_input", arguments: {} }); + await client.close(); + await analytics.close(); + assert.equal(requests.length, 1); + assert.equal(requests[0].url, MIXPANEL_TRACK_URL); + assert.match(MIXPANEL_TRACK_URL, /^https:\/\/api-eu\.mixpanel\.com\/track\?(?=.*\bip=0\b)(?=.*\bverbose=1\b)/); + const [session, logs, repair] = events(); + assert.equal(session.event, "Session Started"); + assert.equal(session.properties.host, "claude"); + assert.deepEqual([logs.event, logs.properties.tool, logs.properties.outcome], ["Tool Called", "mobile_logs_session", "success"]); + assert.deepEqual([repair.properties.tool, repair.properties.outcome], ["mobile_repair_input", "error"]); + assert.deepEqual(Object.keys(logs.properties).sort(), ["$insert_id", "distinct_id", "host", "os", "outcome", "plugin_version", "time", "token", "tool"]); + assert.equal(logs.properties.distinct_id, "anon_1"); +}); + +test("product analytics leaves out polling tools", async () => { + const { send, events } = mixpanel(); + const { client, analytics } = await connect({ send }); + await client.callTool({ name: "mobile_read_logs", arguments: {} }); + await client.close(); + await analytics.close(); + assert.deepEqual(events().map(event => event.event), ["Session Started"]); +}); + +test("product analytics sends nothing in development, without a token, or when turned off", async () => { + const { send, requests } = mixpanel(); + async function session(options: AnalyticsOptions) { + const { client, analytics } = await connect({ send, ...options }); + await client.callTool({ name: "mobile_logs_session", arguments: {} }); + await client.close(); + await analytics.close(); + } + await session({ environment: "development" }); + await session({ token: "" }); + for (const [key, value] of [["MOBILE_DEV_TELEMETRY", "off"], ["MOBILE_DEV_ANALYTICS", "off"], ["DO_NOT_TRACK", "1"]]) { + await withEnv(key, value, () => session({})); + } + assert.equal(requests.length, 0); +}); + +test("product analytics batches at Mixpanel's limit and counts batches Mixpanel rejects as failed", async t => { + const envelopes: Envelope[] = []; + Sentry.init({ + dsn: "https://public@example.com/1", defaultIntegrations: false, + transport: () => ({ async send(envelope) { envelopes.push(envelope); return { statusCode: 200 }; }, async flush() { return true; } }), + }); + t.after(async () => { await Sentry.close(); }); + const { send, requests } = mixpanel(0); + const { client, analytics } = await connect({ send }); + for (let index = 0; index < 120; index += 1) await client.callTool({ name: "mobile_logs_session", arguments: {} }); + await client.close(); + await analytics.close(); + assert.deepEqual(requests.map(request => request.events.length), [50, 50, 21]); + await analytics.close(); + assert.equal(requests.length, 3); + await Sentry.flush(); + const sent = JSON.stringify(envelopes); + assert.ok(sent.includes("analytics.flush")); + assert.ok(sent.includes('"outcome":{"value":"failed"')); + assert.equal(sent.includes('"outcome":{"value":"success"'), false); +}); diff --git a/packages/shared/src/telemetry.ts b/packages/shared/src/telemetry.ts index b5c40858..d59a2555 100644 --- a/packages/shared/src/telemetry.ts +++ b/packages/shared/src/telemetry.ts @@ -8,6 +8,13 @@ export const SENTRY_SERVER_DSN = "https://2ee03a9449e1f1b48e3e7c7606b6f562@o4512 export const SENTRY_NATIVE_DSN = "https://1deeefda39022a67df902c638dcf3c8f@o4512180958068736.ingest.de.sentry.io/4512181036318800"; export const SENTRY_ORIGIN = "https://o4512180958068736.ingest.de.sentry.io"; export const SENTRY_RELEASE = `mobile-dev@${PLUGIN_VERSION}`; +/** Public token of the EU-resident Mixpanel project for product analytics. */ +export const MIXPANEL_TOKEN = "780e4817b73c1234479ad8d418d34e89"; +/** + * Mixpanel's EU ingestion endpoint. `ip=0` stops Mixpanel from storing or geolocating the sender's IP; + * `verbose=1` makes rejected batches say so, since Mixpanel answers them with HTTP 200 too. + */ +export const MIXPANEL_TRACK_URL = "https://api-eu.mixpanel.com/track?ip=0&verbose=1"; export const TELEMETRY_META_KEY = "mobile-dev/telemetry"; export const TELEMETRY_INTERVAL_MS = 30_000; export const TELEMETRY_HOSTS = ["codex", "claude", "cursor", "mac", "other"] as const; diff --git a/website/src/content/docs/docs/privacy.md b/website/src/content/docs/docs/privacy.md index 977f33cb..b1a633b3 100644 --- a/website/src/content/docs/docs/privacy.md +++ b/website/src/content/docs/docs/privacy.md @@ -3,7 +3,7 @@ title: Privacy Policy description: Read how Mobile Dev processes local development data, shares results with your host, collects diagnostics, and provides privacy controls. --- -Last updated: 23 October 2026 +Last updated: 3 November 2026 ## 1. Who we are and what this policy covers @@ -65,8 +65,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 @@ -97,6 +97,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 @@ -129,6 +144,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. @@ -144,6 +161,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/). @@ -153,6 +171,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. | @@ -174,6 +193,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