零基础堆出一套能发飞书的监控:Grafana、Loki 和几次在 Cursor 里的对话
原创 · 约 43 分钟阅读 · 阅读 --

零基础堆出一套能发飞书的监控:Grafana、Loki 和几次在 Cursor 里的对话

作者: Alex Xiang


古董级程序员,大厂出来后一直在创业公司,现在仍活跃在一线做 AI 相关的开发。更完整的更新写在微信公众号「字与码」:工作经历、对新技术的想法,以及这些年走弯路的记录,会不定期发在那里。若觉得博客对你有用,欢迎顺手关注。

先说自己的底子:创业团队里大家都一职多能,专门盯运维的同事当时腾不出手,又赶上线上故障要看监控曲线,这一摊就落到我头上。那时我对 Grafana 的印象无非是「别人浏览器里画曲线的网站」;Prometheus 知道是拉指标的;Loki 只晓得和日志沾边;LogQL 从没写过;告警规则、Contact point、Webhook 这些菜单全靠点开试。业务跑在现成的 K8s 上,stdout 已经进 Loki,不可能为了几张图再去啃三天官方文档,大量细碎问题就分散在 Cursor 里、对着仓库问。下文里的 prompt 多是当时原话或只改了标点;也不是要树什么「人机协作样板」——query 抄错、Explore 里一片空、飞书通知里飘着 [no value],后文都会按顺序交代清楚。

为什么要凑这三件套

Prometheus 适合接口延迟、CPU、QPS 这类天然是数值的指标,告警规则也成熟。Loki 主要从容器标准输出采集日志,索引以标签为主、不对正文做重型存储,成本相对可控,用来从现有日志里还原趋势和异常是正合适的选择——我们这条路线就是 日志里已经打了 [REQ] <--elapsed_ms=,就不必立刻再造一整套 metrics,先把图和告警跑起来。Grafana 当一个壳:数据源、Dashboard、Unified Alerting 全在一起,不用维护三四套 UI。三件都不完美:用日志算 p95、多副本合成一条曲线,数学上要妥协;真要签死 SLO,后面多半还要 histogram 或 trace。但对「先看见、先能叫到人」来说够了。

下文假设的服务叫 tool-gateway(虚构名),路径写成 /api/v1/query(检索)和 /api/v1/invoke(执行)。所有 LogQL 里的 {namespace="prod", app="tool-gateway"} 只是示例:你要在 Grafana Explore → Label browser 里看到自己集群里真实存在的标签键值,整段替换掉,否则永远是 No data


一、日志长什么样:先约定「一行代表什么」

tool-gateway 的中间件对每个 HTTP 请求打两行。入口只有方法和路径,没有状态码:

2026-03-31 06:55:03.913 | INFO | 7c2a…e441 | …middleware:206 - [REQ] --> POST /api/v1/query

出口才有 statuselapsed_ms,这才是一次请求从进到出闭合的时间:

2026-03-31 06:55:04.412 | INFO | 7c2a…e441 | …middleware:210 - [REQ] <-- POST /api/v1/query status=200 elapsed_ms=498.7

执行类接口同理:

[REQ] <-- POST /api/v1/invoke status=200 elapsed_ms=341.0

后面做 QPS、延迟,都锚定在 [REQ] <-- 这一行:一行对应一次已完成的 HTTP 请求。若你只看到 -->、没有看到同一 trace_id 下的 <--,要么是请求还在飞,要么是并发日志交错,不能拿两行不同路径硬拼成「一条请求缺半句」。

检索链路里还有与应用相关的耗时日志,字段里带毫秒数,后面用 regexp 抽数字

[向量召回] query embedding 完成: model=text-embedding-3-small, duration_ms=31.22
[向量召回] 向量检索 DB 完成: limit=200, exact_search=False, results=100, total_ms=31.22 (set_local=13.32, execute=17.83, …)

弄清以上格式之后,再往下读 LogQL,每一条管道在过滤什么、为什么要这么写,才有落脚点。

文中和 Cursor 的往返用 <blockquote class="post-dialog"> 包一层:左边是蓝灰细条,和站内普通引用块样式不同;同步到微信公众号草稿时,脚本会给同一块加上一致的内联样式,读者在手机里也能看出「这是对话题」。

我:动手配 Loki 之前,我先把仓库打进 Cursor,问:「每个 HTTP 接口是不是都有一条肯定会打的日志,拿来统计 QPS?要接 Grafana / Loki 告警。」

Cursor:最稳的是中间件的 [REQ] --> / [REQ] <--;不是每个 handler 都额外打 INFO(例如健康检查可能只有中间件那两行在 INFO)。统计完成的请求,锚在 [REQ] <-- 即可。

