作者:来自 Elastic Sophia Solomon

六只猫正密谋逃脱,而你只有一次告警机会来阻止它们。使用 OpenTelemetry 为一款 Python 游戏添加插桩,将遥测数据发送到 Elastic APM,再编写一条 ES|QL 规则 ------ 它将决定你能否在最爱逃跑的那只猫尝试第十次逃脱之前将其抓住。
Elastic 原生支持 OpenTelemetry。通过 OTLP,可将追踪、日志和指标直接发送到 Elasticsearch,无须使用专有 Agent。了解其工作原理,在云端免费试用,或在本地运行。
本文将带你使用 OpenTelemetry(OTel)为 Python 游戏《猫咪大逃脱》添加插桩,并将遥测数据发送到 Elastic APM。接着,你将在 Kibana 中编写一条 Elasticsearch 查询语言(ES|QL)规则,在任何一只猫的逃脱尝试次数达到 10 次之前触发告警,而每只猫都会按各自的节奏逐渐加速。将 cat.name 换成 user.id,将逃脱尝试换成登录失败次数,同一条规则就能用于检测撞库攻击。
《猫咪大逃脱》的游戏机制

在这款侦探游戏中,你最终在 Elastic 集群中创建的规则,将决定你与这些猫咪之间的胜负。每只猫都会按各自不断加快的节奏,悄悄向围栏靠近。只要有一只猫的逃脱尝试次数达到 10 次,它就会成功逃脱,而你将失去侦探执照。侦探,如果你愿意接受任务,你的职责就是配置一条 Elastic Observability 告警,在任何一只猫尝试第 10 次逃脱之前触发。告警触发得越早,你的得分就越高。
所需条件:Elastic Observability、Python 和网络隧道
- 你自己 Fork 的 《猫咪大逃脱》仓库或创建的分支
- Elastic Observability 9.3.0 或更高版本(可申请 Elastic Cloud Serverless 七天免费试用)
- Python 3.11.0 或更高版本(建议使用最新版本)
- ngrok 或类似的隧道工具,因为 Kibana 的 Webhook 需要通过它访问你的笔记本电脑
《猫咪大逃脱》通过 python-dotenv 加载环境文件(.env)中的凭据进行配置。请修改这些配置,使其与你的 Elastic 集群匹配,并确保通过 Elastic APM 建立连接。你可以按以下路径在 Agent 配置中填入机密令牌:Fleet → Agent Policies(Agent 策略)→ 点击要修改的策略 → Elastic APM → Settings(设置) ,然后向下滚动,找到 Agent Authorization(Agent 授权)。
要配置环境,请复制一份 .env.example,并将其重命名为 .env。你的 .env 文件需要包含两个必填值:
ini
`
1. ELASTIC_APM_HOST=your-host.apm.us-central1.gcp.cloud.es.io
2. ELASTIC_SECRET_TOKEN=your_token
3. NUM_CATS=3
`AI代码解释
NUM_CATS 为可选项,默认值为 1,最大值为 6。猫咪越多,场面越混乱;但越混乱,也意味着你有机会获得更高的分数,侦探。
使用 OpenTelemetry 为 Python 应用添加插桩并接入 Elastic APM
抓住调皮的猫咪并不容易。我们应该根据什么触发告警?应该测量什么?为什么?要回答这些问题,就需要随时了解猫咪们的动向。这正是插桩发挥作用的地方。在可观测性领域,插桩是指向应用添加代码或配置,使其能够生成、采集和导出遥测数据。这些遥测数据包括指标、日志和追踪。下面就来看看能帮助你开展调查的代码。
我们将使用 OTel 为应用添加插桩。OTel 是用于生成、采集和导出遥测数据的 标准化 框架,也是可观测性领域的行业标准。开始之前,需要在项目中定义一个 resource。在 OTel 插桩中,resource 表示产生遥测数据的实体,在本例中就是我们的 escape-room-cat 服务。
ini
`
1. SERVICE_NAME = "escape-room-cat"
2. resource = Resource.create({"service.name": SERVICE_NAME})
`AI代码解释
service.name 是 OTel 标准语义约定中的一个键。_语义约定_是一套标准化、与供应商无关的命名规则,贯穿整个 OTel,用于组织数据结构。在后续章节中设置信号时,你还会看到更多这样的约定。
使用 OTLP 导出器设置追踪、指标和日志
以下代码通过 OTel 配置可观测性的三大支柱:日志、指标和追踪。下面逐一介绍各 类 信号的代码。
ini
`
1. trace_provider = TracerProvider(resource=resource)
2. trace_provider.add_span_processor(
3. BatchSpanProcessor(
4. OTLPSpanExporter(endpoint=f"{OTLP_BASE_URL}/v1/traces", headers=_AUTH_HEADER)
5. )
6. )
7. trace.set_tracer_provider(trace_provider)
8. tracer = trace.get_tracer(SERVICE_NAME)
`AI代码解释
这段代码配置了 TracerProvider,用于开始采集追踪,再由 OTLPSpanExporter 通过 OpenTelemetry 协议(OTLP)批量导出。一个 追踪(trace)由多个跨度(span)组成,而 span 可以通过添加属性来丰富其信息。本文稍后将介绍 cat.escape_attempt 这个 span。
ini
`
1. meter_provider = MeterProvider(
2. resource=resource,
3. metric_readers=[
4. PeriodicExportingMetricReader(
5. OTLPMetricExporter(endpoint=f"{OTLP_BASE_URL}/v1/metrics", headers=_AUTH_HEADER),
6. export_interval_millis=5000,
7. )
8. ],
9. )
10. metrics.set_meter_provider(meter_provider)
11. meter = metrics.get_meter(SERVICE_NAME)
`AI代码解释
接下来,通过 MeterProvider 和 PeriodicExportingMetricReader 配置指标。系统会按照读取器设定的时间间隔定时采集指标,再由 OTLPMetricExporter 以 OTLP 格式导出,而不是像追踪和日志那样,在事件到来时推送。
即使有了 PeriodicExportingMetricReader,仍然需要 MeterProvider,因为它是 OTel SDK 中的配置层,负责组织遥测数据的处理、采集,以及向 Elastic Observability 项目的导出。TracerProvider 和下文的 LoggerProvider 也发挥类似作用:
scss
`
1. logger_provider = LoggerProvider(resource=resource)
2. logger_provider.add_log_record_processor(
3. BatchLogRecordProcessor(
4. OTLPLogExporter(endpoint=f"{OTLP_BASE_URL}/v1/logs", headers=_AUTH_HEADER)
5. )
6. )
7. set_logger_provider(logger_provider)
8. logging.getLogger().addHandler(
9. LoggingHandler(level=logging.INFO, logger_provider=logger_provider)
10. )
11. logging.getLogger().setLevel(logging.INFO)
12. logger = logging.getLogger(SERVICE_NAME)
`AI代码解释
LoggerProvider 的工作方式与其他信号略有不同。这里的代码通过 LoggingHandler,将 Python 内置的 logging 模块与 OTel 连接起来,而不是创建一套新的日志系统。该处理器会将 Python 应用中的所有 logging.info 调用送入 OTel 管道,使结构化日志记录能够与其他信号一同导出。
计数器、仪表和 span:游戏产生的信号
现在,我们已经了解如何使用 OTel 为游戏添加插桩,接下来看看本应用中各类信号的具体配置。
首先,介绍最重要的计数器(counter)------一种只增不减的指标:
ini
`
1. escape_attempts_counter = meter.create_counter(
2. "cat.escape.attempts", description="Number of escape attempts by each cat", unit="1"
3. )
`AI代码解释
这个指标对游戏至关重要,因为它决定了你的胜负。只要任意一只猫的 cat.escape.attempts 计数器达到 10,你就输了,而那只猫会成功逃脱。
下一个指标是可观测仪表(observable gauge),其值由游戏中的函数控制:
ini
`
1. meter.create_observable_gauge(
2. "cat.suspicion.level",
3. callbacks=[_suspicion_callback],
4. description="How suspicious each cat is acting (0-100)",
5. unit="percent",
6. )
7. ------------------------------------------------------------
8. cat.suspicion = min(100, cat.suspicion + random.uniform(8, 15))
`AI代码解释
这个可观测仪表会在每个采集周期记录猫咪当前的可疑程度。cat.suspicion.level 由一个函数控制:随机选取 8 到 15 之间的浮点数,加到当前可疑程度上。
与只增不减的计数器不同,可观测仪表表示的是可以上下变化的值。这里使用可观测仪表而非计数器,是因为我们想了解的是_这只猫现在有多可疑?,而不是_累计增加了多少可疑程度? 在这个游戏中,猫咪的可疑程度是另一个可用于创建规则的指标。
下面是游戏的核心事件:每当猫咪尝试逃脱时,就会创建一个 span。这个 span 记录了是哪只猫、这是第几次逃脱尝试、猫咪的位置、尝试是否成功,以及猫咪的可疑程度。再下面的 span 则在猫咪每次移动时采集:
css
`
1. with tracer.start_as_current_span("cat.escape_attempt") as span:
2. span.set_attribute("cat.name", cat.name)
3. span.set_attribute("cat.position.x", cat.x)
4. span.set_attribute("cat.position.y", cat.y)
5. span.set_attribute("escape.attempt_number", attempt_num)
6. span.set_attribute("escape.success", attempt_num >= MAX_ATTEMPTS)
7. span.set_attribute("cat.suspicion.level", cat.suspicion)
8. span.set_attribute("game.phase", game.phase.value)
9. escape_attempts_counter.add(1, {"cat.name": cat.name})
10. ----------------------------------------------------------------------------------
11. with tracer.start_as_current_span("cat.move") as span:
12. span.set_attribute("cat.name", cat.name)
13. span.set_attribute("cat.position.x", cat.x)
14. span.set_attribute("cat.position.y", cat.y)
15. span.set_attribute("cat.distance.moved", dist)
16. span.set_attribute("game.phase", game.phase.value)
17. distance_traveled.add(dist, {"cat.name": cat.name})
`AI代码解释
在 Kibana 中查看追踪数据时,追踪及其 span 属性的显示如下:

