Skip to content

Go 可观测性工具选型:先分清谁采集、谁保存、谁帮你定位问题 ​

给 Go 服务接可观测性,容易从“装哪套工具”开始。更有用的起点是:出问题时,团队要回答哪些问题?如果只需要知道错误率是否上升,却先搭起多套 tracing 后端,花掉的维护时间未必能换来更快的排查。

这篇把知识地图里的九项工具放回各自的位置:Prometheus、Grafana、Loki、Tempo、Jaeger、OpenTelemetry Go SDK、Pyroscope、Sentry,以及 ELK。读完应能画出一条说得清的数据路径,知道每个组件提供什么、要付什么成本,以及哪些能力必须在选定版本上实测。

文中产品能力来自 2026-10-13 核对的官方资料。本次实际运行的是一个 Go 1.27.2、Linux/amd64 的本地容量模型,用它解释标签基数、采样和保留的代价。没有部署这些后端,也没有把计算结果冒充它们的吞吐、压缩率或价格测试。

先按要做的事找工具 ​

你要完成的事主要考虑谁它实际负责什么还需要谁配合
看请求量、错误率、延迟分布,评估告警规则Prometheus指标采集、时间序列存储、PromQL 与规则计算应用指标入口;通知分组和路由通常交给 Alertmanager
把不同后端的数据放到可操作的视图中Grafana数据源查询、展示、关联跳转,也有告警能力真正保存指标、日志、trace 的数据源
按服务和时间查日志,再检索具体内容Loki日志接收、保存与 LogQL 查询日志采集代理;通常用 Grafana 查看
保存和搜索分布式 trace,融入 Grafana 工作流TempoTrace 后端、TraceQL 和按 trace ID 查找应用埋点、采集管道、查询界面
查看请求的跨服务调用关系与耗时JaegerTrace 接收、存储接入、查询 API 和 UI埋点;所选部署方式对应的持久存储
在 Go 代码里生成可导出的遥测数据OpenTelemetry Go SDK在进程内实现采样、聚合、处理和导出API、插桩、exporter;可选 Collector;最后仍要后端
回看某个版本在哪些函数上消耗资源Pyroscope持续 profile 的接收、保存与查询Go profiler 或采集代理、版本和时间标记
把错误整理成可跟踪的问题,关联发布Sentry错误事件及其上下文、问题处理工作流Go SDK、捕获位置、脱敏策略和项目配置
对日志做结构化索引、全文检索与分析ELKElasticsearch 存储检索,Logstash 处理接入,Kibana 探索展示采集端、字段设计、索引生命周期和运维

这里的职责划分依据各项目官方介绍,具体入口在下面逐项展开。工具之间并不都互斥。Grafana 与 Prometheus 常常配合;OpenTelemetry SDK 与 Tempo 处于不同层;Loki 和 ELK 才经常出现在同一项日志存储需求的候选名单里。先把这些关系分开,再比较功能才有意义。

下面是一种可选组合,箭头表示写入数据的方向,不是要求把每一条都装上。为了展示职责,指标采用 Prometheus 抓取路径,trace 采用 OTLP 路径;生产中可以根据现有系统作不同选择。

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    A["Go 服务"] -->|"指标快照经 HTTP 抓取"| P["Prometheus"]
    A -->|"OTel API + Go SDK<br/>OTLP trace"| C["Collector<br/>过滤、批量、转发"]
    C --> T["Tempo 或 Jaeger<br/>Trace 后端"]
    A -->|"结构化日志"| L["日志采集代理"]
    L --> K["Loki 或 ELK<br/>日志存储与查询"]
    A -->|"runtime profile<br/>SDK / 代理"| Y["Pyroscope"]
    A -->|"Sentry SDK 错误事件"| S["Sentry"]
    P -->|"查询结果"| G["Grafana<br/>展示和关联"]
    T -->|"查询结果"| G
    K -->|"受支持数据源的查询结果"| G
    Y -->|"查询结果"| G
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    A["Go 服务"] -->|"指标快照经 HTTP 抓取"| P["Prometheus"]
    A -->|"OTel API + Go SDK<br/>OTLP trace"| C["Collector<br/>过滤、批量、转发"]
    C --> T["Tempo 或 Jaeger<br/>Trace 后端"]
    A -->|"结构化日志"| L["日志采集代理"]
    L --> K["Loki 或 ELK<br/>日志存储与查询"]
    A -->|"runtime profile<br/>SDK / 代理"| Y["Pyroscope"]
    A -->|"Sentry SDK 错误事件"| S["Sentry"]
    P -->|"查询结果"| G["Grafana<br/>展示和关联"]
    T -->|"查询结果"| G
    K -->|"受支持数据源的查询结果"| G
    Y -->|"查询结果"| G

