前端转型全栈 07:生产报错,怎么避免后知后觉?

0 阅读10分钟

用户先发现接口坏了,值班的人却一无所知

凌晨两点,报名接口开始返回 500。用户在群里发来截图,值班的人打开终端,先问:从几点开始?影响了多少请求?是哪条接口?报错时请求里发生了什么?

如果服务只打印一行 request failed,没有统计请求量和错误率,也没有任何告警,这几个问题只能靠猜。服务不是刚刚坏掉,而是你刚刚才知道它坏了。

最小可观测不是先搭一套很贵的平台,而是让你能从「出了什么问题」走到「哪一类请求受影响」,再定位到一条具体请求:日志留现场,指标看趋势,告警叫醒人。

image.png

先分清三件事各自回答什么

信号回答的问题最小例子
日志这一条请求发生了什么?requestId=... 的请求返回 500,耗时 820ms
指标最近一段时间整体怎么样?5 分钟错误率、请求量、P95 延迟
告警现在是否需要人采取行动?错误率持续超出阈值,通知值班人

日志是事件记录,指标是按时间聚合的数字;告警规则再从指标中识别需要处理的症状。OpenTelemetry 把 logs、metrics、traces 作为不同信号来描述系统行为;这篇先把日志、指标和告警接通,不要求一上来就部署完整的 tracing 系统。OpenTelemetry:Signals

第一步:让每条请求留下能检索的日志

不要只打印一段给人看的句子。把字段拆开,日志才能按请求 ID、路由和状态码检索。下面只展示请求结束时记录的最小字段;异常日志要补上能排查问题的上下文,但先按脱敏策略处理;示例只留错误类型,实际可再加稳定错误码或过滤后的堆栈。不要把密码、Cookie、Authorization、完整请求体或密钥写进日志。

import { randomUUID } from 'node:crypto'
import { performance } from 'node:perf_hooks'
import { createServer } from 'node:http'
import type { IncomingMessage, ServerResponse } from 'node:http'

function withRequestLog(
  req: IncomingMessage,
  res: ServerResponse,
  route: string, // 路由模板,如 /api/signups,不要直接记用户输入的任意 URL
  run: () => Promise<void>,
) {
  const requestId = randomUUID()
  const startedAt = performance.now()

  res.setHeader('x-request-id', requestId)
  res.once('finish', () => {
    const durationMs = Math.round(performance.now() - startedAt)
    recordRequest(req.method ?? 'UNKNOWN', route, res.statusCode, durationMs / 1000)
    console.log(JSON.stringify({
      level: res.statusCode >= 500 ? 'error' : 'info',
      timestamp: new Date().toISOString(), event: 'request_completed', requestId,
      method: req.method, route, statusCode: res.statusCode, durationMs,
    }))
  })
  res.once('close', () => {
    if (res.writableFinished) return
    console.warn(JSON.stringify({
      level: 'warn', timestamp: new Date().toISOString(), event: 'response_aborted', requestId,
      method: req.method, route, statusCode: res.statusCode,
      durationMs: Math.round(performance.now() - startedAt),
    }))
  })

  void (async () => {
    try {
      await run() // run 负责结束响应
    } catch (error) {
      console.error(JSON.stringify({
        level: 'error', timestamp: new Date().toISOString(), event: 'request_failed', requestId, route,
        errorType: error instanceof Error ? error.name : 'UnknownError',
      }))
      if (!res.headersSent) {
        res.statusCode = 500
        res.end('Internal Server Error')
      } else {
        res.destroy()
      }
    }
  })()
}

实际项目里,route 应从路由框架取得模板(例如 /api/signups/:id),而不是直接把 /api/signups/847193 当成标签或日志维度。示例在 Node.js 的 finish 事件里记录服务端完成响应的耗时;它表示响应数据已交给操作系统发送,不代表客户端一定收到了。异常关闭则单独记为 response_aborted,不混进已完成请求的延迟统计。Node.js:HTTP finish / close 事件 requestId 只是用于关联同一次请求的日志,不是身份凭证;跨服务时要显式传递,并按你的信任边界验证外部传入值。

第二步:用少量指标看出趋势

HTTP 服务先回答三个问题就够了:请求有没有进来、失败比例有没有升高、请求是不是变慢。安装当前 Prometheus Node.js client:

