T3 Code 非官方中文翻译不是官方产品,也不是 T3 Tools 官方文档。源提交 d2c25950b4d9英文原文GitHub 源文件
Skip to content

中继可观测性 ​

面向维护者。正在使用 T3 Code?请参阅 用户文档。

中继(relay)的 Alchemy 技术栈有一套共用的 Axiom 追踪配置:

  • t3-code-relay-traces-prod,Worker、移动应用和第一方中继客户端(client)共用的 OpenTelemetry 追踪数据集
  • t3-code-relay-otel-ingest-prod,限定在这个数据集上的 Worker 摄取令牌
  • t3-code-mobile-otel-ingest-prod,限定在这个数据集上的移动端摄取令牌
  • t3-code-relay-client-otel-ingest-prod,限定在这个数据集上的第一方中继客户端摄取令牌
  • 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 转发这些数据时使用的摄取令牌
  • t3-code-mobile-sourcemaps-prod,存放移动端 JavaScript source map 的 R2 存储桶,以及 t3-code-mobile-sourcemaps-upload-prod,一个只能向该存储桶写入对象的 Cloudflare 令牌

Alchemy 的阶段(stage)会在资源名后接上净化后的阶段名,以便把资源隔开,例如个人阶段对应 t3-code-relay-traces-dev-julius。

从 infra/relay 按平常的 Alchemy 流程部署:

sh
vp run deploy

Alchemy 通过自己的提供方(provider)解析账号级的 Axiom 部署凭据。运行时,Worker 只收到自己那份限定范围的摄取令牌。移动端和中继客户端各自使用单独配好的、限定范围的摄取令牌。

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