日志存储选择 ELK 时,也可以直接在 Kibana 中工作;选择 Jaeger 时,它也有自己的 UI。图中的 Grafana 不是它们可用的前提。

OpenTelemetry Go SDK:先把应用侧和后端分开 ​

OpenTelemetry 的几个名词需要逐个认清。它们经常出现在同一份安装文档里,却不负责同一件事。组件说明

名词运行在哪里主要职责常见误解
APIGo 进程内让业务或库创建 span、记录指标等引用了 API 就一定已经把数据保存下来
SDKGo 进程内实现 Provider、采样、聚合、处理器和生命周期SDK 是一个能查询历史数据的服务
InstrumentationGo 进程内或对应自动插桩机制在 HTTP、RPC、数据库或业务边界生成数据并传播上下文安装 SDK 后,每个函数自动出现 span
Exporter通常是 SDK 或 Collector 的组件把某种信号编码并发送到目标换 exporter 就能抹平所有后端语义差异
Collector独立进程或服务接收、处理、路由、导出遥测Collector 本身就是长期存储和查询数据库
Backend独立服务保存、索引、查询数据只要网络收到数据就必然永久保存

Go 中常见的动作是:创建 exporter,配置 sdktrace.NewTracerProvider 和处理器,取得 Tracer,在操作边界 Start/End span,设置传播器,并在退出时以有时限的 context 调用 Shutdown。指标则有对应的 MeterProvider 和 reader。可复用库通常依赖 API,由应用决定 SDK 和导出目标,避免每个库各自启动一套全局采集器。Go 插桩说明

Collector 的价值在集中处理:同一处做属性处理、批量发送和目标路由,后端迁移时不必逐个修改业务程序。不过它增加了一个需要监控的服务。接收成功、排队成功和最终保存成功是不同阶段;队列上限、重试策略、拒绝量和丢弃量都要观察。Receiver、processor、exporter 还必须被放进对应的 pipeline,写进配置但没有启用,并不会参与处理。Collector 配置

也不要把“OpenTelemetry 支持某信号”理解为所有语言、组件都同样成熟。本次核对的 Go 文档将 traces、metrics 标为 Stable,logs 标为 Release candidate;具体项目仍应固定模块版本,检查所需 exporter 和 instrumentation 的状态。这里没有安装或验证这些 SDK,容量实验只用标准库。Go 状态页

Prometheus:擅长回答趋势和比例,先把指标维度收住 ​

Prometheus 接收数值时间序列,用指标名和标签集合标识一条序列。Go 应用可以通过官方 client_golang 注册指标,再用 promhttp.HandlerFor 暴露受控的抓取入口;Prometheus 根据目标配置抓取。rate、聚合和 histogram 查询让你回答“最近五分钟错误率有没有上升”这类问题。数据模型、Go 接入

使用它时,先给每个指标一个明确的问题。例如请求总数用于分母,失败数用于分子,延迟 histogram 用于观察分布。把请求 ID 加成标签,却没有一个需要按单个请求聚合的指标问题,往往只是把事件明细强塞进时间序列。每个新的标签组合都会增加序列,而直方图还会为每组标签产生多个桶。

Prometheus 自身能保存本地时间序列并执行规则;Alertmanager 处理告警的分组、抑制和通知路由。它并不是只有抓取器,也不是抓取后必须依赖 Grafana 才能查数据。反过来,监控数据存在采集空窗等限制,不应直接当成要求逐条精确的计费账本。官方概览

保留期和磁盘预算需要显式决定。Prometheus 支持按时间或大小控制本地数据保留,也有远端存储接口;如果目标是长时间、多集群保存,应该把本地能力、远端存储、备份恢复与查询路径一起评估,不能把“支持 remote write”理解为已经自动得到高可用长期存储。存储说明

Grafana:把调查过程连起来,数据源权限仍然要管 ​

Grafana 的常用入口是配置 data source,再在 Explore 或 dashboard panel 中运行该数据源的查询。Prometheus 对应 PromQL,Loki 对应 LogQL,Tempo 对应 TraceQL。一个界面里能看多种数据,并不表示数据被转换成一种存储格式,或所有查询语言已经统一。数据源

它适合把排查动作安排得更顺手:从错误率的时间段进入日志,从日志中的 trace ID 打开请求链路,再看相同服务和版本的 profile。不过关联需要一致的服务名、时间范围和标识字段;光把四个数据源都添加进去,不会凭空产生这些关系。