npm install @prometheus-io/client

使用它暴露 Counter 与 Histogram;Counter 只增不减,适合累计次数;Histogram 记录耗时分布,Prometheus 可以在查询时计算分位数。Prometheus client for Node.js · Prometheus:Histograms and summaries

import * as client from '@prometheus-io/client'

client.collectDefaultMetrics() // Node.js 进程、事件循环等默认指标

export const requests = new client.Counter({
  name: 'signup_http_requests_total',
  help: 'Completed HTTP requests',
  labelNames: ['method', 'route', 'status_code'] as const,
})

export const requestDuration = new client.Histogram({
  name: 'signup_http_request_duration_seconds',
  help: 'HTTP request duration in seconds',
  labelNames: ['method', 'route', 'status_code'] as const,
  buckets: [0.05, 0.1, 0.3, 0.8, 1.5, 3],
})

// 在请求结束时调用;route 必须是有限集合中的路由模板。
export function recordRequest(method: string, route: string, statusCode: number, seconds: number) {
  const labels = { method, route, status_code: String(statusCode) }
  requests.inc(labels)
  requestDuration.observe(labels, seconds)
}

// 在 HTTP 服务中单独处理 GET /metrics;此路由不要计入业务请求指标。
export async function serveMetrics(res: ServerResponse) {
  res.setHeader('content-type', client.register.contentType)
  res.end(await client.register.metrics())
}

将前两个 TypeScript 代码片段放在同一个模块里,再将中间件挂到 HTTP 服务。示例只保留两个路由;真实项目要把 route 映射到有限的路由模板,不能把带用户输入的完整 URL 直接用作指标标签。

const server = createServer((req, res) => {
  if (req.method === 'GET' && req.url === '/metrics') {
    void serveMetrics(res).catch((error) => {
      console.error(JSON.stringify({
        level: 'error', timestamp: new Date().toISOString(),
        event: 'metrics_export_failed',
        errorType: error instanceof Error ? error.name : 'UnknownError',
      }))
      res.destroy()
    })
    return
  }

  const route = req.url === '/healthz' ? '/healthz' : 'other'
  withRequestLog(req, res, route, async () => {
    if (route === '/healthz') {
      res.end('ok')
      return
    }
    res.statusCode = 404
    res.end('Not Found')
  })
})

server.listen(3000, '0.0.0.0')

/metrics 需要让 Prometheus 抓取到,但不要顺手暴露给公网;至少限制在监控网络或通过认证的内部入口访问。指标名带单位(_seconds),标签只放方法、路由模板、状态码这类有限取值。不要把 userId、邮箱、原始 URL、错误消息放进指标标签:每个不同标签组合都会创建新的时间序列,高基数会放大存储与查询成本。Prometheus:Metric and label naming

应用暴露了 /metrics 还不够,Prometheus 必须知道去哪抓取、加载哪份告警规则。下面假设 Prometheus、应用和 Alertmanager 在同一个 Compose 网络里,应用服务名为 app、容器端口为 3000,Alertmanager 服务名为 alertmanager;其他部署方式要替换成监控端实际可达的地址。rule_files 指向下一节的规则文件。

global:
  scrape_interval: 15s
  evaluation_interval: 15s
rule_files:
  - /etc/prometheus/signup-alerts.yml
alerting:
  alertmanagers:
    - static_configs:
        - targets: ['alertmanager:9093']
scrape_configs:
  - job_name: signup-api
    static_configs:
      - targets: ['app:3000']

Prometheus 文档中,抓取目标由 scrape_configs 指定,规则文件由 rule_files 加载,Alertmanager 接收端由 alerting 指定。Prometheus:Configuration Alertmanager 还需要配置自己的 route 和 receivers,把消息交给实际的通知渠道。可以从 Prometheus 所在网络的调试环境运行 curl -i http://app:3000/metrics 确认应用端点可达,再到 Prometheus 的 Targets 页面确认该 job 为 UP。

@prometheus-io/client 当前 README 的 API 示例通过 registry.metrics() 暴露抓取内容。若项目仍使用旧包名 prom-client,请按该项目锁定的版本核对安装命令和导入方式;不要把两个版本的代码混着抄。

第三步:只对需要人处理的症状告警

