Postgres 里按归一化查询汇总统计的视图。

pg_stat_statements 找出最贵的查询

因为 pg_stat_statements 会把同一条查询形态的每一次执行都聚合起来,「这个页面慢」就变成了一张排好序的表。代价只是一行配置加一次重启;回报是你能知道上万种查询形态里,到底是哪一种在占用时钟。 真正重要的决定只有一个:按哪一列排序。按平均耗时排,只是把慢日志已经告诉过你的事再说一遍;按总耗时排,才能找出那个一直悄悄吃掉数据库四分之一时间的查询。 先归一化,再聚合 这个扩展并不原样保存你的 SQL。常量会被折叠成参数符号,于是 WHERE tenant_id = 42 和 WHERE tenant_id = 8712 合并成同一条记录,展示出来的文本是第一个产生该 queryid 的查询。编号规则是位置式的——符号从当前文本里最大 $n 的下一个数字开始,所以 PL/pgSQL 里的 SELECT i + 1 INTO j 会显示成 SELECT i + $2。列表的行为一样:只差 IN 元素个数的两条查询会被压成 IN ($1 /*, ... */),调用次数记为 2。 ...

十二月 12, 2027 · 1 分钟 · XB Field Notes
三个相连的方块,依次标注观察、决策与行动。

链路采样减少数据量,但不会固定成本上限