选型时要分清 Grafana 可视化软件与 Grafana Cloud 托管产品。仅安装 Grafana,并没有替你部署所有数据后端。还要确认数据源凭据的权限、谁能执行什么查询,以及告警究竟由 Grafana 还是后端规则承担,避免同一症状触发两套无人协调的通知。此处的权限和告警归属是部署检查项,不是对所有版本默认设置的断言。

Loki:标签帮你缩小日志范围,正文仍要读 ​

Loki 主要索引日志流的标签,而不是像传统全文检索系统那样给每条日志正文建立同类全文索引。采集代理把日志组织成流并推送;日志内容压缩保存,查询时用 LogQL 筛选和处理。Loki 概览

这让标签设计直接决定查询工作量。如果排查时通常知道服务、环境和时间,先用这些低基数条件缩小范围,再搜索错误文本,路径比较自然。如果常常要在很宽的时间范围内,对未知服务的大量内容作任意检索,就需要用真实查询评估扫描量与延迟,不能只凭“索引较少”推导查询一定更便宜。

service、environment 通常是候选标签;request_id、trace_id 和用户 ID 一般不适合拿来划分大量日志流。需要关联时,可按后端与采集器能力放在日志字段或 structured metadata 中,再在已缩小的范围内查询。Loki 官方专门提醒高基数会损害表现。标签基数

Loki 的保留策略需要正确配置并实际生效;官方说明 Compactor 执行相应保留工作,单纯设置对象存储生命周期不能随意替代整个流程。另外,Loki OSS 本身没有内置完整的身份认证层,官方要求在前面部署认证代理。租户 ID 用来标识租户,不能把客户端自填一个 ID 就当成可信身份。保留、认证

ELK:为可检索的字段付出索引和运维成本 ​

ELK 指 Elasticsearch、Logstash、Kibana。Elasticsearch 保存并检索数据;Logstash 提供接入、转换和输出管道;Kibana 用来探索数据、展示和管理。今天的 Elastic Stack 还包含 Agent 等组件,简单接入并非一定要经过 Logstash,已有复杂解析和转换需求时,它的作用更明确。Elastic Stack

Go 应用通常先输出结构化日志,再由采集与处理管道送入 Elasticsearch。这里值得提前设计的是字段:需要全文匹配的正文与需要精确筛选的标识不是同一种用途。Elasticsearch 的 text 和 keyword 有不同语义,不能把所有字段随意交给动态映射后,就假定检索、排序和聚合都符合预期。text、keyword

比较维度Loki 路线Elasticsearch 日志路线
首先设计什么少量稳定的流标签,以及正文或结构化元数据字段类型、索引策略、需要搜索和聚合的字段
常见调查入口服务、环境、时间,再过滤内容字段条件、全文检索和聚合
主要成本来自哪里流数量、写入量、保留量、查询扫描与计算写入与索引、分片和副本、查询与聚合、保留量
容易走错的地方把每个请求 ID 都变成日志流标签字段无限增长,分片过多,所有内容都无差别索引
如何比较用自己的日志和查询测扫描、延迟、资源用同一份日志和同类查询测相同项目

这张表是基于两种数据模型推导出的评估方法,没有声称其中一套在你的负载下必然更快或更省钱。真正的取舍往往在日常查询方式和团队已有维护能力。

保留和冷热迁移也要按部署方式决定。Elasticsearch 的 ILM 可以管理 rollover、阶段迁移和删除;Serverless 等部署并不一定使用同一机制。选型表里应写具体部署与生命周期方案,而不是只写“ELK 保留 30 天”。生命周期说明

Tempo 与 Jaeger:同是 trace 后端,查法和部署选择要分别看 ​

Tempo:从 trace ID 到 TraceQL,按实际查询评估成本 ​

Tempo 接收 trace,支持按 trace ID 查找,也能用 TraceQL 搜索。它使用对象存储保存历史 trace;Grafana 是常见查询入口。如果团队已经围绕 Grafana 组织指标和日志调查,统一入口与关联路径是有价值的选择条件。Tempo 架构、TraceQL

不要沿用“Tempo 只能按 ID 查,不能搜索”的旧印象,也不要走到另一个极端,把它当成不需要限制的全文检索仓库。按 ID 查一次请求,与跨很多天筛查大量 trace,是不同的查询负载。试用时分别测二者,并观察每秒写入 span 数、平均 span 大小、并发查询和保留期。

版本对部署成本也有实质影响。本次核对的 Tempo v3.1.x 架构文档区分 monolithic 和 microservices 模式:后者使用 Kafka-compatible 队列,前者在进程内转发,不使用这条 Kafka 路径。不能把旧版部署图或某个快速入门命令直接当成当前所有模式的架构。版本对应架构

