安全的移動應用遙測代理
移動應用程式分發到您無法控制的裝置。任何編譯到 React Native 包、Swift 應用程式、Kotlin 應用程式或 Flutter 二進位制檔案中的內容最終都可以被檢查。請勿在應用程式中傳送 Telemetry API 金鑰、傳送到應用程式的遠端設定值或客戶端可讀的環境檔案。
相反,將一個小事件傳送到您的應用程式擁有的經過身分驗證的端點。該端點驗證固定合約,新增受信任的伺服器上下文,並使用伺服器端金鑰轉發事件。
移動端代理示意圖:應用將事件傳送至自己的 API,API 驗證身分、校驗允許的事件並限制請求速率,再轉發至 Telemetry。 移動應用不會收到 Telemetry 的攝取金鑰。
當這個模式適合時
使用移動代理來獲取產品里程碑、有限效能測量、同步結果、受控錯誤類別和發布品質訊號。範例包括:
- 本地到伺服器同步達到最終結果後
mobile_sync_completed。 mobile_screen_ready用於一小組命名螢幕和測量的持續時間。- 伺服器確認持久結果後
mobile_purchase_flow_completed。 mobile_api_request_completed用於取樣、分類的依賴性結果。
這不能替代崩潰符號、裝置日誌、分散式追蹤或精確的稽核分類帳。移動交付受到連線、後台執行限制、應用程式終止、同意和客戶端時鐘品質的影響。將專業診斷保留在其專業系統中,並僅傳送 SQL 分析所需的結果。
定義客戶合約
客戶端應從事件名稱和欄位的版本列表中進行選擇。它不應選擇 Telemetry 資料表名、傳送任意屬性包或轉發異常訊息。
{
"event_name": "mobile_sync_completed",
"event_version": 1,
"event_id": "0196f37e-6e83-7b75-9d04-6b8283b35f74",
"occurred_at": "2026-07-30T18:42:11.150Z",
"status": "success",
"duration_ms": 842,
"item_count": 12,
"network_type": "wifi"
}
保持尺寸有界。優先選擇受控的 screen_name 而不是原始路由,優先選擇 error_type 列舉而不是異常字串,優先選擇粗略的 network_type 而不是網路識別符號。請勿包含存取權杖、裝置廣告識別符號、聯絡人資料、訊息內容、檔案路徑、剪貼簿資料、自由格式搜尋文字或原始 URL。
event_id 可用於識別重試,但此範例不會去重,也不保證恰好一次交付。請使用伺服器接收時間檢查資料新鮮度,因為裝置時鐘可能不準確。
實施應用程式擁有的端點
以下工廠函式僅在伺服器執行,透過 Log API 轉送用戶端回報的同步觀察。請在應用程式中註冊傳回的處理函式:authenticate 使用已驗證的應用程式工作階段;allowRequest 使用帳戶和工作階段限流,並設定全域上限;apiKey 來自伺服器設定;environment 為固定值。此函式不實作身分驗證、同意流程、限流儲存或請求本文讀取期限。不要把金鑰傳給行動用戶端。
type Actor = {
accountId: string;
deviceSessionId: string;
platform: "ios" | "android";
appVersion: string;
};
type Dependencies = {
authenticate: (request: Request) => Promise<Actor | null>;
allowRequest: (actor: Actor) => Promise<boolean>;
apiKey: string;
environment: "production" | "staging" | "development";
diagnostic?: () => void;
};
const fields = new Set([
"event_name", "event_version", "event_id", "occurred_at",
"status", "duration_ms", "item_count", "network_type",
]);
const statuses = new Set(["success", "failed", "cancelled"]);
const networks = new Set(["wifi", "cellular", "ethernet", "offline", "unknown"]);
async function readBoundedJson(request: Request) {
if (!request.body) return { ok: false as const, status: 400 };
const reader = request.body.getReader();
const chunks: Uint8Array[] = [];
let bytes = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
bytes += value.byteLength;
if (bytes > 4096) {
void reader.cancel().catch(() => {});
return { ok: false as const, status: 413 };
}
chunks.push(value);
}
const joined = new Uint8Array(bytes);
let offset = 0;
for (const chunk of chunks) {
joined.set(chunk, offset);
offset += chunk.byteLength;
}
const text = new TextDecoder("utf-8", { fatal: true }).decode(joined);
return { ok: true as const, value: JSON.parse(text) as unknown };
} catch {
return { ok: false as const, status: 400 };
} finally {
reader.releaseLock();
}
}
function integerBetween(value: unknown, maximum: number): value is number {
return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= maximum;
}
function boundedText(value: unknown, maximum: number): value is string {
return typeof value === "string" && value.length > 0 && value.length <= maximum;
}
function clientTimestamp(value: unknown, receivedAt: number) {
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)) return false;
const parsed = Date.parse(value);
return Number.isFinite(parsed) && new Date(parsed).toISOString() === value &&
parsed >= receivedAt - 86_400_000 && parsed <= receivedAt + 300_000;
}
export function createMobileTelemetryHandler(deps: Dependencies) {
return async function postMobileTelemetry(request: Request) {
const reportFailure = () => { try { deps.diagnostic?.(); } catch {} };
try {
if (request.method !== "POST") return new Response(null, { status: 405, headers: { Allow: "POST" } });
const actor = await deps.authenticate(request);
if (!actor) return new Response("unauthorized", { status: 401 });
const type = request.headers.get("content-type")?.split(";")[0].trim().toLowerCase();
if (type !== "application/json") return new Response("unsupported content type", { status: 415 });
if (!(await deps.allowRequest(actor))) return new Response("rate limited", { status: 429 });
const parsed = await readBoundedJson(request);
if (!parsed.ok) return new Response("invalid body", { status: parsed.status });
const input = parsed.value;
if (input === null || typeof input !== "object" || Array.isArray(input)) {
return new Response("invalid event", { status: 422 });
}
const body = input as Record<string, unknown>;
const receivedAt = Date.now();
if (
Object.keys(body).some(key => !fields.has(key)) ||
body.event_name !== "mobile_sync_completed" || body.event_version !== 1 ||
typeof body.event_id !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(body.event_id) ||
!clientTimestamp(body.occurred_at, receivedAt) ||
typeof body.status !== "string" || !statuses.has(body.status) ||
!integerBetween(body.duration_ms, 300_000) || !integerBetween(body.item_count, 10_000) ||
typeof body.network_type !== "string" || !networks.has(body.network_type)
) return new Response("invalid event", { status: 422 });
if (
!deps.apiKey || !boundedText(actor.accountId, 128) || !boundedText(actor.deviceSessionId, 128) ||
!boundedText(actor.appVersion, 80) || !["ios", "android"].includes(actor.platform) ||
!["production", "staging", "development"].includes(deps.environment)
) return new Response("collector unavailable", { status: 503 });
const response = await fetch("https://api.telemetry.sh/log", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${deps.apiKey}` },
signal: AbortSignal.timeout(1000),
body: JSON.stringify({ table: "mobile_sync_completed", data: {
event_id: body.event_id, event_version: 1, occurred_at: body.occurred_at,
received_at: new Date(receivedAt).toISOString(), status: body.status,
duration_ms: body.duration_ms, item_count: body.item_count,
network_type: body.network_type, account_id: actor.accountId,
app_platform: actor.platform, app_version: actor.appVersion,
environment: deps.environment,
} }),
});
if (!response.ok) {
void response.body?.cancel().catch(() => {});
reportFailure();
return new Response("collection unavailable", { status: 503 });
}
void response.body?.cancel().catch(() => {});
return new Response(null, { status: 202 });
} catch {
reportFailure();
return new Response("collection unavailable", { status: 503 });
}
};
}
端點固定目標表,並檢查實際位元組數、JSON 結構、未知欄位、數值和伺服器情境。occurred_at 必須是帶毫秒的 UTC 時間,最多早於接收時間 24 小時或晚於接收時間五分鐘;請依應用程式需求明確調整。用戶端回報的狀態不能證明後端已提交資料,權威結果應由後端發出。
202 表示上游 HTTP 回應成功,不證明持久儲存、去重或 SQL 讀回。匯出失敗傳回 503,診斷失敗不會覆蓋該回應。遙測請求應在產品操作完成後獨立傳送。一秒的 AbortSignal 以協作方式限制轉送,不是驗證、限流和讀取請求本文的總期限。此函式沒有持久佇列或自動重試。
應用四個伺服器端控制元件
認證
需要 API 的其餘部分使用相同的簽名應用程式會話或安裝憑據。 CORS 不是移動安全邊界,僅自定義標頭並不能證明誰傳送了請求。
允許名單
拒絕未知的事件名稱、欄位、列舉值、超大字串、無效數字、未來時間戳和超出小尺寸限制的正文。允許名單既是隱私控制又是基數控制。
速率限制
透過經過身分驗證的帳戶和適當的安裝或會話識別符號進行限制。還施加了全球上限。當客戶端超出限制時,返回正常的應用程式錯誤,而不會無限期地重試。
新增可信上下文
代理應新增帳戶識別符號、伺服器接收時間、環境和經過驗證的應用程式版本(如果這些值可用)。如果帳戶識別符號在您的上下文中敏感,請在收集之前對其進行一致的假名化,並記錄誰可以反轉對映。
選擇移動交付行為
對於 React Native 和 Flutter,請使用已經負責經過身分驗證的 API 請求的平臺 HTTP 客戶端。對於本機 iOS 和 Android,請使用應用程式使用的相同 URLSession 或 HTTP 堆疊。端點和事件契約在不同平臺上應該保持相同。
當離線交付很重要時,保持一個小的有界佇列。限制專案數量和壽命,丟棄超出記錄視窗的事件,並使用帶有抖動的指數退避。不要讓分析重試延遲產品操作。在嘗試中保留相同的 event_id ,以便伺服器可以進行重複資料刪除。
有些結果最好由伺服器發出。購買、訂閱更改、存取策略決策或完成的匯入僅在後端提交後才具有權威性。讓伺服器直接發出這些結果,而不是信任客戶端斷言。
查詢並驗證結果
先使用捕獲傳輸的離線測試,涵蓋成功、失敗、取消、無效或過大的 JSON、未認證、限流、上游失敗和重複 ID。不要把虛構成功寫入 mobile_sync_completed;獨立的攝取測試資料應使用 telemetry_quickstart。
隨後在自己的應用程式中執行一次已獲同意的真實同步,並把查詢中的 ID 換成觀察到的 event_id。測試資料或用戶端狀態本身不證明真實整合或持久業務結果:
SELECT
received_at,
event_id,
app_platform,
app_version,
status,
duration_ms,
item_count
FROM mobile_sync_completed
WHERE event_id = 'your-real-observed-event-id'
ORDER BY received_at DESC
LIMIT 50;
在推出之前,請驗證:
- 提供的應用程式二進位制檔案和 JavaScript 捆綁包不包含 Telemetry API 金鑰。
- 未知的事件和欄位會被拒絕,而不是默默地轉發。
- 不存在原始異常文字、URL、使用者輸入、憑據和裝置識別符號。
- 重試保留同一個
event_id。此工廠函式允許重複事件;計算結果前請檢查衝突的重複酬載,並由應用程式負責去重。 - 儀表板在比率和百分位數旁邊顯示樣本計數。
- 同意、保留、刪除和帳戶刪除行為符合應用程式策略。
需要規劃的故障模式
將代理視為盡力可觀測性,除非該事件是單獨設計的持久業務工作流程的一部分。通常應記錄 Telemetry 超時並對其進行限制,而不會導致移動產品操作失敗。監控代理拒絕率、轉發失敗、佇列壽命和事件新鮮度,以便靜默檢測中斷看起來不像產品使用率下降。
不要自動接受任意客戶端事件以“使除錯更容易”。這樣一來,端點就會接受團隊尚未審查的欄位。僅在檢視其目的、型別、基數、隱私分類和刪除要求後才新增新的版本化欄位或事件。