从游戏映射而来的字段会带有 numeric_labels 或 labels 前缀。这是因为我们的数据从 OTel 格式转换为了 Elastic 通用模式(ECS)。不过,你也可以根据需要在 Kibana 中重命名这些字段,以缩短名称或方便搜索。

使用 Python logging 模块输出结构化日志
在三类信号中,日志最便于人类阅读。本游戏通过半结构化日志记录方式输出日志:
python
`
1. logger.warning(
2. f"🚨 {cat.name} escape attempt #{attempt_num}/{MAX_ATTEMPTS} "
3. f"at ({cat.x:.1f}, {cat.y:.1f}) near {edge_str} "
4. f"--- suspicion={cat.suspicion:.0f}%"
5. )
8. logger.info(f"🎮 {cat.name} begins escape plotting")
11. logger.error(f"🚪 {cat.name} ESCAPED! Game over.")
14. logger.info(
15. f"✅ Alert fired! Worst cat at {worst}/{MAX_ATTEMPTS}. "
16. f"Score: {game.score}. All cats caught!"
17. )
`AI代码解释
游戏会输出多条日志,并通过多种方法记录。通常,日志应保持清晰简洁,方便调试人员快速识别,尤其是错误日志。在这个 Flask 应用中,我们使用了 Python 的 logging 模块。发送到 Kibana 的日志分别通过 logger.info、logger.warning 和 logger.error 记录,对应不同的日志严重级别。logger.info 用于记录应用的一般信息,logger.warning 表示系统已从中恢复的意外事件,logger.error 则记录故障或严重错误。在《猫咪大逃脱》中,唯一的错误就是你输掉游戏。
这些日志通过标准 Python 日志记录器输出,再由 OTLP 日志导出器导出,因此在 Kibana 中会显示结构化的 severity_text、资源属性,以及与追踪的关联信息:
bash
`🚨 Mittens escape attempt #2/10 at (42.2, 62.1) near inner yard --- suspicion=24%`AI代码解释
接下来是 _log,这是一个专为游戏内使用而编写的自定义日志函数:
python
`
1. def _log(msg: str):
2. ts = time.strftime("%H:%M:%S")
3. with game_lock:
4. game.log_lines.insert(0, f"[{ts}] {msg}")
5. if len(game.log_lines) > 60:
6. game.log_lines.pop()
7. ----------------------------------------------------------------------------------
8. _log(
9. f"🚨 {cat.emoji} {cat.name} attempt #{attempt_num}/{MAX_ATTEMPTS} "
10. f"near {edge_str} ({cat.suspicion:.0f}%)"
11. )
12. ----------------------------------------------------------------------------------
13. _log(f"🎉 ALERT FIRED! Worst cat at {worst}/{MAX_ATTEMPTS}. Score: {game.score}")
14. return jsonify({"ok": True, "score": game.score, "worst_count": worst})
15. ----------------------------------------------------------------------------------
16. _log(f"🚪💨 {cat.emoji} {cat.name} ESCAPED! YOU LOSE!")
17. return
18. ----------------------------------------------------------------------------------
19. _log(f"{cat.emoji} {cat.name} starts pacing...")
`AI代码解释
这个日志函数会获取当前时间,并使用通过 game_lock = threading.Lock() 创建的 game_lock,向玩家展示与 Kibana 接收到的相同信息。在游戏中,多个线程(猫咪)会发出请求。当两个线程同时访问同一资源时,可能导致数据损坏,而 game_lock 可以避免这种冲突造成的数据丢失。
现在你已经了解插桩机制。在编写规则并开始游戏之前,最后一步是了解 alert_fired API ,并配置所需的 Webhook,让你的 URL 能够接收告警。
为告警配置 Webhook 连接器
要发送 Webhook 告警,需要使用 ngrok 或类似的隧道工具,将本地端口开放给 Kibana,使其能够通过互联网向该端口发送 POST 请求。隧道会为本地端口提供一个公共 URL,也就是你在 Kibana 中配置的 <your-tunnel>。在 Kibana 中添加一个类型为"Webhook"的连接器,并使用以下设置:
bash
`
1. Method: POST
2. URL: http://<your-ip>:5003/api/alert_fired
3. Headers: Content-Type: application/json
4. ----------------------------------------------------------------------------------
5. # You configure this with your rule under "Actions" later
6. Body:
7. {
8. "cat_name": "{{context.group}}",
9. "current_count": {{context.value}},
10. "rule_name": "{{rule.name}}",
11. "fired_at": "{{context.date}}"
12. }
`AI代码解释
在 Kibana 9.4 中,连接器位于 Stack Management → Connectors(Stack 管理 → 连接器)。保存后,即可在所有规则中复用。
{{context.group}} 字段表示哪只猫触发了规则,fired_at 中的 {{context date}} 字段则表示告警发生的时间。这些参数仅用于提供信息,而决定游戏胜负的端点 /api/alert_fired 会对 current_count 和 rule_name 进行更严格的检查。
以下代码展示了游戏的 /api/alert_fired 端点。它根据上述参数设置了多项检查,以确保告警准确匹配游戏中的具体情况。这些检查在实际系统的告警中同样重要,有助于确认通过告警 Webhook 发送的确实是预期来源、系统或告警。
python
`
1. @app.route("/api/alert_fired", methods=["POST"])
2. def api_alert_fired():
3. # Checkpoint 1
4. payload = request.get_json(silent=True) or {}
7. # Checkpoint 2
8. rule_name = payload.get("rule_name")
9. if rule_name != EXPECTED_RULE_NAME:
10. return jsonify({"ok": False, "msg": "Unexpected or missing rule_name."}), 400
13. # Checkpoint 3
14. count = payload.get("current_count")
15. if not isinstance(count, (int, float)) or count < MIN_COUNT:
16. return jsonify({"ok": False, "msg": "Missing or invalid current_count."}), 400
`AI代码解释
该端点在判定获胜之前会执行三项检查,对应上方代码中的编号注释:
- 解析请求体。
request.get_json将 Webhook 发送的请求体解析为 Python 字典。传入silent=True后,空请求体或格式错误的请求体不会引发错误;端点会继续运行,并使用{}作为回退值。 - 检查规则名称。
rule_name必须为Cat Escape Alert,以防其他来源的意外请求触发获胜判定。请严格使用此名称,或在代码中修改它。(参见EXPECTED_RULE_NAME = "Cat Escape Alert"。) - 检查尝试次数。 这项检查可防止在没有猫咪实际行动时,将告警计为获胜。阈值设为 1,因此必须至少记录一次逃脱尝试。如果希望提高门槛,可以在代码中修改。(参见
MIN_COUNT = 1。)
如何使用 ES|QL 创建 Kibana 告警规则
游戏有两种结局:如果你创建的规则正确触发告警,你就获胜;如果任意一只猫的逃脱尝试次数达到 10 次,你就失败。
请按照以下说明创建规则并开始游戏。以字母编号的小节将详细介绍如何创建一条能够助你获胜的规则。
1)进入 Kibana → Stack Management → Rules(Kibana → Stack 管理 → 规则)。