Jaeger:查询 UI 和存储选择,是完整方案的一部分 ​

Jaeger 提供收集、查询 API 和 UI。这里依据固定的 2.21 文档:v2 的同一二进制可以按角色部署,且构建于 OpenTelemetry Collector 框架之上。已经使用独立 Collector 的系统可以把它放在 Jaeger 前面,但不是所有 Jaeger 部署都必须再多一层 Collector。Jaeger 2.21 架构

Go 新接入应评估 OpenTelemetry 插桩和 OTLP 接收路径;不要把多年以前的 Jaeger 专用客户端教程不加核对地照搬到当前方案。具体接收协议、查询 API 和采样控制接口,应按部署版本确认。Jaeger 2.21 API

Jaeger 的 all-in-one 只是角色组合,不等于“已经有可靠持久化”。用内存存储的开发配置,重启会失去数据;生产是否合适,取决于所选存储、容量和扩展方式。如果团队已有成熟的存储运维与 Jaeger 调查流程,这些投入也应算进迁移成本。

选择问题Tempo 应验证的点Jaeger 应验证的点
日常怎么找慢请求TraceQL、按 ID 查询、Grafana 关联Jaeger UI、服务与操作查询、API 工作流
谁负责持久化所选模式的对象存储与写入路径所选存储后端及 collector/query 角色配置
扩展时增加什么当前版本对应的写入、存储和查询组件当前存储架构,以及是否需要缓冲队列
如何接 Go受支持的 OTLP 接收与应用 OTel 管道同样核对 OTLP,避免旧客户端路径造成额外绑定
怎么作决定相同 trace 样本、查询集和保留要求下试用用同一套验收条件作对照

任何一方都不能恢复从未采集、已采样丢弃或未正确传播上下文的 span。Trace 显示断了一段,先检查埋点、传播、采样和导出健康,再判断是不是后端问题。这是由数据经过多个环节才能到达后端推导出的排查顺序。

Pyroscope:把函数级资源消耗放回时间和版本里 ​

Pyroscope 保存、查询持续 profile,适合追问“这个版本为什么多用了 CPU”“哪条路径在分配对象”。Go 的接入可用其 SDK,由 runtime/pprof 采集;也有经 Alloy 拉取的路径。它观察的是采样资源消耗,与 trace 中单次请求各阶段的耗时互相补充。Pyroscope、Go 接入

选择它前,先确认最需要 CPU、存活堆、分配还是锁竞争 profile,再决定采集精度和覆盖副本。需要保存服务、版本、窗口与采样设置,才能作有意义的前后比较。Go 原生 profile 的标签能力存在类型边界,不能假定每种 profile 都能按每个请求随意切分。Go 固定版本说明

开销是实际采样、编码、上传和存储共同产生的。应在代表性负载下测量,尤其警惕高密度锁采样、强制 GC 和高基数标签。只为偶尔分析一个本地基准,用 go tool pprof 文件流程就可以;需要回看过去版本与时间段时,再评估持续采集后端。这里是按需求给出的建议,没有使用厂商“低开销”表述代替本地测量。

Sentry:减少错误排查的重复劳动,也要控制上传的上下文 ​

Sentry 更适合围绕错误事件和问题处理来组织工作:发生什么错误、哪些发布受影响、团队是否已经在处理。它不等同于原始日志仓库,也不能靠只记录 error 就推算完整请求错误率;后者还需要明确的分母和完整统计口径。Sentry 事件与问题 API

Go 接入的常用动作包括 Init、CaptureException,设置 release/environment 等上下文,并在退出前做有时限的 Flush。返回了一个 Go error,并不意味着它已自动上报;panic 的捕获也取决于集成与恢复边界,不能假定一个中间件捕获了所有 goroutine 的问题。官方 Go SDK v0.50.0

接 SDK 时,应先判断哪些错误值得形成事件,避免每层都重复上报同一个错误。再核对事件中会带什么:错误文本、请求上下文、用户信息、breadcrumb 和自定义字段。脱敏应该尽量发生在发送前,不能只依赖“默认配置应该没问题”。

这里有一个明确的版本差异:v0.50.0 的 ClientOptions 已把 SendDefaultPII 标为 deprecated,并提供更细的 DataCollection;BeforeSend 针对错误事件,其他信号有相应 hook。复制一句 SendDefaultPII: false,不能证明所有自定义字段和所有信号都不会带出敏感数据。固定版本选项

托管和自建的运维责任不同。托管方案要核对数据地域、访问控制、事件额度、超额规则和保留期;自建则要算升级、依赖组件和恢复的维护成本。本文没有核对具体付费套餐,不给价格、免费额度或保留天数承诺。

