跳转至

中继可观测性

面向维护者。正在使用 T3 Code?请参阅 docs/user。

中继 Alchemy 技术栈拥有一个共享的 Axiom trace 设置:

  • t3-code-relay-traces-prod,由 Worker、移动应用和第一方中继客户端共享的 OpenTelemetry trace 数据集
  • t3-code-relay-otel-ingest-prod,数据集作用域的 Worker ingest token
  • t3-code-mobile-otel-ingest-prod,数据集作用域的移动端 ingest token
  • t3-code-relay-client-otel-ingest-prod,数据集作用域的第一方中继客户端 ingest token
  • t3-code-relay-recent-spans-prod,近期请求和端点 span 的视图
  • t3-code-mobile-logs-prod 和 t3-code-mobile-metrics-prod,移动应用来自 expo-observe 的崩溃、错误和启动数据(参见 移动端崩溃与错误)
  • t3-code-mobile-telemetry-ingest-prod,Worker 转发该数据时使用的 ingest token
  • t3-code-mobile-sourcemaps-prod,移动端 JavaScript source map 的 R2 存储桶,以及 t3-code-mobile-sourcemaps-upload-prod,一个只能向其写入对象的 Cloudflare token

Alchemy 各阶段会附加经过清洗的阶段名称以隔离资源,例如个人阶段使用 t3-code-relay-traces-dev-julius。

按照常规 Alchemy 工作流从 infra/relay 部署:

vp run deploy

Alchemy 通过其 provider 解析账户级别的 Axiom 部署凭据。在运行时,Worker 只接收其作用域内的 ingest token。移动端和中继客户端使用各自单独配置的作用域 ingest token。

Worker 会发出 Effect 内置的 HTTP 服务器 span,以及端点和数据库子 span。Effect 的 OpenTelemetry 导出器将语义 HTTP 属性存储在 attributes. 前缀之下。例如:

['t3-code-relay-traces-prod']
| where kind == 'server'
| extend endpoint = column_ifexists('attributes.http.route', ''),
    customAttributes = column_ifexists('attributes.custom', dynamic({}))
| project _time, name, trace_id, duration,
    ['attributes.http.request.method'],
    ['attributes.url.path'],
    ['attributes.http.response.status_code'],
    endpoint,
    relayOperation = customAttributes['relay']['operation']
| order by _time desc
| limit 200

预置视图也读取 attributes.http.route 中的端点。中继特定的 span 注释存储在 attributes.custom 下;relay.operation 是发出的自定义属性之一。

对于已完成的事故,代理应优先使用预置视图或 APL 查询,而不是持续跟踪 Cloudflare Worker。该技术栈不预置单独的查询 token。需要脚本化查询访问的响应者可使用经授权的账户级 AXIOM_TOKEN 以及 AXIOM_ORG_ID;作用域 ingest token 对其生产方而言仍然是仅写入的凭据。

DPoP 证明失败包含稳定的 relay.dpop.failure_code span 属性。time_window 失败表示已签名的证明对于中继允许的时间窗口而言太旧或太超前。它可能指向任一端设备上的日期或时间问题,但也可能由延迟的请求导致。客户端使用此类别,以及来自较旧中继的无类别情况,来判断时钟偏移是已确认还是仅作为一种可能原因。

Webhook

一个公共 webhook 请求对应一个 relay.hooks.forward span。其 relay.hook.outcome 表示发生了什么:forwarded、held、rate_limited、inbox_full、not_found、payload_too_large、environment_unavailable 或 environment_timeout。relay.hook.endpoint_key 标识受管端点,并据此标识环境。在转发时,relay.hook.upstream_status 或 relay.hook.upstream_error 记录环境的响应,而 relay.hook.upstream_outcome 记录环境对请求的处理结果(accepted、duplicate、prompt_too_long、queue_full、rejected_signature、expired、disabled 等),来自其 x-t3-hook-outcome 响应头。被持有的请求在投递时会在其 relay.inbox.deliver span 上记录相同信息。中继在每次转发时发送自己的 traceparent,并丢弃发送方提供的任何此类头;同时中继会对每次转发签名(x-t3-relay-delivery),以便环境只信任来自中继的投递 ID、接收时间和 trace 上下文,因此在导出到 T3 Connect 的环境中,环境的 ScheduledTaskService.triggerWebhook span 会落在同一 trace 中。relay.hook.rate_limit 表示哪个配额已用尽:endpoint 或 hook。当 Cloudflare 速率限制器不可用且请求无限制通过时,会设置 relay.hook.rate_limiter_failed_open。