链路采样决定哪些观测数据进入后续追踪管线,可以减少导出流量和存储量,但配置的百分比并不是字节数、处理器开销或费用的严格上限。不同请求产生的跨度数量不同,属性大小不同,业务流量也不断变化。即使一直保留百分之一的数据,如果请求流量突然增加十倍,被保留的数据量仍然可能大致增加十倍。不能只看到比例很低,就判断采集系统已经有了确定的成本边界。 头部采样在链路开始附近作出决定。基于链路标识的比例采样器,需要标识满足算法所依赖的随机性要求。其概率描述的是合适流量总体中的选择规律,并不承诺每十六个请求都恰好保留一半,也不保证每条业务路由都得到同等覆盖。对于数量很少的请求和罕见故障,即使整体采样百分比看起来正常,保留下来的实例仍可能不足以解释问题。 让同一项决定跨边界保持一致 如果系统期望同一条链路沿用上游决定,下游插桩就应遵循已经接受的父上下文。Python SDK 的 ParentBased 对根跨度调用配置的根采样器,并为已采样、未采样的父跨度应用对应策略。因此,把 TraceIdRatioBased(0.5) 包在父级策略中,与每个下游跨度都独立进行一次抽样不同。上下文传播方式和接收端采样配置必须配合,不能只修改入口的百分比,而忽略其余服务如何解释传播标志。 未被采样的链路仍然要执行业务代码,也仍可能涉及上下文处理和插桩开销。省掉导出并不意味着整个请求没有成本。反过来,即使父级决定得到保留,也不能保证每个子操作都能在界面中出现:某个库可能没有插桩,服务可能丢失上下文,导出器也可能失败。采样一致性只解决数据缺失的一部分原因,无法独自证明整个观测传输过程都可靠。 坏掉的样子 请求在开始时可能一切正常,直到后半程才失败。如果头部采样器早已决定丢弃,应用之后设置错误状态,并不能把未记录的跨度补回来。尾部采样可以先收集更多跨度,再依据整条链路的信息判断,但需要缓冲数据、将相关跨度路由到一起,并处理迟到或缺失的数据。它同样无法重建到达自身之前就被上游舍弃的跨度,因此不能把尾部规则理解成无条件保留所有失败的保证。 另一种误用,是直接拿带有选择偏差的追踪数据计算业务可靠性。专门保留慢请求或错误请求的策略,已经改变了观察总体。应另外维护分母清晰的请求结果指标,并在排查说明中交代采样策略。要判断费用是否受控,还需要观察实际导出的跨度数和字节数、队列情况、采集器资源与数据保留期限。一个配置项只能说明计划采用怎样的选择规则,不能替代实际负载与费用证据。 快速验证 创建 Python 虚拟环境并安装 opentelemetry-sdk==1.35.0,将代码保存为 trace-sampling.py,再使用该环境运行。本次执行环境是 Python 3.14.4。代码中的自定义标识生成器专门服务于确定性测试:十六个标识被有意安排在这个 SDK 版本的比例判断阈值两侧。生产环境不能照搬这种顺序标识方案,否则将破坏采样器依赖的随机分布假设。这里追求的是可重复核对已知输入的结果。 import json from collections import Counter from opentelemetry.context import Context from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import SimpleSpanProcessor from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter from opentelemetry.sdk.trace.id_generator import IdGenerator from opentelemetry.sdk.trace.sampling import ParentBased, TraceIdRatioBased from opentelemetry.trace import Status, StatusCode from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator class TestIds(IdGenerator): # Controlled test inputs only; production IDs need suitable randomness. def __init__(self): self.trace_index = 0 self.span_index = 0 def generate_trace_id(self): index = self.trace_index self.trace_index += 1 return ((index + 1) << 64) | (index * (1 << 60) + 1) def generate_span_id(self): self.span_index += 1 return self.span_index exporter = InMemorySpanExporter() provider = TracerProvider(sampler=ParentBased(TraceIdRatioBased(0.5)), id_generator=TestIds()) provider.add_span_processor(SimpleSpanProcessor(exporter)) tracer = provider.get_tracer("sampling-lab") executed_children = 0 late_error_recording = None for index in range(16): with tracer.start_as_current_span(f"root-{index}", context=Context()) as root: for child in range(2): with tracer.start_as_current_span(f"child-{index}-{child}"): executed_children += 1 if index == 15: late_error_recording = root.is_recording() root.set_status(Status(StatusCode.ERROR, "error discovered after start")) assert provider.force_flush() spans = exporter.get_finished_spans() counts = Counter(span.context.trace_id for span in spans) assert len(counts) == 8 and set(counts.values()) == {3} and len(spans) == 24 assert {s.name for s in spans if s.parent is None} == {f"root-{i}" for i in range(8)} assert executed_children == 32 and late_error_recording is False assert not any(span.name == "root-15" for span in spans) exporter.clear() propagator = TraceContextTextMapPropagator() for flag, label in (("01", "sampled"), ("00", "unsampled")): # High trace-ID bits do not override an accepted remote parent's flag. header = f"00-1111111111111111ffffffffffffffff-2222222222222222-{flag}" context = propagator.extract({"traceparent": header}, context=Context()) with tracer.start_as_current_span(label, context=context): with tracer.start_as_current_span(label + "-child"): pass assert provider.force_flush() remote_spans = exporter.get_finished_spans() assert {span.name for span in remote_spans} == {"sampled", "sampled-child"} assert next(span for span in remote_spans if span.name == "sampled").parent.is_remote print(json.dumps({"controlled_roots": 16, "exported_traces": len(counts), "exported_spans": len(spans), "executed_child_operations": executed_children, "late_error_was_recording": late_error_recording, "remote_parent_exported_spans": len(remote_spans)}, indent=2)) provider.shutdown() 十六个根操作分别启动两个子操作,最终应导出八条链路、二十四个跨度,而三十二个子操作全部实际执行。结果恰好为一半,是测试输入被精确安排的产物,不能推广成随机业务流量中的逐批保证。编号十五的根操作被故意放在不保留的集合内;后来设置错误状态,也不会令它转成记录状态或出现在导出结果里。断言直接验证这一限制,而不是通过几次随机运行估计采样比例。 ...

十月 23, 2027 · 2 分钟 · XB Field Notes
Prometheus 抓取结果主题封面,展示观察、判断与行动三个步骤。

Prometheus 主动抓取指标,并记录抓取结果