这一步定了我后面所有 rate 的锚点,没再去业务代码里猜「哪句代表一次调用」。

我:我分不清「少半句日志」和「两个请求绞在一起」,在 Cursor 里贴了两行路径不一致的 [REQ],问:「[REQ] --> POST /api/v1/query[REQ] <-- POST /api/v1/invoke status=200 … 为什么格式不一样?」

Cursor:这是中间件刻意设计的一进一出两行;若路径对不上,多半是并发请求交错,要靠同一 trace_id 配对。不必去怀疑 Loki 丢字段。


二、进 Grafana 之后第一步:确认标签,不要用猜的

Loki 不像全文检索数据库,查询必须先收窄标签 {key="value"},再在管道里 |=|~ 过滤正文。

我踩的第一个坑是照搬别人截图里的 {namespace="acme-demo"},贴上去 No data。自己环境里的 Promtail 可能根本没打这个键,或者值叫别的。正确顺序是:打开 Explore,选好 Loki 数据源,左侧 Label browser 点开,看实际有哪些 namespaceapppodcontainer 之类,把示例里的占位符换成你的。

第二个坑是 Query type。要画时间序列曲线,必须选 RangeInstant 只在某一个时刻取样,贴到 Dashboard 上经常像「断成一截」,我在这儿浪费了挺久。

下面所有「成品查询」都假定你已经能用 纯过滤 在 Explore 里搜出原始日志行。若搜不到,先把 | regexp 去掉,只留 {...} |= "[REQ] <--",确认时间范围和标签无误,再接 rateunwrap

我:「Explore 里明明能搜到日志,加上 rate 就空了,是不是 Loki 坏了?」

Cursor:先查 Query type 是不是 Range;标签要从 Label browser 抄,不要猜 namespacerate 方括号里是一段时间窗口,结果是按秒的速率,不是「每分钟一条数」。


三、例题 A:全站「完成请求」的总 QPS

目的:统计所有接口合起来,每秒有多少次请求正常走完(以出口日志条数为准)。

思路:标签选好;正文必须包含 [REQ] <--;对匹配到的日志条数用 rate 求每秒增长率;所有 Pod 的数据用 sum 加总。

完整 LogQL:

sum(
  rate(
    {namespace="prod", app="tool-gateway"}
      |= "[REQ] <--"
    [1m]
  )
)

说明

  • |= 表示日志行必须包含该子串。
  • [1m]滑动窗口:用最近一段时间内的样本估算速率,不是「每分钟只算一次」。
  • rate(...) 在 Loki 里给出的就是 大致的条/秒,也就是你想要的 QPS 量级;Y 轴单位在面板里设成 ops/s 或自行在标题写「req/s」。
  • 若结果为空,先去掉外层 sum(rate(...)),只跑内层,确认有数据再加 rate

四、例题 B:只统计「检索」或「执行」接口的 QPS

目的:把总流量拆开,分别看图。

不要|= "search" 之类:Logger 模块名里往往带 …search… 字样,几乎所有行都匹配,QPS 会虚高。过滤应落在 URL 路径 上。

检索(路径里带 /api/v1/query,含可能的内部变体时可再收紧正则):

sum(
  rate(
    {namespace="prod", app="tool-gateway"}
      |= "[REQ] <--"
      |~ "/api/v1/query"
    [1m]
  )
)

执行(固定路径时可用 |= 精确子串):

sum(
  rate(
    {namespace="prod", app="tool-gateway"}
      |= "[REQ] <--"
      |= "/api/v1/invoke"
    [1m]
  )
)

|~ 后面可以写正则;|=必须包含 该字面量。若你担心 /api/v1/query 误配别的路由,可改成更死的写法,例如只匹配 POST /api/v1/query (注意行尾空格),那就要用 |~ 写完整正则,此处不展开,原则是 先 Explore 看真实路径再写过滤器

我:在查询里写过 [REQ] <--or "search",曲线突然顶天,并贴了一行带 logger 模块名的日志。

Cursor:模块路径里常有 search 字样,or 后半会把几乎所有行都匹配进来。要按接口拆流量,只能卡 URL,不能靠正文里随手一个子串。


五、例题 C:按 HTTP 状态码分组的 QPS

目的:例如同时看 200、4xx、5xx 各有多少条/秒

思路:日志行里的 status=200 不是 Loki 的标签,要用 regexp 管道抽出命名捕获组,Loki 会把它提升为临时标签 status,再 sum by (status)

完整 LogQL:

sum by (status) (
  rate(
    {namespace="prod", app="tool-gateway"}
      |= "[REQ] <--"
      | regexp `status=(?P<status>\d+)`
    [1m]
  )
)

这一条会返回多条时间序列,每条曲线的标签里有一个 status 值。Dashboard 里适合做堆叠或分色折线。


六、例题 D:多副本时,检索接口的「全集群加权平均延迟」

目的:从 elapsed_ms=498.7 里抽出数值,得到 所有 Pod 合在一起 的平均延迟(毫秒),一条曲线

直接 avg_over_time 的问题:Loki 会按 日志流(通常每个 Pod 一条流)分别算平均,你会看到十几条曲线,误以为查询写错。

正确做法:在每个时间窗口内,对各流 延迟之和 / 请求条数 先算流内平均,再对分子、分母分别 sum,全局还是「总耗时 / 总请求数」。

完整 LogQL(检索 /api/v1/query):

sum(
  sum_over_time(
    {namespace="prod", app="tool-gateway"}
      |= "[REQ] <--"
      |~ "/api/v1/query"
      | regexp `elapsed_ms=(?P<elapsed_ms>[\d.]+)`
      | unwrap elapsed_ms
    [5m]
  )
)
/
sum(
  count_over_time(
    {namespace="prod", app="tool-gateway"}
      |= "[REQ] <--"
      |~ "/api/v1/query"
      | regexp `elapsed_ms=(?P<elapsed_ms>[\d.]+)`
    [5m]
  )
)

说明

  • regexp 命名组 elapsed_msunwrap 连用,把时间序列当成数值聚合。
  • 分母的 count_over_time 不要再 unwrap,数的是「匹配到的日志行数」,即请求数。
  • [5m] 可改成 [1m],曲线更抖;窗口越大越平滑。

执行接口只要把第二、第三行的 |~ "/api/v1/query" 换成 |= "/api/v1/invoke" 即可,其余不动。

我:avg_over_timeunwrap 之后出来 12 条曲线,我只要一条 query 接口的平均延迟。」

Cursor:每个 Pod 一条日志流,avg_over_time 对流各自算平均。要合成全集群一条线,用 sum(sum_over_time … | unwrap …) 除以 sum(count_over_time …),分子分母都对所有流先求和。

我把这段 LogQL 粘回 Explore,曲线并拢,才确认不是查询写炸而是副本维度没聚合。


七、例题 E:检索接口的 p95 延迟(多副本折中)

目的:看「较慢的那部分请求」到什么量级。

限制:严格意义上的 全集群 request 级 p95 要把所有副本上的耗时合在一起再排序,单靠 Loki 多流查询做不到。工程上常用折中:每个 Pod 在窗口内算 p95,再聚合max by () (...) 取各 Pod p95 的最大值,偏保守,适合当作「最差副本的尾部」参考。

完整 LogQL:

max by () (
  quantile_over_time(
    0.95,
    {namespace="prod", app="tool-gateway"}
      |= "[REQ] <--"
      |~ "/api/v1/query"
      | regexp `elapsed_ms=(?P<elapsed_ms>[\d.]+)`
      | unwrap elapsed_ms
    [5m]
  )
)

若你更关心「典型副本」而不是「最坏副本」,可把 max by () 换成 avg by (),含义随之变化,这一点要在团队里对齐口径。invoke 的 p95 明显高于平均值,多半是长尾分布,不一定是面板配错。


八、例题 F:从业务日志里统计「query embedding」耗时

目的:不只看 HTTP 一整段,还要看向量编码这一小段耗时(日志里 duration_ms=)。

加权平均(一条曲线):

sum(
  sum_over_time(
    {namespace="prod", app="tool-gateway"}
      |= "[向量召回]"
      |= "query embedding 完成"
      | regexp `duration_ms=(?P<duration_ms>[\d.]+)`
      | unwrap duration_ms
    [5m]
  )
)
/
sum(
  count_over_time(
    {namespace="prod", app="tool-gateway"}
      |= "[向量召回]"
      |= "query embedding 完成"
      | regexp `duration_ms=(?P<duration_ms>[\d.]+)`
    [5m]
  )
)

同一类日志的 p95(多副本折中,写法同前):

max by () (
  quantile_over_time(
    0.95,
    {namespace="prod", app="tool-gateway"}
      |= "[向量召回]"
      |= "query embedding 完成"
      | regexp `duration_ms=(?P<duration_ms>[\d.]+)`
      | unwrap duration_ms
    [5m]
  )
)

total_ms(向量库阶段)加权平均示例,与 duration_ms 同样写法,只换关键字和捕获名:

sum(
  sum_over_time(
    {namespace="prod", app="tool-gateway"}
      |= "[向量召回]"
      |= "向量检索 DB 完成"
      | regexp `total_ms=(?P<total_ms>[\d.]+)`
      | unwrap total_ms
    [5m]
  )
)
/
sum(
  count_over_time(
    {namespace="prod", app="tool-gateway"}
      |= "[向量召回]"
      |= "向量检索 DB 完成"
      | regexp `total_ms=(?P<total_ms>[\d.]+)`
    [5m]
  )
)

九、例题 G:告警里为什么出现 [no value],以及规则该怎么接

我:告警 Description 里写了「当前接口 QPS:{{ $values.A.Value }}」,飞书推送却是「当前 QPS:[no value]」。我贴了整条 Alert 查询(当时还有多余的 sum by (status))和截图,问:「Description 里 value 不对。」

Cursor:两件事可能叠在一起:查询侧只滤了 status=200 却没有用 regexp 抽出 status 标签时,sum by (status) 没有意义;告警侧 Loki 的 rate 常返回多条序列,A 不是标量,要加 Reduce 得到 B,注解里改用 $values.B。界面左侧的 A/B/C 就是文档里的 refId。

现象(与上面对话同类,便于后文照抄规则):Alert 的 Description 里写了 {{ $values.A.Value }},飞书收到 「当前 QPS:[no value]」

原因通常有两条叠在一起

第一,查询写成了没有意义的 sum by (status)。例如既用正文过滤了 status=200,又没有用 regexp 抽出 status 标签,by (status) 分组是空的,序列怪异,后面模板取不到值。

下面是一条 不推荐、仅作反例 的查询(不要照抄进生产):

sum by (status) (
  rate(
    {namespace="prod", app="tool-gateway"}
      |= "[REQ] <--"
      |= "status=200"
    [1m]
  )
)

若你只想监控 200 的 QPS,直接 sum(rate(...)) 即可:

sum(
  rate(
    {namespace="prod", app="tool-gateway"}
      |= "[REQ] <--"
      |= "status=200"
    [1m]
  )
)

第二,Loki 的 rate 往往返回多条时间序列(多个 Pod)。告警条件需要的是一个数:在 Grafana Unified Alerting 里给查询 A 后面再加一行 Expression,类型选 Reduce,Input 选 A,Function 用 LastMean,得到 B。阈值条件挂在 B 上;Description 里写 {{ $values.B.Value | printf "%.2f" }} 之类的,字母以你界面上 Reduce 那一行的 refId 为准(左侧的 B,官方界面不总写着 refId 三个字)。

我在对话里问过「找不到 refId」——搞明白 A/B/C 就是 refId 之后,在规则页 Preview 里确认 B 真有数字,再谈飞书,否则永远是盲盒。


十、飞书卡片:从告警到群里一条消息,中间发生的事

这一节要把「谁渲染谁」说死,不然很容易把 Contact point 里的 Body告警规则里的 Description 糊成一锅粥。

10.1 调用链(按时间顺序)

  1. Alert rule 里的查询 A、Reduce B、阈值条件判定「触发」或「恢复」。
  2. Grafana Notification policy 决定走哪个 Contact point
  3. Contact point 类型选 Webhook,填写飞书机器人的 URLHTTP Body 一栏填你准备好的模板文件内容(或内联同等内容)。
  4. Grafana 用 Go template 解析这段 Body:传入的数据结构是 Alertmanager 兼容 的那套(根对象上有 .Status.Alerts.GroupLabels.ExternalURL 等字段,与 Prometheus 生态一致)。
  5. 渲染结果必须是 一段合法 JSON 字符串。Grafana 把它 POST 给飞书。
  6. 飞书按 机器人 / interactive 卡片 协议展示:我们用的是 msg_type: "interactive",正文块用 lark_md 方便加粗、换行、链接。

所以你脑子里的模型应当是:规则里的 $values / $labels 只管告警判定和写进 .Annotations;Webhook 模板里用的是另一套变量(.Alerts 等),通过 .Annotations.description 间接显示你在规则里写好的长文

10.2 两套「模板」各管哪一段

写在哪儿用的语法典型变量用途
Alert rule → Annotations(Summary / Description)Grafana 告警模板$labels.*$values.A / $values.B(视 Reduce 而定)、humanize生成每条告警 human-readable 说明,会进 .Annotations,最后出现在飞书正文里
Contact point → Webhook BodyGo template.Status.Alerts.ExternalURLrange拼出 飞书接受的 JSON,控制卡片颜色、标题、是否循环多条 Alert