协议兼容不等于整条管道已经接通 ​

选工具时,“支持 OpenTelemetry”只能作为起点。发送的是什么信号、哪种传输、目标启用了哪个入口,仍要逐一对上。

边界要核对什么容易出现的错配
SDK → OTLP 接收端gRPC 或 HTTP、端点、TLS、认证、支持的信号把 HTTP 地址当 gRPC 使用;端口开了但 receiver 未启用
Prometheus 抓取暴露格式、路径、抓取权限与超时把 /metrics 当成 OTLP 接收地址
Prometheus remote write对方的 remote-write 接口与版本兼容把查询 API、抓取文本与写入协议混为一谈
Collector → 后端exporter、目标支持、批量和重试限制Trace 后端收到日志,或组件存在但没接进 pipeline
Grafana → 数据源查询 API、插件能力、访问权限应用成功上传,Grafana 却没有查询权限
字段与时间服务命名、资源属性、单位、语义约定、时钟同名服务被拆成两组;秒与毫秒混用;关联不到 trace

OTLP 规定的是遥测传输,不是统一查询语言。Prometheus 也有明确的 OTLP 接收配置和属性转换规则,不能简单宣称它“只能 pull”或“装上就收所有 OTLP”。OTLP 规范、Prometheus 与 OTel

迁移试用最好先只复制一小部分合成或获准使用的数据,对比丢弃率、字段保留、查询结果和关闭恢复行为。双写会增加采集、网络和后端成本,也可能重复计算指标;应有结束条件,不要让临时双写长期留下。

用几十行模型先发现预算问题,再去压测产品 ​

以下不是 Prometheus、Tempo 或 Loki 模拟器,只计算明确假设下的数据量。它不发网络请求,也不按计算出的序列数分配内存。所有源文件、配置和测试都在文末。

先假设只有一条 classic histogram,标签维度如下:2 个服务,每个服务按 10 个实例、2 种方法、12 个路由、5 类状态组合计算。假设所有组合都会出现,有 10 个有限桶,再加 +Inf 桶、_sum 和 _count,每组标签是 13 条序列。经典 histogram 组成

模型中的关键算式是:

go
series, err := multiply(combinations, c.FiniteBuckets+3)
samples, err := multiply(series, 86400/c.ScrapeSeconds)

完整实现检查乘法溢出与配置范围,不会丢弃错误。按每 15 秒抓取一次、全天没有中断计算,实际运行结果:

text
series=31200 samples_per_day=179712000

31,200 条序列来自 2×10×2×12×5×13。一整天每条序列抓取 5,760 次,得到约 1.797 亿个样本。这里没有计算其他指标、可选的 _created 序列、native histogram、序列新增消失或抓取抖动。真正服务中标签组合可能稀疏,模型只是帮助发现数量级,不是精确容量规划。

现在把“12 个路由模板”误做成“100 万个带 ID 的原始 URL”。测试只改一个数字:

go
c.LabelDimensions["route"] = 1000000
_, err := estimate(c)

实际测试记录:

text
series budget exceeded: need=2600000000 budget=100000

这一个字段就把估计推到 26 亿条序列。换一个存储产品不会自动修复这种维度设计。更直接的处理是先使用规范化的路由模板,把高基数明细放到合适的日志或 trace 字段中,再评估保留和查询需求。

模型还有一层人为定义的标签名白名单。测试加入 request_id 后得到:

text
label "request_id" is not allowed by this model

这只是这个程序的规则,并非 Prometheus 会自动帮你拒绝请求 ID。它也不会检查 route 的实际内容,更不是敏感信息检测器。白名单、值的规范化和上线前审查仍需要应用自己完成。

采样和保留,改变的是哪些字节 ​

配置再假设整个系统每秒 2,000 个请求,每个请求 8 个 span,保留比例 10%,每个 span 平均 600 字节;日志每请求 2 条,每条平均 350 字节,保留 7 天。

go
trace := c.RequestsPerSec * c.SpansPerRequest * c.TraceKeepRatio * c.BytesPerSpan * 86400 / (1 << 30)
logs := c.RequestsPerSec * c.LogsPerRequest * c.BytesPerLog * 86400 / (1 << 30)

实测输出:

text
trace_raw_gib_per_day=77.25 retained_raw_gib=540.73
log_raw_gib_per_day=112.65 retained_raw_gib=788.57
model_only=true network=none backend_verified=false

这些是按人为输入的平均字节数推算的原始数据量,GiB 按 2 的 30 次方字节计算。它们不是磁盘用量,更不是账单:未计入压缩、索引、副本、元数据、写前日志、临时文件、查询缓存、跨区传输和查询费用。

