diff --git a/docs/telemetry.md b/docs/telemetry.md index 904f299f..50183da6 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 6f9cb542..10c04b59 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mobile-dev", - "version": "0.1.176", + "version": "0.1.177", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mobile-dev", - "version": "0.1.176", + "version": "0.1.177", "workspaces": [ "packages/shared", "packages/core", diff --git a/package.json b/package.json index 14eac602..c3464ca3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mobile-dev", - "version": "0.1.176", + "version": "0.1.177", "private": true, "workspaces": [ "packages/shared", diff --git a/packages/core/src/analytics.ts b/packages/core/src/analytics.ts index 5af8d010..baf2105c 100644 --- a/packages/core/src/analytics.ts +++ b/packages/core/src/analytics.ts @@ -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"; @@ -14,6 +14,7 @@ const BATCH_SIZE = 50; const MAX_QUEUED = 500; type AnalyticsEvent = { event: string; properties: Record }; +type AnalyticsProfile = { $token: string; $distinct_id: string; $ip: "0"; $set: Record }; type Send = (url: string, init: RequestInit) => Promise; /** Services analytics uses; tests replace them. */ @@ -37,23 +38,25 @@ 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; @@ -61,17 +64,29 @@ export function instrumentProductAnalytics(server: McpServer, options: Analytics const distinctId = userId; const queue: AnalyticsEvent[] = []; let host: TelemetryHost = "other"; + let pendingProfile: AnalyticsProfile | undefined; let sending: Promise | 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 = {}) { - 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, @@ -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; }, }; } diff --git a/packages/core/src/telemetry.ts b/packages/core/src/telemetry.ts index 43ba05b2..80529c21 100644 --- a/packages/core/src/telemetry.ts +++ b/packages/core/src/telemetry.ts @@ -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") { diff --git a/packages/core/test/analytics.test.ts b/packages/core/test/analytics.test.ts index 71ba67e6..88fb466d 100644 --- a/packages/core/test/analytics.test.ts +++ b/packages/core/test/analytics.test.ts @@ -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 }; +type SentProfile = { $token: string; $distinct_id: string; $ip: string; $set: Record }; 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`. */ @@ -45,16 +63,23 @@ async function withEnv(key: string, value: string, run: () => Promise) { } } -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"); @@ -62,6 +87,8 @@ test("product analytics sends sessions and tool calls to Mixpanel's EU endpoint 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 () => { @@ -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(); + 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(); + 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); }); diff --git a/packages/shared/src/telemetry.ts b/packages/shared/src/telemetry.ts index 1be1402f..5a9f1842 100644 --- a/packages/shared/src/telemetry.ts +++ b/packages/shared/src/telemetry.ts @@ -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; diff --git a/packages/shared/src/version.ts b/packages/shared/src/version.ts index 89e0920b..b6a430d8 100644 --- a/packages/shared/src/version.ts +++ b/packages/shared/src/version.ts @@ -1 +1 @@ -export const PLUGIN_VERSION = "0.1.176"; +export const PLUGIN_VERSION = "0.1.177"; diff --git a/website/src/content/docs/docs/privacy.md b/website/src/content/docs/docs/privacy.md index b1a633b3..636ece78 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: 3 November 2026 +Last updated: 5 November 2026 ## 1. Who we are and what this policy covers @@ -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