被持有的请求在端点的 HookInboxObject 中处理,对它的每次调用都构成独立的 trace,而不是转发 span 的子级:relay.inbox.hold、relay.inbox.wake,以及每次告警运行时的 relay.inbox.deliver。每个都携带 relay.hook.endpoint_key,因此可以关联到转发。详细信息位于子 span 上:当收件箱拒绝请求时,HookInboxStore.hold 携带 relay.inbox.refused(max_per_hook、max_requests、max_bytes 或 already_held)。HookInboxStore.deliverDue 携带 relay.inbox.run_result(drained、more_pending、busy 或 unreachable)、发送和投递的请求数量、重试延迟背后的连续失败次数、投递请求等待的最长时间以及剩余积压。直接出错的一次运行会在根 relay.inbox.deliver span 上标记 relay.inbox.run_result = failed。

这些查询可以回答的问题:

// 每小时的 Webhook 结果
['t3-code-relay-traces-prod']
| where name == 'relay.hooks.forward'
| summarize count() by bin(_time, 1h), outcome = tostring(['attributes.custom']['relay.hook.outcome'])

// 因环境不可达而卡住的收件箱,按端点分组
['t3-code-relay-traces-prod']
| where name == 'HookInboxStore.deliverDue'
| extend c = ['attributes.custom']
| where tostring(c['relay.inbox.run_result']) == 'unreachable'
| project trace_id, failures = toint(c['relay.inbox.consecutive_failures']),
    backlog = toint(c['relay.inbox.held_count'])
| join kind=inner (
    ['t3-code-relay-traces-prod']
    | where name == 'relay.inbox.deliver'
    | project trace_id, endpoint = tostring(['attributes.custom']['relay.hook.endpoint_key'])
  ) on trace_id
| summarize runs = count(), failures = max(failures), backlog = max(backlog) by endpoint

警报

该技术栈在部署设置 RELAY_ALERT_NOTIFIER_ID 时,会从 infra/relay/src/monitoring.ts 提供阈值监控器和一个 t3-code-relay-health 仪表盘。寻呼监控器发送到该通知器,并且需要连续两次失败运行才会触发。警告在设置 RELAY_WARNING_NOTIFIER_ID 时发送到该通知器,否则仅在 Axiom 中可见。每个监控器的描述都给出了其健康基线。

监控器统计 HTTP 状态码,而不是 span 错误。中继在 9 秒截止时间切断的请求会以中断的 span 结束,没有错误标记,但仍会返回 504。/connect 的 502 是正常的:大多数由离线主机产生,其客户端每隔几秒重试一次。

仪表盘的“正在连接”图表显示了用户所见的连接。Web、桌面和移动端每次尝试都会导出一个根 relay.connection.attempt span,该 span 持续到初始同步完成,并与中继和主机的 span 处于同一 trace 中。大多数重连会复用缓存的 relay Token,并直接连接到主机,因此中继延迟只影响冷连接。总耗时慢但各阶段快,通常意味着客户端在回退到中继之前,等待一个无法访问的直接(LAN)路由超时。

链路失败 / 隧道恢复失败。 打开仪表盘的“按原因统计的链路失败”表格。在“按状态统计的 Cloudflare API 调用”图表中,出现 Cloudflare 429 的 provisioning: ensure-tunnel 或 configure-tunnel 即 #16810 故障:账户的 Cloudflare API 速率限制。请求 Cloudflare 提高限制,并检查是否有东西在循环调用 API(来自主机的隧道恢复是最大的调用方)。慢速 Postgres 则指向数据库或 Hyperdrive。