Trace 的保留比例也不能直接当成 SDK 到 Collector 的流量比例。若在应用侧 head sampling,较早丢弃的 trace 有机会减少后续传输;若先收集完整 trace 再做 tail sampling,采样点之前仍要承受全量数据,而且保留下来的慢请求可能有更多 span 或更大的属性。模型只是把一个固定比例用于原始量计算,没有实现任何采样算法。OTel 采样

最后用一次有范围的试用作决定 ​

仅凭功能表,很难决定两种日志系统或两种 trace 后端谁更合适。可以先挑一条真实排查路径,例如“从错误率告警定位到某次数据库调用”,把可验收的动作写下来:能收到数据、能按时间找到它、字段正确、能关联到请求、重启后仍符合保留要求。

验收维度具体检查为什么它影响选型
数据进入单条合成请求能否找到;字段、单位和版本是否正确避免用空 dashboard 当接通证据
查询能力常用三到五类查询的结果、p95 延迟与并发能力厂商演示未必代表团队日常调查
数据损失后端暂不可用时,队列、拒绝、丢弃如何表现不让遥测故障拖垮业务,也不把丢失误判为没有问题
保留与恢复过期删除是否生效;重启、恢复能保留什么保留期既是成本要求,也是事件调查能力
安全发送字段、认证、租户隔离、查询权限、数据地域能收到数据不等于允许把这些数据送过去
成本写入、存储、查询、网络、许可和维护人力开源软件同样需要资源与值班维护
退出成本能否修改 exporter、导出数据和重建常用查询协议统一不自动统一查询与产品特性

这张表没有替你给九款产品排总名次。它把选择收敛到具体责任:哪个问题现在没人回答,哪条数据路径最短,哪份成本和风险是团队愿意承担的。先补这条路径,再扩展其他信号,通常比一次安装全套更容易验证价值。

验证范围与参考资料 ​

容量模型在 Go 1.27.2、Linux/amd64、CGO_ENABLED=1 下实际执行了 go run、普通测试、race 测试和 go vet。测试覆盖正常估算、基数预算、禁止的标签名、未知 JSON 字段、额外 JSON 值、配置长度上限、不合法比例、NaN/Inf、整数和浮点溢出。脚本另外验证拼错 retention_days 会以非零状态退出,避免把配置错误当默认值继续运行。

模型是纯本地的公式与输入验证,不证明任何后端的接入、耐久性、安全隔离或吞吐;race 通过也不构成产品性能证据。SDK、Collector、各后端和查询示例均未部署。实际选型仍需固定软件版本、运行配置和受控数据,再执行上面的验收。

资料核对日期:2026-10-13。Jaeger 使用固定 2.21 文档,Sentry Go 使用固定 v0.50.0 API,Go 使用固定 1.27.2 文档;其他产品使用当日官方文档。页面名含 latest 的资料会变化,尤其不能把其中的架构和默认值永久当作旧版本也成立。

完整实验代码与配置 ​

把下面五个文件放在同一目录,安装 Go 1.27.2 后执行 bash run.sh。无需第三方 Go 模块、Docker 或后端账户;race 检查要求本机具备支持的 C 工具链。

config.json 中的所有数据是教学假设,不是任何项目的真实生产数据或厂商报价。配置仅用于这个模型,不是 Prometheus、Collector 或其他产品的配置文件。完整反例位于 main_test.go 和 run.sh;最后的未知字段失败是预期行为。

go.mod ​

go
module example.com/observability/toolmodel

go 1.27.2

config.json ​

json
{
  "label_dimensions": {
    "service": 2,
    "instance": 10,
    "method": 2,
    "route": 12,
    "status_class": 5
  },
  "finite_buckets": 10,
  "scrape_seconds": 15,
  "series_budget": 100000,
  "requests_per_second": 2000,
  "spans_per_request": 8,
  "trace_keep_ratio": 0.1,
  "bytes_per_span": 600,
  "logs_per_request": 2,
  "bytes_per_log": 350,
  "retention_days": 7
}

main.go ​

代码块 · 153 行 · 点击收起 / 展开
go
package main

import (
	"bytes"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"
	"math"
	"os"
	"sort"
)