Prometheus 会主动请求每个已配置目标的 HTTP 指标端点。目标负责暴露观测值,采集器决定抓取时刻、解析响应并保存样本。这种安排让采集故障能够从采集器一侧被看见,但不能保证端点一定可达、所有预期目标都被发现,也不能保证用户的登录或下单流程正常。理解这些边界,才能把采集结果转换成准确的告警和排查动作。 一个任务包含多个独立抓取的实例 任务通常把用途相同的目标组织在一起,例如一组接口服务副本。每个目标仍是独立实例,具有自己的抓取结果。常见的 job 与 instance 标签标识这层上下文,最终标签也可能经过重新标记而改变。服务发现提供候选目标,抓取配置再决定实际访问的端点、协议、周期、超时和认证方式。因此,目标没有被发现与目标已经配置但抓取失败,是两类不同的问题。 采集器必须能够连接目标。虽然导出器不需要主动向采集器上传指标,目标仍然需要允许经过授权的采集器访问其入站端口。网络策略、证书身份、认证凭据与路由不会因为采用拉取模式而消失。拉取只是改变连接由哪一方发起,不能把任意导出器变成可以直接暴露在公网的安全接口。部署时要从采集器所在网络检查这条路径。 准确理解系统生成的指标 对于已配置的实例,Prometheus 会生成 up 指标,抓取成功时为一,失败时为零。它是指标名称,不是所谓的 up 标签。抓取耗时与样本数量则补充了采集质量的证据。导出器可能在后端数据库不可用时仍返回合法响应,也可能漏掉一项重要业务指标,因此即使 up 为一,也不能推导后端依赖和全部业务观测都正常。 这些进一步的问题应由应用结果指标、导出器专门提供的依赖状态,以及实际业务探测回答。反过来,up=0 描述的是采集失败,监控网络故障可能使它变为零,而用户请求仍然成功。告警措辞应写清采集目标与错误证据,避免让响应人员把所有监控链路问题直接当成客户故障。业务影响仍需要另一组证据确认。 坏掉的样子 连接被拒绝、请求超时、证书校验失败或导出格式无效,都可能让抓取失败。先查看目标的最近抓取错误,再决定需要调整什么。延长周期无法修复错误端口;增大超时可能让缓慢的导出器看起来恢复,却不会消除它的执行成本。采集变贵时还应检查输出序列数量和响应大小,确认是否新增了不必要的标签组合或昂贵查询。抓取超时不能超过配置周期,缩短周期前则应评估导出器负载。更快的轮询可能成倍增加导出器对数据库的查询次数,却不会降低每次查询的成本。 从服务发现中移除的目标,与继续存在但返回零的目标不同。前者的对应序列之后可能不再出现在即时查询中,所以把全站 up 求和,并不能证明所有预期实例仍然存在。需要完整性保证时,应把发现结果与实际部署清单对照。单独请求一次 /metrics 成功,同样不能代替登录、支付或订单提交的功能验证。 快速验证 把下面程序保存为 pull.py,在 Docker 可用的环境执行 python3 pull.py。程序使用 prom/prometheus:v3.5.1,如果本机尚未缓存镜像,首次运行需要从镜像仓库下载。实验只创建一个一次性容器,让其中的 Prometheus 抓取自己的回环端点,以及回环地址上没有服务监听的端口。它不发布宿主机端口,容器也无法访问外部网络。 import json import subprocess import uuid # Requires Docker and the public Prometheus 3.5.1 image. # All HTTP traffic stays on loopback inside one disposable container. script = r"""set -eu cat > /tmp/prom.yml <<'CONFIG' global: scrape_interval: 1s scrape_timeout: 500ms scrape_configs: - job_name: self static_configs: - targets: ['127.0.0.1:9090'] - job_name: refused static_configs: - targets: ['127.0.0.1:1'] CONFIG /bin/prometheus --config.file=/tmp/prom.yml --storage.tsdb.path=/tmp/data \ --web.listen-address=127.0.0.1:9090 > /tmp/prom.log 2>&1 & for attempt in $(seq 1 30); do if wget -qO /tmp/ready.json 'http://127.0.0.1:9090/api/v1/query?query=count%28up%29%20%3D%3D%202%20and%20sum%28up%29%20%3D%3D%201'; then if grep -q '"status":"success"' /tmp/ready.json && ! grep -q '"result":\[\]' /tmp/ready.json; then wget -qO- 'http://127.0.0.1:9090/api/v1/query?query=up' exit 0 fi fi sleep 1 done cat /tmp/prom.log >&2 exit 1 """ name = "xbcatm-prom-live-" + uuid.uuid4().hex[:12] try: result = subprocess.run( ["docker", "run", "--rm", "--name", name, "--network", "none", "-i", "--entrypoint", "/bin/sh", "prom/prometheus:v3.5.1", "-s"], input=script, text=True, capture_output=True, timeout=45, ) finally: subprocess.run(["docker", "rm", "-f", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) if result.returncode: raise RuntimeError(result.stderr or result.stdout) payload = json.loads(result.stdout) assert payload["status"] == "success" values = {item["metric"]["job"]: float(item["value"][1]) for item in payload["data"]["result"]} assert values == {"self": 1.0, "refused": 0.0}, values print(json.dumps({"up_by_job": values, "scrape_reachability_only": True}, indent=2)) 结果应当包含 self: 1.0 和 refused: 0.0。程序等待两个目标都有抓取结果,再通过查询接口进行断言,因此不会把启动期间数据库暂时为空误判成配置失败。退出清理仅针对本次随机命名的容器,不涉及其他容器。这个结果证明真实采集器可以区分成功抓取与连接拒绝;它没有测试业务功能,也没有证明服务发现清单完整。 ...

