补充指南
注意:本文档**不是**规范,而是为了支持 Metrics API 和 SDK 规范而提供的,它**不**对现有规范添加任何额外要求。
面向仪器库作者的指南
仪器选择
Instruments(仪器)是 Metrics API 的一部分。它们允许 Measurements(测量值)以 同步 或 异步 的方式进行记录。
选择正确的仪器很重要,因为
- 它有助于库实现更好的效率。例如,如果我们想向 Prometheus 报告房间温度,我们应该考虑使用 Asynchronous Gauge(异步仪表),而不是定期轮询传感器,这样我们只在抓取(scraping)时访问传感器。
- 它使库的用户更容易使用。例如,如果我们想报告 HTTP 服务器请求延迟,我们应该考虑使用 Histogram(直方图),这样大多数用户只需启用指标流即可获得良好的体验(例如,默认的桶、最小/最大值),而无需进行额外的配置。
- 它能清晰地表达指标流的语义,从而使消费者更好地理解结果。例如,如果我们想报告进程堆大小,使用 Asynchronous UpDownCounter(异步增减计数器)而不是 Asynchronous Gauge(异步仪表),我们就能明确地表明消费者可以将所有进程的堆大小加起来得到“总堆大小”。
以下是一种选择正确仪器的方法
- 我想要**计数**某物(通过记录增量值)
- 如果值是单调递增的(增量值始终非负)——请使用 Counter(计数器)。
- 如果值**不是**单调递增的(增量值可以是正数、负数或零)——请使用 UpDownCounter(增减计数器)。
- 我想要**记录**或**计时**某事,并且关于这件事的**统计数据**可能是有意义的——请使用 Histogram(直方图)。
- 我想要**测量**某物(通过报告绝对值)
- 如果测量值是 不可加的——请使用 Asynchronous Gauge(异步仪表)。
- 如果测量值是 可加的
- 如果值是单调递增的——请使用 Asynchronous Counter(异步计数器)。
- 如果值**不是**单调递增的——请使用 Asynchronous UpDownCounter(异步增减计数器)。
可加性
在 OpenTelemetry 中,Measurement(测量值)包含一个值和一组 Attributes(属性)。根据测量值的性质,它们可能是可加的、不可加的,或介于两者之间。以下是一些示例:
服务器温度是不可加的。下表中温度的总和为
226.2,但此值没有实际意义。主机名 温度 (F) MachineA 58.8 MachineB 86.1 MachineC 81.3 行星质量是可加的,值
1.18e25(3.30e23 + 6.42e23 + 4.87e24 + 5.97e24) 表示太阳系中类地行星的总质量。行星名称 质量 (kg) 水星 3.30e23 火星 6.42e23 金星 4.87e24 地球 5.97e24 电池单元的电压可以串联相加。但是,如果电池并联,则无法将电压值相加。
在 OpenTelemetry 中,每个 Instrument(仪器)都隐含了它是否是可加的。
| Instrument | 可加 |
|---|---|
| Counter | 可加 |
| UpDownCounter | 可加 |
| Histogram | 混合1 |
| Asynchronous Gauge | 不可加 |
| Asynchronous Counter | 可加 |
| Asynchronous UpDownCounter | 可加 |
1:Histogram(直方图)的桶计数是可加的,如果桶相同,则总和是可加的,但最小值和最大值是不可加的。
数值类型选择
对于接受增量和/或减量作为输入的仪器(例如 Counter 和 UpDownCounter),底层数值类型(例如,有符号整数、无符号整数、双精度浮点数)对动态范围、精度以及数据解释方式有直接影响。通常,整数精度高但动态范围有限,可能会发生溢出/下溢。 IEEE-754 双精度浮点格式具有广泛的数值动态范围,但精度有所牺牲。
整数
举个例子:使用一个 16 位有符号整数来计数数据库中的已提交事务,每 15 秒报告一次累计总数。
- 在 (T0, T1] 期间,报告了
70。 - 在 (T0, T2] 期间,报告了
115。 - 在 (T0, T3] 期间,报告了
116。 - 在 (T0, T4] 期间,报告了
128。 - 在 (T0, T5] 期间,报告了
128。 - 在 (T0, T6] 期间,报告了
173。 - …
- 在 (T0, Tn+1] 期间,报告了
1,872。 - 在 (Tn+2, Tn+3] 期间,报告了
35。 - 在 (Tn+2, Tn+4] 期间,报告了
76。
在上述情况下,后端系统可以知道在 (Tn+1, Tn+2] 期间可能发生了系统重启(因为开始时间从 T0 变为了 Tn+2),因此有机会调整数据为:
- (T0, Tn+3] :
1,907(1,872 + 35)。 - (T0, Tn+4] :
1,948(1,872 + 76)。
假设我们保持数据库运行。
- 在 (T0, Tm+1] 期间,报告了
32,758。 - 在 (T0, Tm+2] 期间,报告了
32,762。 - 在 (T0, Tm+3] 期间,报告了
-32,738。 - 在 (T0, Tm+4] 期间,报告了
-32,712。
在上述情况下,后端系统可以知道在 (Tm+2, Tm+3] 期间发生了整数溢出(因为开始时间与之前相同,但值变为负数),因此有机会调整数据为:
- (T0, Tm+3] :
32,798(32,762 + 36)。 - (T0, Tm+4] :
32,824(32,762 + 62)。
如本例所示,即使存在 16 位整数的限制,我们也可以高保真地计数数据库事务,而无需担心因整数溢出而导致的信息丢失。
重要的是要理解,我们处理计数器重置和整数溢出/下溢是基于我们选择了合适的动态范围和报告频率的假设。试想一下,如果我们使用相同的 16 位有符号整数来计数数据中心中的事务(每秒可能有数千甚至数百万笔事务),如果我们每 15 秒报告一次数据,我们就无法判断 -32,738 是 32,762 + 36 的结果,还是 32,762 + 65,572,甚至是 32,762 + 131,108 的结果。在这种情况下,使用更大的数字(例如 32 位整数)或增加报告频率(例如,如果成本允许,每微秒一次)将有助于解决问题。
浮点数
举个例子:使用 IEEE-754 双精度浮点数来计数α磁谱仪检测到的正电子数量。每次检测到一个正电子时,谱仪都会调用 counter.Add(1),并将结果作为累计总数每 1 秒报告一次。
- 在 (T0, T1] 期间,报告了
131,108。 - 在 (T0, T2] 期间,报告了
375,463。 - 在 (T0, T3] 期间,报告了
832,019。 - 在 (T0, T4] 期间,报告了
1,257,308。 - 在 (T0, T5] 期间,报告了
1,860,103。 - …
- 在 (T0, Tn+1] 期间,报告了
9,007,199,254,325,789。 - 在 (T0, Tn+2] 期间,报告了
9,007,199,254,740,992。 - 在 (T0, Tn+3] 期间,报告了
9,007,199,254,740,992。
在上述情况下,计数器在 Tn+1 和 Tn+2 之间停止增加,因为 IEEE-754 双精度计数器已“饱和”,9,007,199,254,740,992 + 1 的结果将是 9,007,199,254,740,992,因此数字停止增长。
注意:在 ECMAScript 6 中,数字 9,007,199,254,740,991 (2 ^ 53 - 1) 被称为 Number.MAX_SAFE_INTEGER,这是可以精确表示为 IEEE-754 双精度数且其 IEEE-754 表示不能通过舍入任何其他整数来获得的最大整数。
除了“饱和”问题之外,我们还应该了解 IEEE-754 双精度数支持 非正规数。例如,1.0E308 + 1.0E308 的结果将是 +Inf(正无穷)。某些指标后端可能难以处理非正规数。
单调性
在 OpenTelemetry Metrics Data Model(数据模型)和 API 规范中,经常使用“monotonic”(单调)一词。
重要的是要理解,不同的 Instruments(仪器)处理单调性的方式不同。
以一个使用 Counter(计数器)记录接收字节总数的网络驱动程序为例:
- 在时间范围 (T0, T1] 期间
- 没有接收到网络数据包
- 在时间范围 (T1, T2] 期间
- 收到一个
30字节的数据包 -Counter.Add(30) - 收到一个
200字节的数据包 -Counter.Add(200) - 收到一个
50字节的数据包 -Counter.Add(50)
- 收到一个
- 在时间范围 (T2, T3] 期间
- 收到一个
100字节的数据包 -Counter.Add(100)
- 收到一个
您可以看到,(T0, T1] 期间的总增量为 0,(T1, T2] 期间的总增量为 280 (30 + 200 + 50),(T2, T3] 期间的总增量为 100,(T0, T3] 期间的总增量为 380 (0 + 280 + 100)。所有增量都为非负数,换句话说,**总和是单调递增的**。
请注意,说“到 T3 为止总共接收了 380 字节”是不准确的,因为在我们在它开始被观测之前(例如,在最后一次操作系统重启之前)可能已经有网络数据包被驱动程序接收。准确的说法是“在 (T0, T3] 期间总共接收了 380 字节”。简而言之,计数代表一个与时间范围相关的**速率**。
这种单调性很重要,因为它为下游系统提供了额外的线索,使它们能够更好地处理数据。设想一下,如果我们报告的累计数据流中总共接收的字节数:
- 在 Tn 时,我们报告了
3,896,473,820。 - 在 Tn+1 时,我们报告了
4,294,967,293。 - 在 Tn+2 时,我们报告了
1,800,372。
后端系统可以判断在 (Tn+1, Tn+2] 期间发生了整数溢出或系统重启,因此有机会“修复”数据。有关整数溢出的更多信息,请参阅 可加性。
让我们以一个使用 Asynchronous Counter(异步计数器)报告进程总页面错误的进程为例:
页面错误由操作系统管理,进程可以通过一些系统 API 获取页面错误的数量。
- 在 T0 时
- 进程启动
- 进程未要求操作系统报告页面错误
- 在 T1 时
- 操作系统报告进程有
1000个页面错误
- 操作系统报告进程有
- 在 T2 时
- 进程未要求操作系统报告页面错误
- 在 T3 时
- 操作系统报告进程有
1050个页面错误
- 操作系统报告进程有
- 在 T4 时
- 操作系统报告进程有
1200个页面错误
- 操作系统报告进程有
您可以看到,报告的数量是绝对值而不是增量,并且该值是单调递增的。
如果我们想计算“在 (T3, T4] 期间引入了多少个页面错误”,我们需要执行减法 1200 - 1050 = 150。
语义约定
一旦您决定了要使用哪些仪器,您就需要决定仪器的名称和属性。
强烈建议您遵循“OpenTelemetry Semantic Conventions”(OpenTelemetry 语义约定),而不是发明自己的语义。
面向 SDK 作者的指南
聚合时序性
同步示例
OpenTelemetry Metrics Data Model(数据模型)和 SDK 被设计为支持累积(Cumulative)和增量(Delta)Temporality(时序性)。重要的是要理解,时序性会影响 SDK 如何管理内存使用。让我们以以下 HTTP 请求为例:
- 在时间范围 (T0, T1] 期间
- verb =
GET, status =200, duration =50 (ms) - verb =
GET, status =200, duration =100 (ms) - verb =
GET, status =500, duration =1 (ms)
- verb =
- 在时间范围 (T1, T2] 期间
- 没有收到 HTTP 请求
- 在时间范围 (T2, T3] 期间
- verb =
GET, status =500, duration =5 (ms) - verb =
GET, status =500, duration =2 (ms)
- verb =
- 在时间范围 (T3, T4] 期间
- verb =
GET, status =200, duration =100 (ms)
- verb =
- 在时间范围 (T4, T5] 期间
- verb =
GET, status =200, duration =100 (ms) - verb =
GET, status =200, duration =30 (ms) - verb =
GET, status =200, duration =50 (ms)
- verb =
注意:在以下示例中,增量聚合时序性先于累积聚合时序性进行讨论,因为同步 Counter 和 UpDownCounter 的测量值是以指定的增量聚合时序性输入到 API 的。
同步示例:增量聚合时序性
让我们设想我们以 Histogram(直方图)的形式导出指标,为了简化叙述,我们将只有一个直方图桶 (-Inf, +Inf)。
如果我们使用**增量时序性**导出指标:
- (T0, T1]
- attributes: {verb =
GET, status =200}, count:2, min:50 (ms), max:100 (ms) - attributes: {verb =
GET, status =500}, count:1, min:1 (ms), max:1 (ms)
- attributes: {verb =
- (T1, T2]
- 无,因为我们没有收到任何测量值。
- (T2, T3]
- attributes: {verb =
GET, status =500}, count:2, min:2 (ms), max:5 (ms)
- attributes: {verb =
- (T3, T4]
- attributes: {verb =
GET, status =200}, count:1, min:100 (ms), max:100 (ms)
- attributes: {verb =
- (T4, T5]
- attributes: {verb =
GET, status =200}, count:3, min:30 (ms), max:100 (ms)
- attributes: {verb =
您可以看到,SDK **只需要跟踪自上次收集/导出周期以来发生的事情**。例如,当 SDK 开始处理 (T1, T2] 期间的测量值时,它可以完全忽略 (T0, T1] 期间发生的事情。
同步示例:累积聚合时序性
如果我们使用**累积时序性**导出指标:
- (T0, T1]
- attributes: {verb =
GET, status =200}, count:2, min:50 (ms), max:100 (ms) - attributes: {verb =
GET, status =500}, count:1, min:1 (ms), max:1 (ms)
- attributes: {verb =
- (T0, T2]
- attributes: {verb =
GET, status =200}, count:2, min:50 (ms), max:100 (ms) - attributes: {verb =
GET, status =500}, count:1, min:1 (ms), max:1 (ms)
- attributes: {verb =
- (T0, T3]
- attributes: {verb =
GET, status =200}, count:2, min:50 (ms), max:100 (ms) - attributes: {verb =
GET, status =500}, count:3, min:1 (ms), max:5 (ms)
- attributes: {verb =
- (T0, T4]
- attributes: {verb =
GET, status =200}, count:3, min:50 (ms), max:100 (ms) - attributes: {verb =
GET, status =500}, count:3, min:1 (ms), max:5 (ms)
- attributes: {verb =
- (T0, T5]
- attributes: {verb =
GET, status =200}, count:6, min:30 (ms), max:100 (ms) - attributes: {verb =
GET, status =500}, count:3, min:1 (ms), max:5 (ms)
- attributes: {verb =
您可以看到,我们正在执行增量到累积的转换,并且 SDK **必须跟踪自进程启动以来发生的事情**,最坏的情况下,SDK **将不得不记住自进程开始以来发生的所有事情**。
设想一下,如果我们有一个长期运行的服务,并且我们使用 7 个属性来收集指标,每个属性都有 30 个不同的值。我们最终可能不得不记住所有 21,870,000,000 种组合的完整集合!这种**基数爆炸**是指标领域一个众所周知的挑战。
更糟糕的是,如果我们导出组合即使没有近期更新,导出批次也可能变得很大,并且成本很高。例如,我们真的需要/想要在上面的例子中为 (T0, T2] 导出相同的东西吗?
因此,以下是一些我们鼓励 SDK 实现者考虑的建议:
- 您希望控制内存使用,而不是让它无限/无界增长——无论使用何种聚合时序性。
- 您希望通过能够**忘记不再需要的对象**来提高内存效率。
- 如果您没有更新,您可能不想一遍又一遍地导出相同的内容。您可以考虑重置和间隙。例如,如果一个累积指标流在很长一段时间内没有收到任何更新,那么重置开始时间是否可以接受?
异步示例
在上面的例子中,我们有由 Histogram Instrument(直方图仪器)报告的测量值。如果我们从 Asynchronous Counter(异步计数器)收集测量值会怎样?
以下示例显示了每个进程启动以来的页面错误数量:
- 在时间范围 (T0, T1] 期间
- pid =
1001, #PF =50 - pid =
1002, #PF =30
- pid =
- 在时间范围 (T1, T2] 期间
- pid =
1001, #PF =53 - pid =
1002, #PF =38
- pid =
- 在时间范围 (T2, T3] 期间
- pid =
1001, #PF =56 - pid =
1002, #PF =42
- pid =
- 在时间范围 (T3, T4] 期间
- pid =
1001, #PF =60 - pid =
1002, #PF =47
- pid =
- 在时间范围 (T4, T5] 期间
- 进程 1001 终止,进程 1003 启动
- pid =
1002, #PF =53 - pid =
1003, #PF =5
- 在时间范围 (T5, T6] 期间
- 新的进程 1001 启动
- pid =
1001, #PF =10 - pid =
1002, #PF =57 - pid =
1003, #PF =8
注意:在以下示例中,累积聚合时序性先于增量聚合时序性进行讨论,因为异步 Counter 和 UpDownCounter 的测量值是以指定的累积聚合时序性输入到 API 的。
异步示例:累积时序性
如果我们使用**累积时序性**导出指标:
- (T0, T1]
- attributes: {pid =
1001}, sum:50 - attributes: {pid =
1002}, sum:30
- attributes: {pid =
- (T0, T2]
- attributes: {pid =
1001}, sum:53 - attributes: {pid =
1002}, sum:38
- attributes: {pid =
- (T0, T3]
- attributes: {pid =
1001}, sum:56 - attributes: {pid =
1002}, sum:42
- attributes: {pid =
- (T0, T4]
- attributes: {pid =
1001}, sum:60 - attributes: {pid =
1002}, sum:47
- attributes: {pid =
- (T0, T5]
- attributes: {pid =
1002}, sum:53
- attributes: {pid =
- (T4, T5]
- attributes: {pid =
1003}, sum:5
- attributes: {pid =
- (T5, T6]
- attributes: {pid =
1001}, sum:10
- attributes: {pid =
- (T0, T6]
- attributes: {pid =
1002}, sum:57
- attributes: {pid =
- (T4, T6]
- attributes: {pid =
1003}, sum:8
- attributes: {pid =
前四个时间段的行为非常简单——我们只是获取异步仪器报告的数据并发送它们。
数据模型在 T5 和 T6 时规定了几种有效的行为,其中一个流死亡而另一个启动。重置和间隙部分描述了如何使用开始时间戳和过时标记来提高接收者对这些事件的理解。
考虑 SDK 是维护每个流的单独时间戳,还是每个进程只有一个时间戳。在此示例中,一个进程可能会死亡并重新启动,它会从零开始计数页面错误。在这种情况下,T5 和 T6 时的有效行为是:
- 如果进程中的所有流共享一个开始时间,并且 SDK 不需要记住所有过去的流:线程以零总和和进程开始时间重新启动。具有重置检测的接收器能够计算正确的速率(除了相对于收集间隔的频繁重启),但重置的确切时间将未知。
- 如果 SDK 维护每个流的开始时间,则将前一个回调时间作为开始时间,因为该时间早于后续回调期间测量的任何事件的发生。这使得流中的第一次观察对于诊断更有用,因为下游消费者可以执行重叠检测或重复抑制,并且在这种情况下不需要重置检测。
- 独立于上述处理方式,SDK 可以添加一个陈旧标记,通过记录之前已报告但当前未报告的流,来指示流中出现中断的开始。如果使用每个流的开始时间戳,则可以发出陈旧标记来精确地开始流中断,并允许忘记已停止报告的流。
可以忽略使用每个流的开始时间戳和陈旧标记的选项。上述第一种处理方式无需额外的内存或代码即可实现,并且在数据模型方面是正确的。
异步示例:Delta 时间性
如果我们使用**增量时序性**导出指标:
- (T0, T1]
- 属性:{pid =
1001}, delta:50 - 属性:{pid =
1002}, delta:30
- 属性:{pid =
- (T1, T2]
- 属性:{pid =
1001}, delta:3 - 属性:{pid =
1002}, delta:8
- 属性:{pid =
- (T2, T3]
- 属性:{pid =
1001}, delta:3 - 属性:{pid =
1002}, delta:4
- 属性:{pid =
- (T3, T4]
- 属性:{pid =
1001}, delta:4 - 属性:{pid =
1002}, delta:5
- 属性:{pid =
- (T4, T5]
- 属性:{pid =
1002}, delta:6 - 属性:{pid =
1003}, delta:5
- 属性:{pid =
- (T5, T6]
- 属性:{pid =
1001}, delta:10 - 属性:{pid =
1002}, delta:4 - 属性:{pid =
1003}, delta:3
- 属性:{pid =
您可以看到我们正在执行累积到 Delta 的转换,这要求我们记住到目前为止遇到的**每个组合的最后一个值**,因为如果我们不这样做,我们将无法使用当前值 - 最后一个值来计算 Delta 值。正如您所知,这非常昂贵。
更有趣的是,如果我们有最小值/最大值,那么从累积时间性**在数学上不可能**可靠地推导出 Delta 时间性。例如
- 如果在 (T0, T2] 期间的最大值为 10,如果在 (T0, T3] 期间的最大值为 20,则我们知道 (T2, T3] 期间的最大值必须是 20。
- 如果在 (T0, T2] 期间的最大值为 20,如果在 (T0, T3] 期间的最大值也为 20,那么我们不知道 (T2, T3] 期间的最大值是多少,除非我们知道没有任何值(计数 = 0)。
因此,以下是一些我们鼓励 SDK 实现者考虑的建议:
- 如果您必须进行累积到 Delta 的转换,并且遇到了最小值/最大值,那么与其丢弃数据,不如将其转换为有用的内容——例如 Gauge。
异步示例:视图中的属性移除
假设上面异步示例中的指标通过一个配置为移除 pid 属性的视图导出,只留下页面错误计数。对于每个指标流,会生成两个覆盖相同时间间隔的测量值,SDK 预计会在生成输出之前对它们进行聚合。
数据模型规定使用“自然合并”函数,在这种情况下意味着将当前点值相加,因为它们是Sum数据点。预期的输出仍然是**累积时间性**
- (T0, T1]
- 维度:{}, sum:
80
- 维度:{}, sum:
- (T0, T2]
- 维度:{}, sum:
91
- 维度:{}, sum:
- (T0, T3]
- 维度:{}, sum:
98
- 维度:{}, sum:
- (T0, T4]
- 维度:{}, sum:
107
- 维度:{}, sum:
- (T0, T5]
- 维度:{}, sum:
58
- 维度:{}, sum:
- (T0, T6]
- 维度:{}, sum:
75
- 维度:{}, sum:
如上面异步累积时间性示例中所讨论的,有各种处理方式可用于检测重置。即使采取了第一种方式,即什么也不做,一个遵循数据模型规则的接收器,关于 未知开始时间 和 插入真实开始时间,在这种情况下也能正确计算速率。“58”在 T5 处接收到,重置了流——从“107”到“58”的变化将显示为一次中断,并且速率计算将在 T6 处正确恢复。重置处理规则的目的是,避免在 T5 处的重置中重复计算 T4 处“107”中反映的“58”的未知部分。
如果采用了使用每个流的开始时间戳的选项,它会减轻接收器的负担,从而能够精确地监控中断并检测重叠的流。当每个流的状态可用时,SDK 在属性停止报告然后稍后重置的情况下,有几种方法可用于计算视图。
- 通过在进程的整个生命周期内记住所有流的累积值
attributes,即使attributes出现和消失,累积总和也将是正确的。在这种情况下,SDK 必须自己检测每个流的重置,否则视图将计算错误。 - 当记住所有流
attributes的成本变得过高时,重置视图及其所有状态,为其提供新的开始时间戳,并让调用者看到流中的中断。
在考虑这个问题时,还要注意,指标 API 对每个异步仪器都有建议:建议用户代码在单个回调中不要提供带有相同attributes的Measurement超过一个。。考虑用户在此方面的错误是否会影响视图的正确性。在维护每个流状态以确保视图正确性时,SDK 作者可能需要考虑检测用户何时进行了重复测量。如果不检查重复测量,视图可能会被错误地计算。
内存管理
内存管理是一个广泛的话题,这里我们只介绍 OpenTelemetry SDK 中一些最重要的事情。
选择更好的设计,使 SDK 需要记住的东西更少,除非有必要,否则避免将事物保留在内存中。一个很好的例子是 聚合时间性。
设计更好的内存布局,使存储更高效,访问存储更快。这通常特定于目标编程语言和平台。例如,将内存与 CPU 缓存行对齐,将热内存放在彼此靠近的位置,将内存靠近硬件(例如,非分页池,NUMA)。
预分配和池化内存,这样 SDK 就无需即时分配内存。这对于具有垃圾回收器的语言运行时特别有用,因为它确保代码中的热路径不会触发垃圾回收。
限制内存使用,并处理临界内存条件。普遍的期望是,遥测 SDK 不应导致应用程序失败。这可以通过一些基数限制算法来完成——例如,当 SDK 达到内存限制时开始合并/丢弃一些数据点,并提供报告数据丢失的机制。
为应用程序所有者提供配置。“什么是有效的内存使用?”这个问题最终取决于应用程序所有者的目标。例如,应用程序所有者可能希望花费更多内存来保留更多指标属性的组合,或者他们可能希望为某些重要的属性积极使用内存,而对不太重要的属性保持保守的限制。