跳转到内容
Telemetry
浏览文档

指南更新于 2026年10月2日阅读约需 9 分钟

让编程智能体使用这篇文档

打开 Claude Code、Codex、Cursor 或其他编码代理的集中提示包,然后将其适应此处介绍的工作流程。

本页内容
  1. 当这个模式适合时
  2. 定义客户合同
  3. 实施应用程序拥有的端点
  4. 应用四个服务器端控件
  5. 认证
  6. 允许名单
  7. 速率限制
  8. 添加可信上下文
  9. 选择移动交付行为
  10. 查询并验证结果
  11. 需要规划的故障模式
  12. 主要参考文献

安全的移动应用遥测代理

移动应用程序分发到您无法控制的设备。任何编译到 React Native 包、Swift 应用程序、Kotlin 应用程序或 Flutter 二进制文件中的内容最终都可以被检查。请勿在应用程序中传送 Telemetry API 密钥、传送到应用程序的远程配置值或客户端可读的环境文件。

相反,将一个小事件发送到您的应用程序拥有的经过身份验证的端点。该端点验证固定合同,添加受信任的服务器上下文,并使用服务器端密钥转发事件。

移动端代理示意图:应用将事件发送至自己的 API,API 验证身份、校验允许的事件并限制请求速率,再转发至 Telemetry。

移动端代理示意图:应用将事件发送至自己的 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;

在推出之前,请验证:

  1. 提供的应用程序二进制文件和 JavaScript 捆绑包不包含 Telemetry API 密钥。
  2. 未知的事件和字段会被拒绝,而不是默默地转发。
  3. 不存在原始异常文本、URL、用户输入、凭据和设备标识符。
  4. 重试保留同一个 event_id。此工厂函数允许重复事件;计算结果前请检查冲突的重复载荷,并由应用负责去重。
  5. 仪表板在比率和百分位数旁边显示样本计数。
  6. 同意、保留、删除和帐户删除行为符合应用程序策略。

需要规划的故障模式

将代理视为尽力可观测性,除非该事件是单独设计的持久业务工作流程的一部分。通常应记录 Telemetry 超时并对其进行限制,而不会导致移动产品操作失败。监控代理拒绝率、转发失败、队列寿命和事件新鲜度,以便静默检测中断看起来不像产品使用率下降。

不要自动接受任意客户端事件以“使调试更容易”。这样一来,端点就会接受团队尚未审核的字段。仅在查看其目的、类型、基数、隐私分类和删除要求后才添加新的版本化字段或事件。

主要参考文献

相关功能

记录稳定的事件名称、类型明确的字段,以及经过隐私审核的上下文。

页面作者和参考资料

Telemetry 编辑团队负责维护本文;产品团队审核功能行为、示例和适用范围。

我们如何审核文档