八月 30, 2027 · 2 分钟 · XB Field Notes
三个相连的方块,依次标注观察、决策与行动。

结构化日志让查询约定明确可见

结构化日志为字段赋予稳定含义,使查询不必每次都从说明文字中推断全部信息。一条记录可以包含事件名称、请求编号、数值时长、严重级别,以及供人阅读的消息。自由文本仍然有价值,也可以被搜索或解析。结构化的实际收益,是让常用问题依赖一份明确的数据约定,即使说明措辞改变,字段的语义仍然保持稳定,而不是简单地给原来的一段话套上花括号。 这份约定必须涉及类型和单位。作为数字保存的时长可以直接进行数值比较,作为字符串保存时,索引和比较方式却可能不同。毫秒与秒不能共用一个含义模糊的字段名称,事件名称也应跨版本稳定描述所发生的动作。模式版本能够标识有意进行的格式变化,但它不会自动转换旧记录,也不会自动保证全部查询消费者继续兼容。字段设计需要围绕真实查询,而不是追求看起来丰富的输出。 把上下文放到正确的记录上 请求编号只有属于当前发出日志的操作时才有帮助。多个请求并发执行时,使用一个进程级可变全局变量保存当前编号并不合适。Python 上下文变量可以在异步任务范围内保持局部状态,但前提是在正确作用域设置并恢复值。这并不意味着所有线程或自定义执行机制都采用同一种继承方式;经过真正的并发边界时,仍需验证上下文怎样到达执行位置。 需要在上下文尚可取得的位置捕获它。本例在发出日志的任务中同步格式化记录,因此格式化器能够读取该任务的上下文变量。如果改成通过队列交给另一线程或进程处理,就应在交接前把所需字段附加到记录上。让监听线程临时读取它自己的当前请求上下文,并不能可靠恢复原发出者的身份。数据最终都进入同一个日志文件,不代表它们在跨线程之后仍然拥有正确的关联关系。 坏掉的样子 手工拼接 JSON 时,消息中的引号或换行可能破坏格式。按行读取的采集器随后可能把一个应用事件拆成多个看似独立的事件。应使用 JSON 序列化器,并保证每个物理行包含一个完整对象。消息的逻辑字符串可以保留换行,但在线上表示中它会被转义,不会产生新的记录边界。后续界面展示解码后的消息时,也应继续清楚区分记录,避免展示方式重新造成误读。 结构化输出不会自动建立索引、降低查询账单或保证投递成功。不受约束的字段、意外变化的类型、缺失的编号和不该记录的敏感内容,都可能使一份合法 JSON 流难以使用。需要依据实际问题选择字段,保持模式稳定,并核对采集器的解析规则和数据保留方式。可查询的结构是生产者与消费者之间的约定,不是文件扩展名或者一次序列化成功就能单方面建立的能力。 快速验证 将脚本保存为 structured-logging.py 并用 Python 执行;本次实际版本为 Python 3.14.4,只使用标准库。两个异步请求各自写入开始事件,在共同的放行点等待,再写入包含刻意构造消息的完成事件。这会让两个请求的存活区间重叠,便于检查编号是否混淆。时长是用于检查 JSON 类型的固定合成值,并不是测得的请求性能,输出也只保存在内存里供断言读取。 import asyncio import io import json import logging from collections import Counter from contextvars import ContextVar request_id = ContextVar("request_id", default=None) stream = io.StringIO() class JsonFormatter(logging.Formatter): def format(self, record): return json.dumps({"schema_version": 1, "event": record.event, "request_id": request_id.get(), "worker": record.worker, "duration_ms": record.duration_ms, "level": record.levelname, "message": record.getMessage()}, ensure_ascii=False, separators=(",", ":")) logger = logging.getLogger("structured-log-lab") logger.setLevel(logging.INFO) logger.propagate = False handler = logging.StreamHandler(stream) handler.setFormatter(JsonFormatter()) logger.addHandler(handler) async def request(name, ready, release): token = request_id.set(name) try: logger.info("starting %s", name, extra={"event": "request.started", "worker": name, "duration_ms": 0}) ready.set() await release.wait() await asyncio.sleep(0) logger.info('untrusted line\n{"event":"forged"}', extra={"event": "request.completed", "worker": name, "duration_ms": 4}) finally: request_id.reset(token) async def main(): ready = [asyncio.Event(), asyncio.Event()] release = asyncio.Event() tasks = [asyncio.create_task(request(name, signal, release)) for name, signal in zip(("req-a", "req-b"), ready)] await asyncio.gather(*(signal.wait() for signal in ready)) release.set() await asyncio.gather(*tasks) assert request_id.get() is None try: asyncio.run(main()) finally: logger.removeHandler(handler) handler.close() lines = stream.getvalue().splitlines() records = [json.loads(line) for line in lines] assert len(lines) == 4 assert Counter(record["request_id"] for record in records) == {"req-a": 2, "req-b": 2} assert all(record["request_id"] == record["worker"] for record in records) assert all(type(record["duration_ms"]) is int for record in records) completed = [record for record in records if record["event"] == "request.completed"] assert len(completed) == 2 and all("\n" in record["message"] for record in completed) assert not any(record["event"] == "forged" for record in records) print(json.dumps({"physical_lines": len(lines), "parsed_records": len(records), "requests": 2, "context_mixing": False, "numeric_duration_preserved": True, "forged_records": 0}, indent=2)) 断言要求最终恰好有四个物理行、四条可解析记录,每个请求编号对应两条记录。即使请求并发,记录中的编号也必须与实际发出日志的工作任务一致。时长在解析后仍然是数字,完成消息中的换行保留在字符串内部,却不会多出一个名为 forged 的伪造事件。每个任务最后恢复自己的上下文令牌,外层上下文仍然没有请求编号,从而验证本例的设置和清理作用域。 ...