环境无法访问。 客户端能到达的主机比平时少。在怀疑主机之前,先检查 Cloudflare Tunnel 状态和最近的中继部署:主机侧的问题不会同时影响这么多环境。

推送投递失败。 查看 relay.apns_deliveries.process_signed_job 或 relay.fcm.process 错误消息。过期的 APNs 密钥或 FCM 服务账号会导致每个任务失败。

Cloudflare API 速率受限 / 接近限制。 警告在持续出现 429 时触发;当 429 频繁到足以阻塞 provisioning 时,寻呼触发。将仪表盘上的调用量与账户限制进行比较,Cloudflare 在 2026 年 10 月将限制提高到每 5 分钟 20,000 次调用(当时峰值使用约为 2,100)。

流量下降。 Worker 宕机、部署破坏了它,或者 span 停止到达 Axiom。首先检查 https://relay.t3.codes/health。

隧道清理停止 / 隧道删除失败。 关于每五分钟删除空闲 Cloudflare 隧道的回收器的警告。“已停止”统计已完成的清理轮次,因此当每一轮清理都失败时它也会触发;当列出隧道失败时,一轮清理就会失败,通常是在 Cloudflare 429 之后。空闲隧道在被回收器处理之前都会计费,“空闲隧道(Cloudflare)”图表会显示这一点。失败的清理轮次不会记录计数,因此在清理轮次失败期间,图表会停在最后一个成功的轮次;请查看 Cloudflare 仪表盘中的隧道列表以获取当前数字。对于失败的删除,请阅读中继的“Failed to delete an inactive managed tunnel”警告。要暂停清理,请参阅 托管隧道清理灰度发布。

链路降级 / 数据库缓慢。 关于缓慢降级的警告。六个小时内链路失败率超过 20%,至今意味着 provisioning 撞上 9 秒截止时间,通常原因是 Postgres 延迟。

移动端崩溃与错误

移动应用通过 expo-observe 上报。其 endpointUrl 指向中继(/v1/mobile/observe,在 apps/mobile/app.config.ts 中从 T3CODE_RELAY_URL 设置),因为 Observe 无法发送认证头,并且会用 EAS project id 为每个路径添加前缀。中继使用自己的 token 将每个请求转发到 Axiom:日志(JavaScript 错误、原生崩溃、日志事件)发送到 t3-code-mobile-logs-*,指标发送到 t3-code-mobile-metrics-*,span 发送到共享的 traces 数据集。Expo 的 Observe 仪表盘不会收到任何内容。端点 URL 被编译进原生应用,因此要将其指向其他地方需要一次应用商店构建。

来自 release 构建的 Hermes 堆栈是混淆后的字节码偏移。在传递过程中,中继会使用来自 R2 的 bundle source map 重写每个 JavaScript 异常的 exception.stacktrace,并将结果记录在日志记录上:

  • relay.symbolication.status:symbolicated,或未符号化的原因。no_bundle_id 是开发构建或本地构建。source_map_missing 表示没有为该 bundle 上传 map;source_map_too_large、source_map_invalid 和 source_map_unavailable(R2 不可读,稍后重试)则表示有 map。source_map_limit 是一个请求中列出的 bundle 数量超过中继一次解码的上限,no_bundle_frames 是指堆栈中没有来自该 bundle 的帧。
  • relay.symbolication.source_map:所使用的 R2 键
  • relay.symbolication.raw_stacktrace:原始堆栈(当它被重写时)

iOS 帧会丢失其安装路径(/private/var/containers/Bundle/Application/<uuid>/...),该路径因安装而异,因此 exception.stacktrace 可跨用户分组。

致命的 JavaScript 错误会以原生中止结束,Observe 会将其报告为带有通用 React Native 堆栈的第二次崩溃。当两者(即两个崩溃)在一次请求中到达时(就像下次启动时那样),中继会将后者标记为 relay.crash.js_fatal。要统计真正的原生崩溃,请将其排除:

['t3-code-mobile-logs-prod']
| where ['attributes.expo.error.source'] == 'nativeCrash'
| where isnull(['attributes.relay.crash.js_fatal'])
| summarize count() by ['attributes.exception.type'], ['attributes.exception.message']

原生崩溃堆栈(expo.error.source == 'nativeCrash')不会进行符号化。在 iOS 上,应用帧看起来像 main (T3Code + 19476):这是应用二进制文件中的偏移量。构建钩子会将该构建的 dSYM 上传到 ios/dsym/<easBuildId>.zip;使用账户 R2 token 下载它,然后:

unzip <easBuildId>.zip
atos -arch arm64 -o T3Code.app.dSYM/Contents/Resources/DWARF/T3Code -l 0x100000000 \
  $(printf '0x%x' $((0x100000000 + 19476)))

这些映射以崩溃的 bundle 为键:OTA 更新对应 <platform>/ota/<updateId>.map,嵌入在商店构建中的 bundle 对应 <platform>/build/<easBuildId>.map。生产环境的移动端工作流会在 eas update 之后上传 OTA 映射,apps/mobile/package.json 中的 eas-build-on-success 钩子会上传构建映射;两者都使用 apps/mobile/scripts/upload-sourcemaps.mts。它们需要 MOBILE_SOURCEMAPS_R2 机密,这是一个 JSON 值,堆栈将其作为 mobileSourceMapsUploadCredentials 输出,该值来自一个只能写入该存储桶的令牌。创建该令牌需要在部署令牌上具有 账户 API 令牌:编辑 权限。部署输出会将其显示为已脱敏;请从 state 中读取,它位于 __redacted__ 键下:

cd infra/relay && vp exec alchemy state read T3CodeRelay/prod/output

将其设置为 GitHub Actions 机密(用于 OTA 更新),并将其设置为 EAS 环境变量(仅用于构建,在 production 环境中具有 secret 可见性)。不要将其放入 preview:PR 预览构建会在该环境中运行 PR 的代码。在 CI 中,只有上传步骤能获得它,eas update 及其运行的依赖项不会。如果部署替换了令牌,请同时更新两者。

每过几天就该看看 t3-code-mobile-health 仪表盘:崩溃数和已处理错误数、热门崩溃及其影响的安装数和应用版本、按版本统计的崩溃、堆栈是否已符号化,以及报告是否最终到达 relay。一次崩溃只计数一次:JS 崩溃后的原生中止(relay.crash.js_fatal)不计入。启动时间属于指标,因此不在仪表盘上;请使用 MPL 查询 t3-code-mobile-metrics-*。生产环境和 PR 预览都会获得该仪表盘,而带有 RELAY_ALERT_NOTIFIER_ID 的部署还会获得以下监视器(infra/relay/src/mobileTelemetry/monitoring.ts):

  • 崩溃激增(pages):每 15 分钟的崩溃数远高于过去 7 天同一时段的数据。在数据积累满一周之前,它没有基线。发布后立即出现激增通常意味着 OTA 更新有问题;请在 EAS 中回滚。
  • 一次崩溃影响大量安装(pages):一小时内同一个崩溃出现在 25 个安装上。
  • 报告停止(warning):一小时内没有转发任何启动指标,因此崩溃报告也不会到达。
  • 崩溃堆栈未符号化(warning):在 6 小时内,超过一半的发布版 JS 堆栈仍保持压缩状态。relay.symbolication.status 会指明原因。

阈值只是起点,不是经过测量的基线;请在获得两周的生产数据后重新审视它们。

没有它,上传会被跳过并发出警告,堆栈将保持压缩状态。每个摄取请求对应一个 relay.mobile_telemetry.ingest span;relay.mobile_telemetry.outcome 的值为 forwarded、rate_limited、body_too_large、upstream_unavailable 或 upstream_timeout。

本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 t3/t3code