type Config struct {
	LabelDimensions map[string]uint64 `json:"label_dimensions"`
	FiniteBuckets   uint64            `json:"finite_buckets"`
	ScrapeSeconds   uint64            `json:"scrape_seconds"`
	SeriesBudget    uint64            `json:"series_budget"`
	RequestsPerSec  float64           `json:"requests_per_second"`
	SpansPerRequest float64           `json:"spans_per_request"`
	TraceKeepRatio  float64           `json:"trace_keep_ratio"`
	BytesPerSpan    float64           `json:"bytes_per_span"`
	LogsPerRequest  float64           `json:"logs_per_request"`
	BytesPerLog     float64           `json:"bytes_per_log"`
	RetentionDays   float64           `json:"retention_days"`
}

type Estimate struct {
	Series, SamplesPerDay           uint64
	TraceRawGiBDay, TraceRawGiBKept float64
	LogRawGiBDay, LogRawGiBKept     float64
}

func decode(r io.Reader) (Config, error) {
	var c Config
	data, err := io.ReadAll(io.LimitReader(r, (64<<10)+1))
	if err != nil {
		return c, err
	}
	if len(data) > 64<<10 {
		return c, errors.New("config exceeds 64 KiB")
	}
	dec := json.NewDecoder(bytes.NewReader(data))
	dec.DisallowUnknownFields()
	if err := dec.Decode(&c); err != nil {
		return c, err
	}
	var extra any
	if err := dec.Decode(&extra); err != io.EOF {
		return c, errors.New("expected exactly one JSON value")
	}
	return c, nil
}

func multiply(a, b uint64) (uint64, error) {
	if b != 0 && a > math.MaxUint64/b {
		return 0, errors.New("integer overflow")
	}
	return a * b, nil
}