三月 9, 2027 · 2 分钟 · XB Field Notes
三个相连的方块,依次标注观察、决策与行动。

链路追踪连接一次请求中已被记录的操作

分布式链路追踪利用共同的链路标识和明确的关系,连接一次请求中被记录下来的操作。一个跨度表示一项操作,带有开始与结束时间、属性、事件和状态。当结账请求依次涉及应用、库存服务和支付服务时,这些信息可以帮助我们还原处理路径。但最终图形取决于哪些位置做了插桩、哪些数据通过采样并成功导出,不能把它当成请求实际执行过的全部动作清单。 链路标识用于归集相关跨度,跨度标识用于区分同一条链路中的具体操作,父跨度标识则连接直接的上游操作。通过 HTTP 跨越边界时,W3C 的 traceparent 请求头携带链路标识、发送端跨度标识和链路标志。接收端必须提取这些上下文,再创建自己的跨度。仅仅在几条互不关联的日志里写入同一个请求编号,并不会自动形成可供追踪系统识别的跨度父子关系。对于由多个上游共同触发的工作,可以用跨度链接表达额外关系,而不是给一个跨度分配多个父级。 先读关系,再分析耗时 一个服务端跨度可能包含数据库操作,也可能包含多个同时进行的外部请求。把所有跨度的时长相加,会重复计算重叠的时间;如果兄弟跨度并发执行,用父跨度时长减去所有子跨度时长,也可能得到毫无意义的负数。应当阅读时间线,判断哪些区间位于影响请求完成的关键路径。跨机器的时钟差异以及缺失的插桩,还会限制我们对图上空白区间的解释精度。 跨度状态也需要单独理解。HTTP 响应码、异常事件和 OpenTelemetry 的错误状态是三份不同的信息,插桩库可能依据自己的约定将它们关联起来。手工记录异常时,不能因此假定所有有关的状态字段都已经改变。服务名称等资源属性回答数据由哪个服务发出,而某个跨度的名称、属性和事件回答该服务中发生了什么操作。混淆这两个层次,会使跨服务排查失去可靠的定位依据。 坏掉的样子 代理可能丢弃传播请求头,接收服务也可能没有调用上下文提取逻辑。此时业务请求已经成功抵达下游,但下游工作在追踪系统里属于另一条链路。还有一种错误是链路标识相同,父跨度却连接错误:数据看似聚到一起,实际因果位置已经不对。验证一个服务边界时,既要检查链路标识,也要检查直接父跨度,不能只确认后端收到了几条跨度就宣布传播正确。 找不到某个跨度,可能是采样舍弃、尚未插桩、异步执行中丢失上下文,或者导出失败造成的,不能据此证明该操作没有运行。同样,界面里一片绿色,也不代表所有下游依赖都成功了。需要结合请求最终结果指标和相关日志,核对采集器及导出器的健康状况,然后再解释链路图。不完整的数据可以帮助提出问题,却不能独自证明一条没有观测缺口的完整处理路径。 快速验证 先创建 Python 虚拟环境,安装 opentelemetry-sdk==1.35.0,其依赖会安装匹配的 API 版本。将以下代码保存为 trace-flow.py,再使用该环境的 Python 执行;本次实际使用 Python 3.14.4。脚本启动仅监听本机回环地址的 HTTP 服务,分别为调用者和接收者创建追踪提供器,把完成的跨度写入内存导出器。请求结束后会关闭服务,无须准备独立的遥测采集器。 import json from http.server import BaseHTTPRequestHandler, HTTPServer from threading import Thread from urllib.error import HTTPError from urllib.request import Request, urlopen from opentelemetry.context import Context from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import SimpleSpanProcessor from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter from opentelemetry.trace import SpanKind, Status, StatusCode from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator exporter = InMemorySpanExporter() providers = [TracerProvider(resource=Resource({"service.name": name})) for name in ("caller", "worker")] for provider in providers: provider.add_span_processor(SimpleSpanProcessor(exporter)) caller, worker = [p.get_tracer("trace-lab") for p in providers] propagator = TraceContextTextMapPropagator() class Handler(BaseHTTPRequestHandler): def log_message(self, *args): pass def do_GET(self): carrier = {key.lower(): value for key, value in self.headers.items()} parent = propagator.extract(carrier, context=Context()) with worker.start_as_current_span("handle" + self.path, context=parent, kind=SpanKind.SERVER) as span: status = 200 if self.path == "/fail": try: raise ValueError("synthetic inventory failure") except ValueError as error: span.record_exception(error) span.set_status(Status(StatusCode.ERROR, str(error))) status = 503 body = b"lab response\n" self.send_response(status) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) server = HTTPServer(("127.0.0.1", 0), Handler) thread = Thread(target=server.serve_forever, daemon=True) thread.start() base = f"http://127.0.0.1:{server.server_port}" try: with caller.start_as_current_span("request", context=Context()): with caller.start_as_current_span("outbound", kind=SpanKind.CLIENT): headers = {} propagator.inject(headers) with urlopen(Request(base + "/ok", headers=headers), timeout=5) as response: assert response.status == 200 response.read() try: with urlopen(base + "/fail", timeout=5) as response: raise AssertionError("Expected HTTP 503") except HTTPError as response: with response: assert response.code == 503 response.read() finally: server.shutdown() thread.join(timeout=5) server.server_close() for provider in providers: assert provider.force_flush() spans = {span.name: span for span in exporter.get_finished_spans()} assert len(spans) == 4 root, client, received, orphan = [spans[name] for name in ("request", "outbound", "handle/ok", "handle/fail")] assert root.parent is None assert root.context.trace_id == client.context.trace_id == received.context.trace_id assert client.parent.span_id == root.context.span_id assert received.parent.span_id == client.context.span_id and received.parent.is_remote assert orphan.parent is None and orphan.context.trace_id != root.context.trace_id assert orphan.status.status_code == StatusCode.ERROR assert [event.name for event in orphan.events] == ["exception"] assert received.resource.attributes["service.name"] == "worker" assert client.resource.attributes["service.name"] == "caller" print(json.dumps({"exported_spans": len(spans), "linked_spans": 3, "unpropagated_request_started_new_trace": True, "failed_span_status": orphan.status.status_code.name, "exception_events": len(orphan.events)}, indent=2)) for provider in providers: provider.shutdown() 第一个请求应形成 request、outbound 和 handle/ok 三个相连跨度。断言逐项核对共同的链路标识、两处正确的父子连接、服务端识别出的远端父上下文,以及两个提供器不同的服务名称。第二个请求故意不携带传播头,服务返回五百零三响应,并创建另一条链路的根跨度;代码明确记录一个异常事件,同时设置错误状态。最终总共导出四个跨度,这些结果分别证明连接正常、边界断开和失败信息记录的实际行为。 ...