常见错误:在 Webhook 的 JSON 里手滑留下半截 {{,或把 $values.B 写进 Webhook(那是规则域的变量,这里不认)。我第一次在测试群看到 整段原始 JSON 或 422,多半是 Content-Type 不是 application/json,或 渲染结果根本不是 JSON

10.3 卡片结构在干什么

  • msg_type: "interactive":飞书交互卡片。
  • card.header.templatefiring 时用 红色条,恢复用 绿色,让人扫一眼知道是不是还在烧。
  • elements 数组:一块块 divtext.taglark_md 才能吃 Markdown 风格的加粗、反引号、[链接](url)
  • range .Alerts:一次通知里可能有多条 firing,循环拼进同一张卡片;summary / description 从每条 alert 的 Annotations 来——也就是你在规则里写模板的地方。
  • {{ .ExternalURL }}:点「打开控制台」进 Grafana。前提grafana.ini[server] root_url 与浏览器里实际访问的 Origin 一致,否则链接跳到错误主机或路径。

10.4 示例 tmpl(由生产用文件改写,便于扫读)

下面不是仓库里的逐字拷贝:删掉了文件尾部大段「给人类看的注释说明」、去掉具体内网 IP;range 里拼 lark_md 的那一长串改写成多行示意,部署时仍须压成飞书接受的一行字符串(换行用 \n)。字段名、逻辑与当时调通的版本一致,可按需再改文案和 emoji。

{{/* Grafana Webhook Body:Go template → 渲染结果为 JSON,POST 给飞书机器人 */}}
{
  "msg_type": "interactive",
  "card": {
    "header": {
      "template": "{{ if eq .Status `firing` }}red{{ else }}green{{ end }}",
      "title": {
        "tag": "plain_text",
        "content": "{{ if eq .Status `firing` }}告警触发{{ else }}已恢复{{ end }}"
      }
    },
    "elements": [
      {
        "tag": "div",
        "text": {
          "tag": "lark_md",
          "content": "**告警:** {{ .GroupLabels.alertname }}\n**状态:** {{ .Status | toUpper }} **条数:** {{ len .Alerts }}\n**Grafana:** [打开控制台]({{ .ExternalURL }})"
        }
      },
      {
        "tag": "div",
        "text": {
          "tag": "lark_md",
          "content": "{{ range .Alerts }}**规则** {{ .Labels.alertname }}\n**实例** `{{ .Labels.instance }}`{{ if .Labels.node }}\n**节点** {{ .Labels.node }}{{ end }}\n**摘要:** {{ if .Annotations.summary }}{{ .Annotations.summary }}{{ else }}—{{ end }}\n{{ if .Annotations.description }}**详情:** {{ .Annotations.description }}\n{{ end }}**时间:** {{ .StartsAt | tz `Asia/Shanghai` | date `2006-01-02 15:04:05` }}(北京时间)\n---\n{{ end }}"
        }
      }
    ]
  }
}

规则侧(仍写在 Grafana Alert 的 Annotations,不要贴进上面这段 JSON)里才可能写:当前 QPS:{{ $values.B.Value | printf "%.2f" }},低于阈值 {{ $values.C.Value }} 一类——humanizeprintf 以你当前 Grafana 版本文档为准。

我:飞书侧卡住了。打开仓库里的 Webhook 模板文件,问:「这段是发通知用的还是告警规则里写?ExternalURL 和飞书 JSON 有没有漏字段?」

Cursor:Body 是 Contact point 渲染用的 Go template,规则和注解里是另一套 $valuesgrafana.iniroot_url 要对齐浏览器访问地址,ExternalURL 才会指对。JSON 里给飞书的结构和反引号比较 Status 的写法别和手改乱了。

我对照飞书 interactive 文档把 header.title.taglark_md 核对了一遍,Test contact point 才在小群里第一次刷出卡片而不是一坨 raw 文本。

Cursor 能做的是:对字段名、对循环语法、帮你盯住两套模板别串台Webhook URL、机器人权限、内网出网 仍要自己在环境里点通。


十一、Cursor 帮了什么

它不能替你拍板阈值,也不能替你承担误告警。省时间的地方主要是:把一整行日志 + 一段报错的 LogQL 丢进对话,比对 仓库里的中间件实现,快速得到「该锚定 <--」「别用 or search」「多副本要分子分母双 sum」这类可执行结论,减少在文档里翻 rate 窗口语义、unwrap 用法的时间。

若你也是业务出身被拉来顶监控,顺序建议不变:Explore 里先看到原文字,再套 rate;面板稳定了再加告警;飞书先在测试群把卡片跑通,再挂生产 Contact point


你在「字与码」里如果也踩过 Grafana 小版本界面改版、或飞书卡片字段长度限制,欢迎留言。