apl
['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 读取端点。中继专属的跨度注解存放在 attributes.custom 之下;relay.operation 是发出的自定义属性之一。

已经结束的事故,智能体应优先使用配置出来的视图或 APL 查询,而不是跟着 Cloudflare Worker 的实时日志往下看。这套技术栈不会另外配置查询令牌。需要用脚本查询的响应人员,使用已授权的账号级 AXIOM_TOKEN,并配合 AXIOM_ORG_ID;限定范围的摄取令牌对其生产者而言,仍然只是只写凭据。

DPoP 证明失败会带上稳定的 relay.dpop.failure_code 跨度属性。time_window 失败表示,已签名的证明相对中继允许的时间窗口来说太旧,或者太超前。这可能说明某一端设备的日期或时间有问题,但也可能只是请求迟到了。客户端借助这个类别,以及旧版中继上没有这个类别这一点,来判断时钟偏差是已经确认,还是只是一种可能的原因。

Webhook ​

一次公开的 webhook 请求是一条 relay.hooks.forward 跨度。它的 relay.hook.outcome 说明发生了什么:forwarded、held、rate_limited、inbox_full、not_found、payload_too_large、environment_unavailable 或 environment_timeout。relay.hook.endpoint_key 标识受管端点,并由此标识环境(environment)。一次转发中,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 跨度上记下同样的内容。中继在每次转发时都带上自己的 traceparent,并丢掉发送方提供的任何同名值,同时为每次转发签名(x-t3-relay-delivery),这样环境只信任来自中继的投递 id、接收时间和追踪上下文;因此,在把追踪导出到 T3 Connect 的环境上,环境自己的 ScheduledTaskService.triggerWebhook 跨度会落进同一条追踪。relay.hook.rate_limit 说明哪一项预算已经用完:endpoint 或 hook。当 Cloudflare 的速率限制器不可用、请求不受限制地通过时,会设置 relay.hook.rate_limiter_failed_open。

暂存的请求在该端点的 HookInboxObject 里处理,每一次进入其中都是自己的一条追踪,并不是转发跨度的子跨度:relay.inbox.hold、relay.inbox.wake,以及每一次 alarm 运行对应的 relay.inbox.deliver。每一条都携带 relay.hook.endpoint_key,因此可以和转发关联起来。细节记在子跨度上:收件箱拒绝某个请求时,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.run_result = failed,记在根 relay.inbox.deliver 跨度上。

这些信息可以回答下面这些问题:

apl
// Webhook outcomes per hour
['t3-code-relay-traces-prod']
| where name == 'relay.hooks.forward'
| summarize count() by bin(_time, 1h), outcome = tostring(['attributes.custom']['relay.hook.outcome'])

// Inboxes stuck behind an unreachable environment, by endpoint
['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

告警 ​

这套技术栈会配置阈值监控,以及一块 t3-code-relay-health 仪表板,其定义来自 infra/relay/src/monitoring.ts,条件是部署设置了 RELAY_ALERT_NOTIFIER_ID。寻呼监控发往该通知器,并且需要连续两次运行都失败。若设置了 RELAY_WARNING_NOTIFIER_ID,警告就发到那里,否则只能在 Axiom 里看到。每条监控的描述都给出它的健康基线。

监控统计的是 HTTP 状态码,跨度错误不在统计之内。中继在 9 秒截止时间把请求切断后,这条跨度以中断结束,上面没有错误标志,响应仍然是 504。/connect 的 502 属于正常情况:其中大部分来自离线主机,客户端每隔几秒就会重试。

仪表板上的「Connecting」图表按用户实际看到的方式展示连接。Web、桌面应用和移动应用为每次尝试导出一条根 relay.connection.attempt 跨度,它一直持续到初始同步完成,并且与中继和主机的跨度处于同一条追踪。大多数重连会复用缓存的中继令牌,直接连向主机,因此中继延迟只影响冷连接。总耗时很长而各个阶段很快,通常表示客户端先把一条不可达的直连(LAN)路由等完,然后才回退到中继。

链接失败 / 隧道恢复失败。 打开仪表板上的「Link failures by reason」表。若是 provisioning: ensure-tunnel 或 configure-tunnel,同时「Cloudflare API calls by status」图表上出现 Cloudflare 429,那就是 #16810 这一故障:该账号的 Cloudflare API 速率限制。请 Cloudflare 提高限额,并检查是否有调用方在循环调用该 API(来自主机的隧道恢复是最大的调用方)。若是 Postgres 变慢,则指向数据库或 Hyperdrive。

环境不可达。 客户端能连上的主机比平时少。先查看 Cloudflare Tunnel 的状态和最近的中继部署,再怀疑主机:主机一侧的问题不会让这么多环境同时变动。

推送投递失败。 查看 relay.apns_deliveries.process_signed_job 或 relay.fcm.process 的错误信息。APNs 密钥过期,或 FCM 服务账号失效,都会让每一项任务失败。

Cloudflare API 被限速 / 接近限额。 429 持续出现时触发警告;429 频繁到使配置停住时触发寻呼。把仪表板上的调用量与账号限额对照。Cloudflare 在 2026 年 10 月把限额提高到每 5 分钟 20,000 次调用(当时的峰值用量约为 2,100)。

流量被丢弃。 Worker 已经停止,某次部署把它弄坏了,或者跨度不再到达 Axiom。先查 https://relay.t3.codes/health。

隧道清理已停止 / 隧道删除失败。 这是针对五分钟回收器的警告,该回收器负责删除空闲的 Cloudflare 隧道。「Stopped」统计的是已经跑完的清扫次数,因此每一次清扫都失败时它也会触发;清扫失败,是因为列出隧道失败,背后通常是 Cloudflare 429。空闲隧道会一直计费,直到回收器追上,这一点显示在「Idle tunnels in Cloudflare」图表上。失败的清扫不记录计数,因此清扫持续失败时,图表会停在上一次成功的数字;当前数量请到 Cloudflare 仪表板的隧道列表中查看。删除失败时,阅读中继打出的「Failed to delete an inactive managed tunnel」警告。若要暂停清理,参见受管隧道清理上线。

链接降级 / 数据库变慢。 这是针对缓慢降级的警告。到目前为止,链接失败率在六小时内超过 20%,都表示配置过程撞上了 9 秒截止时间,常见原因是 Postgres 延迟。

移动端崩溃与错误 ​

移动应用通过 expo-observe 上报。它的 endpointUrl 指向中继(/v1/mobile/observe,在 apps/mobile/app.config.ts 里根据 T3CODE_RELAY_URL 设置),因为 Observe 无法发送认证头,而且会给每条路径加上 EAS 项目 id 前缀。中继用自己的令牌把每个请求转发到 Axiom:日志(JavaScript 错误、原生崩溃、日志事件(event))进入 t3-code-mobile-logs-*,指标进入 t3-code-mobile-metrics-*,跨度进入共享的追踪数据集。Expo 的 Observe 仪表板什么都收不到。端点 URL 编译进了原生应用,因此要改指向别处,就需要一次商店构建。

发布构建中的 Hermes 堆栈是压缩后的字节码偏移。请求经过时,中继用 R2 中该 bundle 的 source map 改写每一条 JavaScript 异常的 exception.stacktrace,并把结果记在日志记录上:

  • relay.symbolication.status:symbolicated,或者没能改写的原因。no_bundle_id 表示开发构建或本地构建。source_map_missing 表示没有为这个 bundle 上传过映射;source_map_too_large、source_map_invalid 和 source_map_unavailable(R2 无法读取,稍后重试)表示映射已经存在。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。统计真正的原生崩溃时,把它排除在外:

apl
['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 令牌下载,然后:

sh
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 映射;eas-build-on-success 钩子位于 apps/mobile/package.json,负责上传某一次构建的映射,两者都通过 apps/mobile/scripts/upload-sourcemaps.mts。它们需要 MOBILE_SOURCEMAPS_R2 密钥。这是一个 JSON 值,技术栈把它输出为 mobileSourceMapsUploadCredentials,来自一个只能向该存储桶写入的令牌。创建该令牌,要求部署令牌具备 Account API Tokens: Edit。部署输出中它是脱敏的;请从状态中读取,它位于 __redacted__ 之下:

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

把它设为 GitHub Actions 密钥,供 OTA 更新使用;再设为 EAS 环境变量,可见性为 secret,并且只放在 production 环境中,供构建使用。不要放进 preview:PR 预览构建会在该环境中运行这个 PR 的代码。在 CI 中只有上传步骤能拿到它,eas update 以及它所运行的依赖拿不到。如果这次部署更换了令牌,两处都要更新。

t3-code-mobile-health 仪表板是每隔几天该查看的地方:崩溃次数和已处理错误的次数、排在前面的崩溃以及它们打中的安装和应用版本、按版本划分的崩溃、堆栈是否已经符号化,以及报告究竟有没有到达中继。一次崩溃只计一次:JavaScript 崩溃之后的那次原生中止(relay.crash.js_fatal)不计入。启动时间是指标,因此不在这块仪表板上;请用 MPL 查询 t3-code-mobile-metrics-*。生产和 PR 预览会得到这块仪表板,设置了 RELAY_ALERT_NOTIFIER_ID 的部署还会得到下面这些监控(infra/relay/src/mobileTelemetry/monitoring.ts):

  • 崩溃尖峰(寻呼):每 15 分钟的崩溃数明显高于过去 7 天的同一时段。尚未攒满一周数据时,它没有基线。发布之后立刻出现的尖峰,通常是一次有问题的 OTA 更新;到 EAS 中把它回滚。
  • 同一次崩溃打中许多安装(寻呼):一小时内,同一次崩溃出现在 25 个安装上。
  • 报告已停止(警告):连续一小时没有转发启动指标,因此崩溃也没有到达。
  • 崩溃堆栈未符号化(警告):超过一半的发布版 JavaScript 堆栈在 6 小时内一直保持压缩。relay.symbolication.status 写明了原因。

这些阈值是起点,并不是测量得到的基线;等生产数据积累两周之后,再回来审视。

没有它时,上传会被跳过并给出警告,堆栈就一直保持压缩。每一次摄取请求都是一条 relay.mobile_telemetry.ingest 跨度;relay.mobile_telemetry.outcome 为 forwarded、rate_limited、body_too_large、upstream_unavailable 或 upstream_timeout。

非官方译文 · 源提交 d2c25950b4d9 · 不代表 T3 Tools