十二月 15, 2026 · 2 分钟 · XB Field Notes
毫秒先转换成秒,再以含明确单位的指标名导出。

把单位写进指标名里

名为 request_duration 的指标会让读者猜测,二百五十究竟表示秒、毫秒还是别的量。面向 Prometheus 的名称,例如 http_request_duration_seconds,能够把单位约定带入查询、记录规则与告警配置。但这个约定必须由正确实现支撑:给毫秒数据的名字加上秒后缀,不会自动改变数值大小,反而可能让监控产生一千倍的误差。名称提供线索,转换逻辑才决定数据是否可信。 在明确的边界完成换算 每个指标族应只表示一种量和一种单位。Prometheus 建议使用秒、字节等基础单位,并在适用时把单位写为后缀。持续时间与绝对时间戳都可以用秒表示,却不是同一种量,因此不能因为名字都含秒,就把上次成功的时间戳与一次请求的耗时直接相加。帮助文本应写明测量对象、开始结束边界以及含义,让简短名称背后仍有明确的契约。 如果底层库输出毫秒,应在观测或导出边界转换一次,并保留足够精度,避免把很短的耗时截断为零。仪表盘可以按基础单位配置显示,让零点二五秒自动显示为二百五十毫秒,这只改变呈现方式,不改变存储样本。不能仅因为坐标轴显示毫秒,就在记录规则中再做一次换算,否则查询和图表会在不同环节重复缩放。 单位与类型回答不同问题 累计传输字节数通常使用字节加总量后缀,当前内存占用仪表则只使用字节后缀。两者单位相同,但只有前者表示不断累积的工作量,适合计算计数器速率。请求次数按惯例使用总量后缀,无须编造一个物理单位。直方图的桶、总和与计数,还会在耗时指标族名称后加入各自的组件后缀,因此阅读名称时也要识别指标族与导出组件之间的关系。 比例通常表示零到一之间的分数,而百分数使用另一种数值尺度,面板应明确自己期待哪种输入。计数器可以在进程重启时归零,Prometheus 的计数器速率函数会根据观测序列中的下降处理重置,并不要求额外存在创建时间序列。应先分别计算独立计数器的速率,再执行聚合,让各自的重置仍能被识别;不要先把多个实例的累计值混成一条看似连续的曲线。 坏掉的样子 名字正确的指标仍然可能承载错误数值。如果同一个标识下混入秒和毫秒,历史数据已经被污染,修改帮助文字或补一个说明标签不能修复旧样本。单位迁移应被视为契约变化,生产端、查询、告警和仪表盘需要一起核对,历史区间的比较也必须说明边界。旧序列含义不兼容时,可以采用新的指标标识,避免新旧数值被误当成同一种量连续使用。 名称后缀也不会强制执行计数器语义。一个普通运行过程中可以升降的仪表,即使名字带总量后缀,也不适合按累计计数器解释。反过来,已经得到每秒速率后再除以时间窗口,会制造另一种量。这些是维度和测量语义的问题,不能只靠显示格式解决。其他遥测体系可能把单位放在元数据中,应遵循已定义的导出映射,而不是机械重命名所有上游仪器。 快速验证 把代码保存为 units.py,使用 Python 3 执行。程序转换三条人为构造的延迟观测,并输出带帮助文字和类型声明的 Prometheus 文本格式。十进制定点运算使毫秒到秒的换算清楚可见;阈值比较则展示,仅仅改名、却不转换数据的生产端,为什么不能和正确的生产端共用同一条延迟规则。 from decimal import Decimal latency_ms = [Decimal('120'), Decimal('250'), Decimal('630')] latency_seconds = [value / Decimal('1000') for value in latency_ms] threshold_seconds = Decimal('0.300') correct_good = sum(value <= threshold_seconds for value in latency_seconds) rename_only_good = sum(value <= threshold_seconds for value in latency_ms) transferred_bytes = 1024 * 1024 used_ratio = Decimal('25') / Decimal('100') assert correct_good == 2 and rename_only_good == 0 assert sum(latency_seconds) == Decimal('1.000') assert used_ratio == Decimal('0.25') print(f"# within 0.300s: converted={correct_good}/3, rename_only={rename_only_good}/3") print('# HELP demo_last_request_duration_seconds Duration of the most recent completed request.') print('# TYPE demo_last_request_duration_seconds gauge') print(f'demo_last_request_duration_seconds {latency_seconds[-1]:.3f}') print('# HELP demo_transferred_bytes_total Bytes transferred since this process started.') print('# TYPE demo_transferred_bytes_total counter') print(f'demo_transferred_bytes_total {transferred_bytes}') print('# HELP demo_capacity_used_ratio Fraction of configured capacity currently used.') print('# TYPE demo_capacity_used_ratio gauge') print(f'demo_capacity_used_ratio {used_ratio}') 首行注释说明,转换后有三分之二的观测不超过零点三秒,而把毫秒数值直接当成秒时,三条都无法满足阈值。导出的最后请求耗时为零点六三秒,累计字节数为一百零四万八千五百七十六,容量使用比例为零点二五,在百分比界面上对应百分之二十五。三个数分别对应持续时间、累计大小与无量纲比例,不能因为都能画成曲线就混用。 ...

十月 30, 2026 · 1 分钟 · XB Field Notes
一行 HTTP 状态与响应头的终端输出,旁边是一个变暗的状态仪表盘。

可读的证据优先于仪表盘

当论断必须经受审计时,选择可核验的句子而不是彩色面板。

八月 18, 2026 · 1 分钟 · XB Field Notes