先给「用户真的受影响」设条件,而不是每出现一个 500 就半夜发通知。下面用 Prometheus 规则展示写法;错误率 2%、P95 0.8s、持续 10m、目标不可达 2m 都是演示阈值,要按业务承诺、历史基线和流量校准,不是通用标准。

groups:
  - name: signup-api
    rules:
      - alert: SignupApiTargetDown
        expr: up{job="signup-api"} == 0
        for: 2m
        labels:
          severity: page
        annotations:
          summary: "报名服务抓取目标不可达"
          runbook: "检查应用进程、容器网络、抓取目标配置与 /metrics;若其他目标也掉线,再检查 Prometheus"

      - alert: SignupApiErrorRateHigh
        expr: |
          sum(rate(signup_http_requests_total{status_code=~"5.."}[5m]))
          /
          sum(rate(signup_http_requests_total[5m])) > 0.02
          and sum(rate(signup_http_requests_total[5m])) > 0.1
        for: 10m
        labels:
          severity: page
        annotations:
          summary: "报名接口 5 分钟错误率持续偏高"
          runbook: "检查版本、错误日志与依赖服务;按 requestId 追查样本请求"

      - alert: SignupApiLatencyP95High
        expr: |
          histogram_quantile(0.95,
            sum by (le) (rate(signup_http_request_duration_seconds_bucket[5m]))
          ) > 0.8
        for: 10m
        labels:
          severity: page
        annotations:
          summary: "报名接口 P95 延迟持续偏高"

Prometheus 的 for 会要求条件持续成立一段时间才把告警标为 firing,可以过滤短暂抖动;新增的 up == 0 规则覆盖目标抓取失败,避免服务彻底不可达时只剩一条没有数据的错误率规则。真正的通知还需要在 Prometheus 中配置 Alertmanager 地址,并在 Alertmanager 或平台告警通道中配置接收人;还要验证「规则触发 → 通知到达 → 值班人看得到」整条链路。Prometheus:Alerting rules 告警优先指向用户可感知的错误率、延迟等症状,并附上看板或排查手册;不要把每个可能的底层原因都变成半夜电话。Prometheus:Alerting best practices

规则文件保存后,至少先做语法校验;Prometheus 随包提供的 promtool 可以分别检查主配置和告警规则:

promtool check config prometheus.yml
promtool check rules signup-alerts.yml

语法通过不等于通知链路有效,仍要在测试环境触发一条告警并确认实际送达。

AI 能帮你写规则,但阈值要你来定

AI 可以从接口定义生成路由指标、日志字段和告警规则,也能帮你解释一条 PromQL。但它不知道报名接口的错误率到多少就会影响业务,也不知道谁在夜里接警。先按用户承诺定目标,再看历史基线校准阈值;每条通知都要能回答「谁来处理、先看哪里」。上面的 2%、0.8s、10m 只是演示,不要未经验证就复制到生产。

最小可观测上线检查单

  • 日志:完成与中断的请求都能按时间检索;日志含 requestId、路由模板、状态码、耗时;异常有可检索的类型或堆栈;敏感字段已排除。
  • 指标:能看到请求数、5xx 比例和耗时分布;路由标签是有限集合;/metrics 仅对监控侧开放。
  • 规则:阈值能对应用户影响;短暂抖动不会立刻 page;每条通知写清症状、入口和下一步排查位置。
  • 链路:手动制造一次测试告警,确认采集、规则、通知、值班接收都能走通;收到告警后能先按时间 / 路由筛失败日志,再用 requestId 查同一请求;不要用生产真实用户数据做测试。
  • 可维护:告警有负责人、通知对象和运行手册;联系人变化或服务下线时同步更新。

监控系统自己也要被监控:抓取目标是否掉线、告警服务是否可用、通知是否真的送达。否则你只是把「不知道应用坏了」换成「不知道监控坏了」。

从后知后觉,到上线前先把告警链路跑通

AI 可以替你补日志字段、生成指标定义和 PromQL,但它不知道用户能忍受多慢、什么错误算业务事故,也不知道半夜应该通知谁。阈值、责任人和处理路径必须由你定,且要亲自演练通知链路。

默认动作从「等用户报错后,我再去服务器找日志」,换成「上线前先演练告警链路,确认通知能收到,也能从通知定位到受影响的请求」。这才是把系统交付到有人能守的状态。

参考