2)选择 Create new rule(创建新规则)→ Elasticsearch query(Elasticsearch 查询)。

3)创建一条针对日志或指标触发告警的规则。你可以使用 Kibana 查询语言(KQL)、Lucene、 Query DSL 或 ES|QL。

- 要创建一条能助你获胜的规则,请选择 ES|QL,并将以下代码填入查询编辑框。游戏的服务名称为
escape-room-cat,而cat.escape.attempts IS NOT NULL会过滤掉所有不包含逃脱尝试次数指标的行。由于cat.escape.attempts是 OTel 计数器,它只会递增,且不会在重启游戏时重置。为避免累计值的影响,我们计算时间窗口内计数器的变化量(EVAL attempts = hi - lo),而不是直接使用其累计值。 - ES|QL 查询:
sql
`
1. FROM metrics-apm*
2. | WHERE service.name == "escape-room-cat" AND cat.escape.attempts IS NOT NULL
3. | STATS hi = MAX(cat.escape.attempts), lo = MIN(cat.escape.attempts) BY cat = labels.cat_name
4. | EVAL attempts = hi - lo
5. | WHERE attempts >= 1
6. | SORT attempts DESC
7. | KEEP cat, attempts
`AI代码解释
- 将规则的执行间隔设置为每 30--45 秒一次。
4)使用已配置的 Webhook 添加一个操作,并采用本文"为告警配置 Webhook 连接器"一节中的配置。



