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
19 changes: 15 additions & 4 deletions docs/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,25 @@ the server-owned installation ID as `distinct_id`. Tools `isFrequentTool` lists
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.
the IP, and reports rejected batches in its reply.

Each connected session also queues an installation profile with the same
`distinct_id`, `host`, `plugin_version` and `os`. Profiles are sent through
`api-eu.mixpanel.com/engage?ip=0&verbose=1` with `$ip: "0"`, so they appear in
Mixpanel's Users view without IP-derived location, names or account details.
Existing events join the profile by installation ID. Profiles are pseudonymous,
not a guarantee of anonymisation under data protection law. A failed profile
update stays pending until the next flush; closing makes one final attempt and
discards any pending update. Profile failures do not prevent event delivery.

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.
analytics, including profile updates. `analytics.flush` counts each Mixpanel request
with `component=server` and `outcome=success` only when Mixpanel accepted the batch.
The bounded `endpoint` attribute distinguishes `track` event batches from `engage`
profile updates; before installation profiles, the metric counted only event batches.
The panel UI sends nothing to Mixpanel, and its CSP is unchanged.

## Volume policy

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "mobile-dev",
"version": "0.1.176",
"version": "0.1.177",
"private": true,
"workspaces": [
"packages/shared",
Expand Down
45 changes: 34 additions & 11 deletions packages/core/src/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ 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 { isFrequentTool, MIXPANEL_TOKEN, MIXPANEL_TRACK_URL, MIXPANEL_ENGAGE_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";
Expand All @@ -14,6 +14,7 @@ const BATCH_SIZE = 50;
const MAX_QUEUED = 500;

type AnalyticsEvent = { event: string; properties: Record<string, string | number> };
type AnalyticsProfile = { $token: string; $distinct_id: string; $ip: "0"; $set: Record<string, string> };
type Send = (url: string, init: RequestInit) => Promise<Response>;

/** Services analytics uses; tests replace them. */
Expand All @@ -37,41 +38,55 @@ 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"> {
async function deliver(send: Send, url: string, batch: AnalyticsEvent[] | AnalyticsProfile[]): Promise<"success" | "failed"> {
try {
const response = await send(MIXPANEL_TRACK_URL, {
const body = JSON.stringify(batch);
const signal = AbortSignal.timeout(5000);
const response = await send(url, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(batch),
signal: AbortSignal.timeout(5000),
body,
signal,
});
const reply = await response.json() as { status?: number };
const reply: { status?: number } = await response.json();
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.
* Sends pseudonymous product analytics to Mixpanel from release servers: connected host sessions and
* the tools they call, plus an installation profile with its host, plugin version and OS. Never sends
* arguments, results, device details, paths or IP properties. 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 pendingProfile: AnalyticsProfile | undefined;
let sending: Promise<void> | undefined;
let closed = false;

async function drain() {
while (queue.length > 0) recordAnalyticsFlush(await deliver(send, queue.splice(0, BATCH_SIZE)));
const profile = pendingProfile;
if (profile !== undefined) {
const outcome = await deliver(send, MIXPANEL_ENGAGE_URL, [profile]);
recordAnalyticsFlush(outcome, "engage");
if (outcome === "success" && pendingProfile === profile) pendingProfile = undefined;
}
while (queue.length > 0) {
const batch = queue.splice(0, BATCH_SIZE);
const outcome = await deliver(send, MIXPANEL_TRACK_URL, batch);
recordAnalyticsFlush(outcome, "track");
}
}
function flush() {
sending ??= drain().finally(() => { sending = undefined; });
return sending;
}
function track(event: string, properties: Record<string, string> = {}) {
if (queue.length >= MAX_QUEUED) return;
if (closed || 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,
Expand All @@ -98,12 +113,20 @@ export function instrumentProductAnalytics(server: McpServer, options: Analytics

return {
sessionStarted(clientName) {
if (closed) return;
host = serverTelemetryHost(clientName);
pendingProfile = {
$token: token, $distinct_id: distinctId, $ip: "0",
$set: { host, plugin_version: PLUGIN_VERSION, os: process.platform },
};
track("Session Started");
},
async close() {
if (closed) return sending;
closed = true;
clearInterval(timer);
await flush();
pendingProfile = undefined;
},
};
}
4 changes: 2 additions & 2 deletions packages/core/src/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,9 @@ export function recordTraceSiftPayload(kind: "task" | "react", bytes: number) {
}

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

export function recordAccessibilityBridgeRestart(outcome: "recovered" | "unavailable" | "failed") {
Expand Down
137 changes: 125 additions & 12 deletions packages/core/test/analytics.test.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,41 @@
import test from "node:test";
import assert from "node:assert/strict";
import { setImmediate } from "node:timers/promises";
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 { MIXPANEL_TRACK_URL, MIXPANEL_ENGAGE_URL, TELEMETRY_INTERVAL_MS } from "../../shared/src/telemetry.ts";
import { PLUGIN_VERSION } from "../../shared/src/version.ts";
import { instrumentProductAnalytics } from "../src/analytics.ts";
import type { AnalyticsOptions } from "../src/analytics.ts";

type SentEvent = { event: string; properties: Record<string, unknown> };
type SentProfile = { $token: string; $distinct_id: string; $ip: string; $set: Record<string, string> };

function mixpanel(status = 1) {
const requests: { url: string; events: SentEvent[] }[] = [];
const requests: { url: string; body: string }[] = [];
const send = async (url: string, init: RequestInit) => {
requests.push({ url, events: JSON.parse(String(init.body)) });
const body = String(init.body);
requests.push({ url, body });
return Response.json({ error: status === 1 ? "" : "rejected", status });
};
return { requests, send, events: () => requests.flatMap(request => request.events) };
function events() {
const batches = requests.filter(request => request.url === MIXPANEL_TRACK_URL);
return batches.flatMap(request => {
const batch: SentEvent[] = JSON.parse(request.body);
return batch;
});
}
function profiles() {
const batches = requests.filter(request => request.url === MIXPANEL_ENGAGE_URL);
return batches.flatMap(request => {
const batch: SentProfile[] = JSON.parse(request.body);
return batch;
});
}
return { requests, send, events, profiles };
}

/** A release server with three tools, connected to a client named `clientName`. */
Expand Down Expand Up @@ -45,23 +63,32 @@ async function withEnv(key: string, value: string, run: () => Promise<void>) {
}
}

test("product analytics sends sessions and tool calls to Mixpanel's EU endpoint without IPs or content", async () => {
const { requests, send, events } = mixpanel();
test("product analytics joins EU installation profiles to events without IPs or content", async () => {
const { requests, send, events, profiles } = 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.equal(requests.length, 2);
assert.equal(requests[0].url, MIXPANEL_ENGAGE_URL);
assert.equal(requests[1].url, MIXPANEL_TRACK_URL);
assert.match(MIXPANEL_TRACK_URL, /^https:\/\/api-eu\.mixpanel\.com\/track\?(?=.*\bip=0\b)(?=.*\bverbose=1\b)/);
assert.match(MIXPANEL_ENGAGE_URL, /^https:\/\/api-eu\.mixpanel\.com\/engage\?(?=.*\bip=0\b)(?=.*\bverbose=1\b)/);
const sentProfiles = profiles();
assert.deepEqual(sentProfiles, [{
$token: "token", $distinct_id: "anon_1", $ip: "0",
$set: { host: "claude", plugin_version: PLUGIN_VERSION, os: process.platform },
}]);
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");
const [profile] = sentProfiles;
assert.equal(profile.$distinct_id, session.properties.distinct_id);
});

test("product analytics leaves out polling tools", async () => {
Expand Down Expand Up @@ -96,17 +123,103 @@ test("product analytics batches at Mixpanel's limit and counts batches Mixpanel
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 { send: record, requests } = mixpanel(0);
const delivery = Promise.withResolvers<void>();
const send = async (url: string, init: RequestInit) => {
await delivery.promise;
return record(url, init);
};
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();
const closing = analytics.close();
delivery.resolve();
await closing;
const eventRequests = requests.filter(request => request.url === MIXPANEL_TRACK_URL);
const batchSizes = eventRequests.map(request => {
const batch: SentEvent[] = JSON.parse(request.body);
return batch.length;
});
assert.deepEqual(batchSizes, [50, 50, 21]);
const requestCount = requests.length;
await analytics.close();
assert.deepEqual(requests.map(request => request.events.length), [50, 50, 21]);
await analytics.close();
assert.equal(requests.length, 3);
assert.equal(requests.length, requestCount);
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);
const trackEndpoint = sent.includes('"endpoint":{"value":"track"');
const engageEndpoint = sent.includes('"endpoint":{"value":"engage"');
const includesIdentity = sent.includes("anon_1");
assert.ok(trackEndpoint);
assert.ok(engageEndpoint);
assert.equal(includesIdentity, false);
});

test("a rejected profile retries on the existing timer without blocking events or sending after close", async t => {
t.mock.timers.enable({ apis: ["setInterval"] });
const recorded = mixpanel();
let profileAttempts = 0;
const send = async (url: string, init: RequestInit) => {
const response = await recorded.send(url, init);
if (url === MIXPANEL_ENGAGE_URL) {
profileAttempts += 1;
if (profileAttempts === 1) return Response.json({ status: 0, error: "rejected" });
}
return response;
};
const { client, analytics } = await connect({ send });
t.after(async () => {
await client.close();
await analytics.close();
});
t.mock.timers.tick(TELEMETRY_INTERVAL_MS);
await setImmediate();
assert.equal(profileAttempts, 1);
const events = recorded.events();
assert.equal(events.length, 1, "A rejected profile must not drop session events.");

t.mock.timers.tick(TELEMETRY_INTERVAL_MS);
await setImmediate();
assert.equal(profileAttempts, 2);
assert.equal(recorded.requests.length, 3, "The retry must not resend accepted events.");
t.mock.timers.tick(TELEMETRY_INTERVAL_MS);
await setImmediate();
assert.equal(profileAttempts, 2, "An accepted profile is not sent again during the session.");

await analytics.close();
analytics.sessionStarted("codex");
t.mock.timers.tick(TELEMETRY_INTERVAL_MS);
await analytics.close();
assert.equal(recorded.requests.length, 3);
});

test("closing waits for an in-flight profile and flushes queued events", async () => {
const recorded = mixpanel();
const gate = Promise.withResolvers<Response>();
const send = async (url: string, init: RequestInit) => {
const response = await recorded.send(url, init);
if (url === MIXPANEL_ENGAGE_URL) return gate.promise;
return response;
};
const { client, analytics } = await connect({ send });
await client.close();
let closed = false;
const closing = analytics.close();
const observedClosing = closing.then(() => { closed = true; });
let secondClosed = false;
const secondClosing = analytics.close();
const observedSecondClosing = secondClosing.then(() => { secondClosed = true; });
await setImmediate();
assert.equal(closed, false);
assert.equal(secondClosed, false);
assert.equal(recorded.requests.length, 1);
const response = Response.json({ status: 1 });
gate.resolve(response);
await observedClosing;
await observedSecondClosing;
const events = recorded.events();
assert.equal(events.length, 1);
assert.equal(recorded.requests.length, 2);
});
1 change: 1 addition & 0 deletions packages/shared/src/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const MIXPANEL_TOKEN = "780e4817b73c1234479ad8d418d34e89";
* `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 MIXPANEL_ENGAGE_URL = "https://api-eu.mixpanel.com/engage?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;
Expand Down
2 changes: 1 addition & 1 deletion packages/shared/src/version.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const PLUGIN_VERSION = "0.1.176";
export const PLUGIN_VERSION = "0.1.177";
11 changes: 8 additions & 3 deletions website/src/content/docs/docs/privacy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: 3 November 2026
Last updated: 5 November 2026

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

Expand Down Expand Up @@ -109,8 +109,13 @@ 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.
not derive a location from your IP address or store it in your analytics records.
Each connected session also creates or updates a pseudonymous installation profile
using that same random installation ID, host, plugin version, and operating system
type. This lets us view the installation's existing events together in Mixpanel.
Profiles contain no names, email addresses or account details. A persistent ID and
usage history are not a guarantee of anonymisation. We do not combine these
analytics with other data about you or use them for advertising.

### Updates and support

Expand Down
Loading