func estimate(c Config) (Estimate, error) {
	var result Estimate
	allowed := map[string]bool{"service": true, "instance": true, "method": true, "route": true, "status_class": true}
	if len(c.LabelDimensions) == 0 {
		return result, errors.New("label dimensions must not be empty")
	}
	keys := make([]string, 0, len(c.LabelDimensions))
	for k := range c.LabelDimensions {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	combinations := uint64(1)
	for _, k := range keys {
		if !allowed[k] {
			return result, fmt.Errorf("label %q is not allowed by this model", k)
		}
		if c.LabelDimensions[k] == 0 {
			return result, fmt.Errorf("label %q has zero values", k)
		}
		var err error
		combinations, err = multiply(combinations, c.LabelDimensions[k])
		if err != nil {
			return result, err
		}
	}
	if c.FiniteBuckets == 0 || c.FiniteBuckets > 100 {
		return result, errors.New("finite_buckets must be between 1 and 100")
	}
	if c.ScrapeSeconds == 0 || c.ScrapeSeconds > 86400 || 86400%c.ScrapeSeconds != 0 {
		return result, errors.New("scrape_seconds must divide 86400 exactly")
	}
	series, err := multiply(combinations, c.FiniteBuckets+3) // +Inf, _sum, _count.
	if err != nil {
		return result, err
	}
	if c.SeriesBudget == 0 || series > c.SeriesBudget {
		return result, fmt.Errorf("series budget exceeded: need=%d budget=%d", series, c.SeriesBudget)
	}
	samples, err := multiply(series, 86400/c.ScrapeSeconds)
	if err != nil {
		return result, err
	}
	values := []float64{c.RequestsPerSec, c.SpansPerRequest, c.BytesPerSpan, c.LogsPerRequest, c.BytesPerLog, c.RetentionDays}
	for _, v := range values {
		if math.IsNaN(v) || math.IsInf(v, 0) || v <= 0 {
			return result, errors.New("rates, sizes and retention must be finite and positive")
		}
	}
	if math.IsNaN(c.TraceKeepRatio) || c.TraceKeepRatio < 0 || c.TraceKeepRatio > 1 {
		return result, errors.New("trace_keep_ratio must be in [0,1]")
	}
	trace := c.RequestsPerSec * c.SpansPerRequest * c.TraceKeepRatio * c.BytesPerSpan * 86400 / (1 << 30)
	logs := c.RequestsPerSec * c.LogsPerRequest * c.BytesPerLog * 86400 / (1 << 30)
	result = Estimate{series, samples, trace, trace * c.RetentionDays, logs, logs * c.RetentionDays}
	for _, v := range []float64{result.TraceRawGiBDay, result.TraceRawGiBKept, result.LogRawGiBDay, result.LogRawGiBKept} {
		if math.IsInf(v, 0) || math.IsNaN(v) {
			return Estimate{}, errors.New("floating-point overflow")
		}
	}
	return result, nil
}

func run(path string) error {
	f, err := os.Open(path)
	if err != nil {
		return err
	}
	c, err := decode(f)
	err = errors.Join(err, f.Close())
	if err != nil {
		return err
	}
	e, err := estimate(c)
	if err != nil {
		return err
	}
	fmt.Printf("series=%d samples_per_day=%d\n", e.Series, e.SamplesPerDay)
	fmt.Printf("trace_raw_gib_per_day=%.2f retained_raw_gib=%.2f\n", e.TraceRawGiBDay, e.TraceRawGiBKept)
	fmt.Printf("log_raw_gib_per_day=%.2f retained_raw_gib=%.2f\n", e.LogRawGiBDay, e.LogRawGiBKept)
	fmt.Println("model_only=true network=none backend_verified=false")
	return nil
}

func main() {
	config := flag.String("config", "config.json", "local synthetic sizing assumptions")
	flag.Parse()
	if err := run(*config); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

main_test.go ​

代码块 · 104 行 · 点击收起 / 展开
go
package main

import (
	"math"
	"os"
	"strings"
	"testing"
)

func baseline(t *testing.T) Config {
	t.Helper()
	b, err := os.ReadFile("config.json")
	if err != nil {
		t.Fatal(err)
	}
	c, err := decode(strings.NewReader(string(b)))
	if err != nil {
		t.Fatal(err)
	}
	return c
}

func TestBaseline(t *testing.T) {
	e, err := estimate(baseline(t))
	if err != nil {
		t.Fatal(err)
	}
	if e.Series != 31200 || e.SamplesPerDay != 179712000 {
		t.Fatalf("unexpected estimate: %+v", e)
	}
	want := 2000.0 * 8 * 0.1 * 600 * 86400 / (1 << 30)
	if math.Abs(e.TraceRawGiBDay-want) > 1e-9 {
		t.Fatal(e)
	}
}

func TestCardinalityBudget(t *testing.T) {
	c := baseline(t)
	c.LabelDimensions["route"] = 1000000
	_, err := estimate(c)
	if err == nil || err.Error() != "series budget exceeded: need=2600000000 budget=100000" {
		t.Fatal(err)
	}
	t.Log(err)
}

func TestSensitiveLabelRejected(t *testing.T) {
	c := baseline(t)
	c.LabelDimensions["request_id"] = 1000000
	_, err := estimate(c)
	if err == nil || !strings.Contains(err.Error(), `label "request_id" is not allowed`) {
		t.Fatal(err)
	}
	t.Log(err)
}

func TestStrictJSON(t *testing.T) {
	for _, text := range []string{`{"retention_day":7}`, `{} {}`, `{"trace_keep_ratio":"10%"}`} {
		if _, err := decode(strings.NewReader(text)); err == nil {
			t.Fatalf("accepted %s", text)
		}
	}
}

func TestOversizedConfig(t *testing.T) {
	text := "{}" + strings.Repeat(" ", 64<<10) + "{}"
	if _, err := decode(strings.NewReader(text)); err == nil {
		t.Fatal("accepted oversized input")
	}
}

func TestInvalidNumbers(t *testing.T) {
	cases := []struct {
		name   string
		change func(*Config)
	}{
		{"zero interval", func(c *Config) { c.ScrapeSeconds = 0 }},
		{"too many buckets", func(c *Config) { c.FiniteBuckets = 101 }},
		{"bad ratio", func(c *Config) { c.TraceKeepRatio = 1.1 }},
		{"nan ratio", func(c *Config) { c.TraceKeepRatio = math.NaN() }},
		{"infinite size", func(c *Config) { c.BytesPerSpan = math.Inf(1) }},
		{"negative retention", func(c *Config) { c.RetentionDays = -1 }},
		{"integer overflow", func(c *Config) { c.LabelDimensions["route"] = math.MaxUint64 }},
		{"floating overflow", func(c *Config) { c.RequestsPerSec = math.MaxFloat64 }},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			c := baseline(t)
			tc.change(&c)
			if _, err := estimate(c); err == nil {
				t.Fatal("expected rejection")
			}
		})
	}
}

func TestZeroTraceRetention(t *testing.T) {
	c := baseline(t)
	c.TraceKeepRatio = 0
	e, err := estimate(c)
	if err != nil || e.TraceRawGiBKept != 0 || e.LogRawGiBKept == 0 {
		t.Fatalf("%+v %v", e, err)
	}
}

run.sh ​

bash
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
go version
go env GOOS GOARCH CGO_ENABLED
go test -count=1 -v ./...
go test -race -count=1 ./...
go vet ./...
go run . -config config.json
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
printf '%s\n' '{"retention_day":7}' > "$tmp/bad.json"
set +e
go run . -config "$tmp/bad.json"
code=$?
set -e
printf 'unknown_field_exit=%s\n' "$code"
test "$code" -ne 0

保留各书来源与许可 · 示例运行条件以正文为准