json
`
1. {
2. "cat_name": "{{context.group}}",
3. "current_count": {{context.value}},
4. "rule_name": "{{rule.name}}",
5. "fired_at": "{{context.date}}"
6. }
`AI代码解释
5)点击 Start Game(开始游戏),然后等待。
6)如果告警及时触发,你就赢了,侦探!你找出了目标猫咪,并及时抓住了它。如果规则未能在第十次逃脱尝试前触发告警,你就输了。试着重新配置规则,检查规则名称是否与检查点要求的 rule_name 完全一致(包括空格和大小写),或调整规则的阈值或执行时间设置。


从猫咪逃脱到撞库攻击检测
这款有趣的互动捉猫游戏,实际上演示了一套简单且各环节正确衔接的可观测性架构。应用中组装的各个部分,正是生产级可观测性技术栈的基础组件,包括:向 Elastic Observability 端点发送数据的 OTLP/HTTP 导出器、通过 resource 标记 service.name 的信号,以及彼此独立的 MeterProvider、LoggerProvider 和 TracerProvider 管道。此外,还包括与描述同一事件的 span 一同输出的计数器、通过回调采集的可观测仪表样本,以及经过转换、用于根据差值触发告警的 ES|QL 查询等。而且,Elastic 9.4.0 还支持原生使用 Prometheus 查询语言(PromQL)查询时间序列数据,为你提供更多查询选择。
如果将"逃脱尝试"替换为"身份验证失败",将 cat.name 替换为 user.id,你为抓住翻越围栏的虎斑猫而创建的同一条规则,就能变成撞库攻击检测器。试试不同的规则、告警类型和查询频率,看看你能在这款游戏中用 Elastic Observability 玩出多少创意。
如果在使用《猫咪大逃脱》时需要帮助,欢迎联系作者。

阳光透过窗户洒进屋内。手握可靠的规则,告警也已就位,侦探终于松了一口气。对这位经验老到的私家侦探而言,昔日彻夜 grep 搜索的日子,如今已变成迅速的决策和亮眼的高分。这起案子虽然棘手,却没有一只猫逃过可观测性后端的严密监视。此次调查虽已告捷,未来还会有更多案子,更多猫咪。不过现在,侦探已经准备好了。
原文:Elastic APM alerting: Build Kibana rules with ES|QL | Elastic Observability Labs