用户先发现接口坏了,值班的人却一无所知
凌晨两点,报名接口开始返回 500。用户在群里发来截图,值班的人打开终端,先问:从几点开始?影响了多少请求?是哪条接口?报错时请求里发生了什么?
如果服务只打印一行 request failed,没有统计请求量和错误率,也没有任何告警,这几个问题只能靠猜。服务不是刚刚坏掉,而是你刚刚才知道它坏了。
最小可观测不是先搭一套很贵的平台,而是让你能从「出了什么问题」走到「哪一类请求受影响」,再定位到一条具体请求:日志留现场,指标看趋势,告警叫醒人。
先分清三件事各自回答什么
| 信号 | 回答的问题 | 最小例子 |
|---|---|---|
| 日志 | 这一条请求发生了什么? | 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,但它不知道用户能忍受多慢、什么错误算业务事故,也不知道半夜应该通知谁。阈值、责任人和处理路径必须由你定,且要亲自演练通知链路。
默认动作从「等用户报错后,我再去服务器找日志」,换成「上线前先演练告警链路,确认通知能收到,也能从通知定位到受影响的请求」。这才是把系统交付到有人能守的状态。
参考
- OpenTelemetry:Signals(logs、metrics、traces 的信号定位)
- Prometheus:Metric and label naming(指标命名、单位与高基数标签)
- Prometheus:Histograms and summaries(延迟分布与直方图)
- Prometheus:Alerting(基于用户症状告警)
- Prometheus:Alerting rules(规则与
for行为) - Prometheus:Configuration(
scrape_configs、rule_files与 Alertmanager) - Node.js:HTTP(响应
finish、close事件语义) - OpenTelemetry:Handling sensitive data(遥测中的敏感数据处理)
- Prometheus Node.js client(Node.js 指标 client API)