安全的移动应用遥测代理
移动应用程序分发到您无法控制的设备。任何编译到 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 超时并对其进行限制,而不会导致移动产品操作失败。监控代理拒绝率、转发失败、队列寿命和事件新鲜度,以便静默检测中断看起来不像产品使用率下降。
不要自动接受任意客户端事件以“使调试更容易”。这样一来,端点就会接受团队尚未审核的字段。仅在查看其目的、类型、基数、隐私分类和删除要求后才添加新的版本化字段或事件。