Metrics Data Model

状态: 混合

概述

状态: 稳定

OpenTelemetry 的指标数据模型包含一个协议规范和用于传输预聚合指标时序数据的语义约定。该数据模型旨在用于从现有系统导入数据和向现有系统导出数据,同时也支持 OpenTelemetry 内部将 Span 或 Log 流生成指标的用例。

可以将流行的现有指标数据格式无歧义地转换为 OpenTelemetry 指标数据模型,而不会丢失语义或保真度。明确规定了从 Prometheus 和 Statsd 暴露格式的转换。

该数据模型规定了在收集路径上使用的一些语义保留的数据转换,以支持灵活的系统配置。该模型通过累积和增量传输的选择来支持可靠性和无状态控制。该模型通过空间和时间重新聚合来支持成本控制。

OpenTelemetry 收集器设计为接受多种格式的指标数据,使用 OpenTelemetry 数据模型传输数据,然后导出到现有系统。通过对数据进行明确定义的转换,该数据模型可以无歧义地转换为 Prometheus Remote Write 协议,而不会丢失功能或语义,包括自动删除属性和降低直方图分辨率的能力。

事件 => 数据流 => 时序数据

状态: 稳定

OTLP Metrics 协议设计为一种传输指标数据的标准。为了描述此数据的预期用途和相关的语义含义,OpenTelemetry 指标数据流类型将链接到一个框架中,该框架包含一个更高级的模型,关于 Metrics API 和离散输入值,以及一个更低级模型,定义了时序数据和离散输出值。模型之间的关系如下图所示。

Events → Data Stream → Timeseries Diagram

此协议旨在满足 OpenCensus Metrics 系统的要求,特别是满足其 Metrics Views 的概念。在 OpenTelemetry Metrics 数据模型中,通过支持在收集路径上进行数据转换来实现 Views。

OpenTelemetry 确定了三种有用的语义保留的指标数据转换,用于构建指标收集系统,以控制成本、可靠性和资源分配。OpenTelemetry Metrics 数据模型旨在支持这些转换,无论是在 SDK 内部数据生成时,还是在 OpenTelemetry 收集器内部作为重新处理阶段。这些转换是

  1. 时间重新聚合:以高频率收集的指标可以重新聚合为更长的时间间隔,允许预先计算低分辨率时序数据或使用它们代替原始指标数据。
  2. 空间重新聚合:具有不需要属性的指标可以重新聚合为具有较少属性的指标。
  3. 增量到累积:以增量暂时性输入和输出的指标使客户端无需维护高基数状态。使用增量允许下游服务承担转换为累积时序数据的成本,或者放弃成本直接计算速率。

OpenTelemetry 指标数据流设计为可以自动将这些转换应用于同一类型的流,但需遵循下面概述的条件。每个 OTLP 数据流都有一个固有的 可分解聚合函数,使其在时间和空间属性上合并数据点的语义定义良好。每个 OTLP 数据点还具有两个有意义的时间戳,结合固有的聚合,使得模型的基本点可以进行标准的指标数据转换,同时确保结果具有预期的含义。

与 OpenCensus Metrics 一样,可以通过选择聚合间隔和所需的属性将指标数据转换为一个或多个 View。一个 OTLP 数据流可以通过配置不同的 View 转换为多个时序数据输出,并且所需的 View 处理可以在 SDK 内部或由外部收集器应用。

示例用例

指标数据模型围绕一系列“核心”用例设计。虽然此列表不详尽,但它旨在代表 OTel 指标使用范围的广度和深度。

  1. OTel SDK 将 10 秒分辨率导出到单个 OTel 收集器,使用累积暂时性,适用于有状态客户端、无状态服务器
    • 收集器将原始数据透传到 OTLP 目的地
    • 收集器重新聚合为更长的时间间隔,而不更改属性
    • 收集器重新聚合为几个不同的 View,每个 View 具有可用属性的子集,输出到同一目的地
  2. OTel SDK 将 10 秒分辨率导出到单个 OTel 收集器,使用增量暂时性,适用于无状态客户端、有状态服务器
    • 收集器重新聚合为 60 秒分辨率
    • 收集器将增量转换为累积暂时性
  3. OTel SDK 将 10 秒分辨率(例如,CPU、请求延迟)和 15 分钟分辨率(例如,房间温度)导出到单个 OTel Collector。收集器向上游导出流,无论是否聚合。
  4. 多个本地运行的 OTel SDK 各自导出 10 秒分辨率,并报告给单个(本地)OTel 收集器。
    • 收集器重新聚合为 60 秒分辨率
    • 收集器重新聚合以消除单个 SDK 的身份(例如,不同的 `service.instance.id` 值)
    • 收集器输出到 OTLP 目的地
  5. OTel 收集器池接收 OTLP 并导出 Prometheus Remote Write
    • 收集器将服务发现与指标资源关联
    • 收集器计算“up”(存活)和陈旧标记
    • 收集器应用一个独立的外部标签
  6. OTel 收集器接收 Statsd 并导出 OTLP
    • 使用增量暂时性:无状态收集器
    • 使用累积暂时性:有状态收集器
  7. OTel SDK 直接导出到第三方后端

这些被认为是用于分析指标数据模型内部权衡和设计决策的“核心”用例。

不在范围内的用例

指标数据模型**不是**旨在成为一个完美的指标罗塞塔石碑。以下是一些用例,虽然不会完全不支持,但不在关键设计决策的范围内。

  • 使用 OTLP 作为两个不兼容格式之间的中间格式
    • 导入 statsd => Prometheus PRW
    • 导入 collectd => Prometheus PRW
    • 导入 Prometheus 端点抓取 => [statsd push | collectd | opencensus]
    • 导入 OpenCensus “oca” => 任何非 OC 或 OTel 格式
  • 待定:定义其他。

模型详情

状态: 稳定

OpenTelemetry 将指标分解为三个相互作用的模型:

  • 事件模型,表示仪器如何报告指标数据。
  • 时序数据模型,表示后端如何存储指标数据。
  • 指标流模型,定义了表示指标数据流如何被操作和传输(在事件模型和时序数据存储之间)的 *O*pen*T*e*L*emetry *P*rotocol (OTLP)。这是本文档指定的模型。

事件模型

事件模型是数据记录发生的地方。它的基础是 Instruments,用于通过事件记录数据观测。这些原始事件随后以某种方式进行转换,然后再发送到其他系统。OpenTelemetry 指标设计成可以使用相同的 instrument 和事件以不同的方式生成指标流。

Events → Streams

即使观测事件可以直接报告给后端,但实际上由于可观测性系统中使用的数据量巨大,以及遥测收集目的可用的网络/CPU 资源有限,这样做是不可行的。最典型的例子是 Histogram 指标,其中原始事件以压缩格式记录,而不是单独的时序数据。

注意

上图显示了一个 instrument 如何将事件转换为多种类型的指标流。在何时以及如何执行此操作方面存在一些注意事项和细微差别。Instrument 和指标配置在 Metrics API 规范 中进行了概述。

虽然 OpenTelemetry 在 instrument 如何转换为指标流方面提供了灵活性,但 instrument 的定义使得可以提供合理的默认映射。精确的 OpenTelemetry instruments 在 API 规范中有详细说明。

在事件模型中,主要数据是(instrument,number)点,最初在实时或按需(分别对应同步和异步情况)观测。

时序数据模型

在这个低级指标数据模型中,时序数据由一个包含多个元数据属性的实体定义:

  • 指标名称
  • 属性(维度)
  • 点的数值类型(整数、浮点数等)
  • 测量单位

每个时序数据的基本数据是有序的(时间戳,值)点,其中一种值类型如下:

  1. 计数器(单调,累积)
  2. Gauge
  3. Histogram
  4. 指数直方图

此模型可以被视为 Prometheus Remote Write 的理想化。与该协议一样,我们还关心知道何时定义了一个点值,与隐式或显式缺失的点值相比。增量数据点的指标流定义的是时间间隔值,而不是时间点值。要精确定义数据的存在和缺失,需要进一步开发这些模型之间的对应关系。

注意:Prometheus 并非 OpenTelemetry 可以映射的唯一可能的时序数据模型,但它在本文档中用作参考。

OpenTelemetry 协议数据模型

OpenTelemetry 协议 (OTLP) 数据模型由指标数据流组成。这些流又由指标数据点组成。指标数据流可以转换为时序数据。

指标流被分组到单个 `Metric` 对象中,通过以下方式标识:

  • 源 `Resource` 属性
  • 仪器 `Scope`(例如,仪器库名称、版本)
  • 指标流的 `name`

包括 `name` 在内,`Metric` 对象由以下属性定义:

  • 数据点类型(例如,`Sum`、`Gauge`、`Histogram` `ExponentialHistogram`、`Summary`)
  • 指标流的 `unit`
  • 指标流的 `description`
  • 固有的数据点属性,如适用:`AggregationTemporality`、`Monotonic`

数据点类型、`unit` 和固有属性被认为是标识属性,而 `description` 字段明确不是标识性质的。

特定点的外部属性不被视为标识属性;这些属性包括但不限于:

  • `Histogram` 数据点的分桶边界
  • `ExponentialHistogram` 数据点的尺度或分桶数。

`Metric` 对象包含单个流,由 `Attributes` 集合标识。在单个流中,点由一个或两个时间戳标识,细节因数据点类型而异。

在某些数据点类型(例如,`Sum` 和 `Gauge`)中,数值点值允许有变化;在这种情况下,关联的变化(即浮点数与整数)不被视为标识属性。

OpenTelemetry 协议数据模型:生产者建议

生产者**应该**避免出现具有相同 `name`、`Resource` 和 `Scope` 属性的多个 `Metric` 标识。生产者应将相同 `Metric` 对象的数据聚合起来作为基本功能,因此出现多个 `Metric`(被认为是“语义错误”)通常需要某个地方发生了重复冲突的 instrument 注册。

生产者**可能**能够解决问题,具体取决于它们是 SDK 还是下游处理器。

  1. 如果潜在冲突涉及非标识属性(即 `description`),生产者**应该**选择较长的字符串。
  2. 如果潜在冲突涉及相似但意见不一的单位(例如,“ms”和“s”),实现**可以**转换单位以避免语义错误;否则,实现**应该**告知用户存在语义错误并透传冲突数据。
  3. 如果潜在冲突涉及 `AggregationTemporality` 属性,实现**可以**使用累积到增量或增量到累积转换来转换暂时性;否则,实现**应该**告知用户存在语义错误并透传冲突数据。
  4. 通常,对于涉及标识属性(即 `description` 以外的所有属性)的潜在冲突,生产者**应该**告知用户存在语义错误并透传冲突数据。

当此类语义错误发生在 OpenTelemetry API 的实现内部时,会假定 `Resource` 值是固定的。因此,实现 OpenTelemetry API 的 SDK 拥有关于重复 instrument 注册冲突来源的完整信息,有时能够帮助用户避免语义错误。有关详细信息,请参阅 SDK 规范。

OpenTelemetry 协议数据模型:消费者建议

消费者**可以**拒绝包含语义错误(即,对于给定的 `name`、`Resource` 和 `Scope` 有多个 `Metric` 标识)的 OpenTelemetry 指标数据。

OpenTelemetry 没有指定任何向最终用户传达此类结果的方法,尽管这个主题值得关注。

点类型

指标流可以使用以下基本点类型之一,所有这些都满足上述要求,这意味着它们为相同类型的点定义了一个可分解的聚合函数(也称为“自然合并”函数)。1

基本点类型是:

  1. Sum
  2. Gauge
  3. Histogram
  4. 指数直方图

比较 OTLP 指标数据流和时序数据模型,OTLP 不会与其点类型一对一映射到时序数据点。在 OTLP 中,Sum 点可以表示单调计数或非单调计数。这意味着 OTLP Sum 要么被转换为 Timeseries Counter(当 Sum 是单调的时),要么转换为 Gauge(当 Sum 不是单调的时)。

Stream → Timeseries

具体来说,在 OpenTelemetry 中,Sums 始终具有一个可以通过加法组合的聚合函数。因此,对于非单调 Sums,我们可以通过加法(自然地)聚合。在时序数据模型中,不能假设任何特定的 Gauge 是 Sum,因此默认聚合将不是加法。

除了 OTLP 中使用的核心点类型外,还有为兼容现有指标格式而设计的数据类型。

指标点

状态: 稳定

指标点是指标的基本构建块。根据 点类型,指标点可能具有不同的字段。以下各节描述了每种点类型的字段以及这些点如何构成指标。

求和

Sums in OTLP consist of the following

  • 增量 (delta) 或累积 (cumulative) 的聚合暂时性 (Aggregation Temporality)。
  • 一个标志,指示 Sum 是否为 单调。在此类指标的情况下,这意味着 Sum 名义上是增加的,我们假定如此,而不失一般性。
    • 对于增量单调 Sums,这意味着读取器**应该**期望非负值。
    • 对于累积单调 Sums,这意味着读取器**应该**期望值不小于前一个值。
  • 一组数据点,每个数据点包含:
    • 一组独立的属性名称-值对。
    • Sum 计算的时间窗口(`(start, end]`)。
      • 时间间隔包含结束时间。
      • 时间以纳秒为单位指定,时间是从 00:00:00 UTC on 1 January 1970 开始的 UNIX Epoch 时间。
    • (可选)一组样本(参见 Exemplars)。
    • (可选)数据点标志(参见 Data point flags)。

聚合暂时性用于理解 Sum 的计算上下文。当聚合暂时性为“增量”时,我们期望指标流的时间窗口没有重叠,例如:

Delta Sum

与累积聚合暂时性形成对比,累积聚合暂时性我们期望报告自“开始”以来的总和(通常开始表示进程/应用程序启动)。

Cumulative Sum

在 Delta 和 Cumulative 聚合之间存在各种权衡,用于不同的用例,例如:

  • 检测进程重启
  • 计算速率
  • 推送与拉取方式的指标报告

OTLP 支持这两种模型,并允许 API、SDK 和用户确定最适合其用例的权衡。

Gauge

OTLP 中的 Gauge 表示给定时间的采样值。Gauge 流包括:

  • 一组数据点,每个数据点包含:
    • 一组独立的属性名称-值对。
    • 采样值(例如,当前 CPU 温度)
    • 采样值的时间戳(`time_unix_nano`)
    • (可选)一个时间戳(`start_time_unix_nano`),最能代表测量可能记录的第一个时刻。这通常设置为指标收集系统启动的时间戳。
    • (可选)一组样本(参见 Exemplars)。
    • (可选)数据点标志(参见 Data point flags)。

在 OTLP 中,Gauge 流中的一个点表示给定时间窗口的最后一个采样值。

Gauge

在这个例子中,我们可以看到我们用 Gauge 采样的底层时序数据。虽然事件模型**可以**在一个给定的指标报告间隔内进行多次采样,但只有最后一个值通过 OTLP 在指标流中报告。

Gauge 不提供聚合语义,而是使用“最后一个采样值”来执行时间对齐或调整分辨率等操作。

Gauges 可以通过转换为直方图或其他指标类型来聚合。这些操作不是默认执行的,需要直接的用户配置。

Histogram

Histogram 指标数据点以压缩格式传达了一组记录的测量值。直方图将一组事件捆绑到划分的群体中,并包含总事件数和所有事件的总和。

Delta Histogram

直方图包括:

  • 增量 (delta) 或累积 (cumulative) 的聚合暂时性 (Aggregation Temporality)。
  • 一组数据点,每个数据点包含:
    • 一组独立的属性名称-值对。
    • Histogram 捆绑的时间窗口(`(start, end]`)。
      • 时间间隔包含结束时间。
      • 时间值指定为自 UNIX Epoch(1970 年 1 月 1 日 00:00:00 UTC)以来的纳秒。
    • 直方图中点的总数(`count`)。
    • 直方图中所有值的总和(`sum`)。
    • (可选)直方图中所有值的最小值(`min`)。
    • (可选)直方图中所有值的最大值(`max`)。
    • (可选)一系列分桶,包含:
      • 显式边界值。这些值表示分桶的下限和上限,以及观测值是否会落入该分桶。
      • 落入该分桶的观测值的计数。
    • (可选)一组样本(参见 Exemplars)。
    • (可选)数据点标志(参见 Data point flags)。

与 Sums 一样,Histograms 也定义了聚合暂时性。上图表示增量暂时性,其中累积的事件计数在报告后重置为零,并发生新的聚合。而累积则继续聚合事件,在开始新时间时重置。

聚合暂时性也对 min 和 max 字段有影响。Min 和 max 对于 Delta 暂时性更有用,因为 Cumulative 的 min 和 max 所表示的值会随着记录更多事件而趋于稳定。此外,可以从 Delta 转换为 Cumulative 的 min 和 max,但不能从 Cumulative 转换为 Delta。从 Cumulative 转换为 Delta 时,min 和 max 可以被丢弃,或捕获在备用表示形式中,例如 Gauge。

分桶计数是可选的。没有分桶的直方图仅通过总和和计数来传达总体,并可被解释为具有覆盖 `(-Inf, +Inf)` 的单个分桶的直方图。

Histogram: Bucket inclusivity

分桶的上限是包含的(除了上限为 +Inf 的情况),而分桶的下限是排他的。也就是说,分桶表示大于其下限且小于或等于其上限的值的数量。处理 OpenTelemetry Metrics 数据的导入者和导出者在翻译为使用包含下限和排他上限的直方图格式以及从这些格式翻译回来时,应忽略此规范。更改边界的包含性和排他性是极端情况下的直方图错误示例;用户应选择直方图边界,使极端情况下的误差在可接受的误差范围内。

ExponentialHistogram

状态: 稳定

ExponentialHistogram 数据点是 Histogram 数据点的替代表示,用于以压缩格式传达一组记录的测量值。ExponentialHistogram 使用指数公式压缩分桶边界,使其适合以相对较小的误差传达高动态范围的数据,与同等大小的其他表示形式相比。

关于Histogram的语句,其中提到聚合时序性、属性和时间戳,以及sumcountminmaxexemplars字段,对于ExponentialHistogram也同样适用。这些字段的解释与Histogram相同,只有桶(bucket)结构在这两种类型之间有所不同。

指数增长尺度

ExponentialHistogram的精度由一个称为scale的参数表征,scale值越大,精度越高。ExponentialHistogram的桶边界位于base(也称为“增长因子”)的整数幂处,其中

base = 2**(2**(-scale))

这些公式中的符号**表示指数运算,因此2**x表示“2的x次方”,通常通过类似math.Pow(2.0, x)的表达式计算。下面显示了选定scale值的计算出的base值。

尺度基数表达式
101.000682**(1/1024)
91.001352**(1/512)
81.002712**(1/256)
71.005432**(1/128)
61.010892**(1/64)
51.021902**(1/32)
41.044272**(1/16)
31.090512**(1/8)
21.189212**(1/4)
11.414212**(1/2)
022**1
-142**2
-2162**4
-32562**8
-4655362**16

这种设计的一个重要特性是“完美子集化”。具有给定scale的指数直方图的桶可以精确地映射到具有较小scale的指数直方图的桶中,这使得消费者可以在不引入误差的情况下降低直方图的分辨率(即降采样)。

指数桶

由带符号整数index标识的ExponentialHistogram桶表示的种群值大于base**index且小于或等于base**(index+1)

直方图的正负范围分别表示。负值按其绝对值映射到负范围,使用与正范围相同的尺度。请注意,在负范围内,因此,直方图桶使用包含下限的边界。

ExponentialHistogram数据点的每个范围都使用密集的桶表示,其中一个范围的桶表示为单个offset值(带符号整数)和一个计数数组,其中数组元素i表示索引为offset+i的桶的计数。

对于给定的范围(正或负)

  • 桶索引0计算范围(1, base]内的测量值。
  • 正索引对应于大于base的绝对值。
  • 负索引对应于小于或等于1的绝对值。
  • 在连续的2的幂之间有2**scale个桶。

例如,对于scale=3,在1和2之间有2**3个桶。请注意,scale=3直方图中索引4的桶的下限映射到scale=2直方图中索引2的桶的下限,并映射到scale=1直方图中索引1(即base)的桶的下限——这些是完美子集化的示例。

scale=3桶索引下限方程
012**(0/8)
11.090512**(1/8)
21.189212**(2/8), 2**(1/4)
31.296842**(3/8)
41.414212**(4/8), 2**(2/4), 2**(1/2)
51.542212**(5/8)
61.681792**(6/8)
71.834012**(7/8)

零计数和零阈值

ExponentialHistogram包含一个特殊的zero_count桶和一个可选的zero_threshold字段,其中zero_count包含绝对值小于或等于zero_threshold的值的计数。zero_threshold的精确值是任意的,与scale无关。

zero_threshold未设置或为0时,此桶存储无法使用标准指数公式表示的值以及四舍五入为零的值。

具有不同zero_threshold的直方图仍然可以轻松合并,方法是取所有涉及的直方图的最大zero_threshold,并将具有较小zero_threshold的直方图的下桶合并到通用的更宽零桶中。如果合并的zero_threshold位于已填充桶的中间,则需要将其增加到匹配桶的上边界。

在特殊情况下,可以使用更宽的零桶来限制填充桶的总数。

生产者期望

生产者可以(MAY)使用不精确的映射函数,因为在一般情况下

  1. 精确映射函数的实现要复杂得多。
  2. 对于所有scale,边界无法精确表示为浮点数。

通常,生产者应(SHOULD)使用映射函数,该函数的预期差值与所有输入的正确结果之差最多为1。

ExponentialHistogram的设计使得可以表示在64位“double”浮点格式中过大或过小的数值。某些scale值虽然有意义,但不一定有用。

ExponentialHistogram表示的数据范围决定了可以有效应用的scale。无论scale如何,生产者都应(SHOULD)确保任何编码桶的索引都落在32位有符号整数的范围内。此建议用于限制标准处理管道(如OpenTelemetry收集器)使用的整数宽度。在未来的版本中,网络协议可以扩展到支持64位桶索引。

生产者使用映射函数来计算桶索引。生产者应(presumed to support)支持具有11位指数和52位尾数的IEEE双精度浮点数。下面将值映射到指数的伪代码引用了以下常量。

const (
    // SignificandWidth is the size of an IEEE 754 double-precision
    // floating-point significand.
    SignificandWidth = 52
    // ExponentWidth is the size of an IEEE 754 double-precision
    // floating-point exponent.
    ExponentWidth = 11

    // SignificandMask is the mask for the significand of an IEEE 754
    // double-precision floating-point value: 0xFFFFFFFFFFFFF.
    SignificandMask = 1 << SignificandWidth - 1

    // ExponentBias is the exponent bias specified for encoding
    // the IEEE 754 double-precision floating point exponent: 1023.
    ExponentBias = 1 << (ExponentWidth-1) - 1

    // ExponentMask are set to 1 for the bits of an IEEE 754
    // floating point exponent: 0x7FF0000000000000.
    ExponentMask = ((1 << ExponentWidth) - 1) << SignificandWidth
)

以下映射函数选择已通过参考实现得到验证。

Scale Zero: 提取指数

对于scale为零,值的索引等于其标准化的基数为2的指数,即在基数为2的分数表示1._significand_ * 2**_exponent_中的exponent值。标准的IEEE 754双精度浮点值索引范围为[-1022, +1023],亚正常值索引范围为[-1074, -1023]。这可以写成

// MapToIndexScale0 computes a bucket index at scale 0.
func MapToIndexScale0(value float64) int32 {
    rawBits := math.Float64bits(value)

    // rawExponent is an 11-bit biased representation of the base-2
    // exponent:
    // - value 0 indicates a subnormal representation or a zero value
    // - value 2047 indicates an Inf or NaN value
    // - value [1, 2046] are offset by ExponentBias (1023)
    rawExponent := (int64(rawBits) & ExponentMask) >> SignificandWidth

    // rawFragment represents (significand-1) for normal numbers,
    // where significand is in the range [1, 2).
    rawFragment := rawBits & SignificandMask

    // Check for subnormal values:
    if rawExponent == 0 {
        // Handle subnormal values: rawFragment cannot be zero
        // unless value is zero.  Subnormal values have up to 52 bits
        // set, so for example greatest subnormal power of two, 0x1p-1023, has
        // rawFragment = 0x8000000000000.  Expressed in 64 bits, the value
        // (rawFragment-1) = 0x0007ffffffffffff has 13 leading zeros.
        rawExponent -= int64(bits.LeadingZeros64(rawFragment - 1) - 12)

        // In the example with 0x1p-1023, the preceding expression subtracts
        // (13-12)=1, leaving the rawExponent equal to -1.  The next statement
        // below subtracts `ExponentBias` (1023), leaving `ieeeExponent` equal
        // to -1024, which is the correct upper-inclusive bucket index for
        // the value 0x1p-1023.
    }
    ieeeExponent := int32(rawExponent - ExponentBias)
    // Note that rawFragment and rawExponent cannot both be zero,
    // or else the value is exactly zero, in which case the the ZeroCount
    // bucket is used.
    if rawFragment == 0 {
        // Special case for normal power-of-two values: subtract one.
        return ieeeExponent - 1
    }
    return ieeeExponent
}

实现允许将亚正常值向上舍入到最小的正常值,这可能允许使用内置函数。

// MapToIndexScale0 computes a bucket index at scale 0.
func MapToIndexScale0(value float64) int {
    // Note: Frexp() rounds submnormal values to the smallest normal
    // value and returns an exponent corresponding to fractions in the
    // range [0.5, 1), whereas an exponent for the range [1, 2), so
    // subtract 1 from the exponent immediately.
    frac, exp := math.Frexp(value)
    exp--

    if frac == 0.5 {
        // Special case for powers of two: they fall into the bucket
        // numbered one less.
        exp--
    }
    return exp
}
负Scale: 提取并移位指数

对于负scale,值的索引等于标准化基数为2的指数(如上面MapToIndexScale0()所示),右移-scale位。请注意,由于符号扩展,此移位对负索引执行正确的舍入。这可以写成

// MapToIndexNegativeScale computes a bucket index for scales <= 0.
func MapToIndexNegativeScale(value float64) int {
    return MapToIndexScale0(value) >> -scale
}

反向映射函数是

// LowerBoundaryNegativeScale computes the lower boundary for index
// with scales <= 0.
func LowerBoundaryNegativeScale(index int) {
    return math.Ldexp(1, index << -scale)
}

请注意,反向映射函数预计会生成亚正常值,即使映射函数将它们舍入为正常值,因为包含最小正常值的桶的下边界可能是亚正常的。例如,在scale=-4时,最小正常值0x1p-1022落入下边界为0x1p-1024的桶中。

所有Scale: 使用对数函数

上面scale为零和负scale的映射和反向映射函数之所以被推荐,是因为它们是精确的。在这些scale下,math.Log()可能不准确,并且比直接计算桶索引更昂贵。本节中的方法可以在所有scale下使用(MAY),尽管它们对于正scale肯定很有用。

可以使用内置的自然对数函数,通过应用一个派生如下的缩放因子来计算桶索引。

  1. 指数基数定义为base == 2**(2**(-scale))
  2. 我们需要index,其中base**index < value <= base**(index+1)
  3. 应用以base为底的对数,即log_base(base**index) < log_base(value) <= log_base(base**(index+1))(其中log_X(Y)表示以X为底的Y的对数)
  4. 使用log_X(X**Y) == Y重写
  5. 因此,index < log_base(value) <= index+1
  6. 使用Ceiling()函数简化方程:Ceiling(log_base(value)) == index+1
  7. 从两侧减去1:index == Ceiling(log_base(value)) - 1
  8. 使用log_X(Y) == log_N(Y) / log_N(X)重写,以便使用自然对数
  9. 因此,index == Ceiling(log(value)/log(base)) - 1
  10. 缩放因子1/log(base)可以通过公式(1)、(4)和(8)导出。

缩放因子等于2**scale / log(2),可以写成math.Ldexp(math.Log2E, scale),因为常数math.Log2E定义为1/log(2)。将它们组合起来

// MapToIndex for any scale.
func MapToIndex(value float64) int {
    scaleFactor := math.Ldexp(math.Log2E, scale)
    return math.Ceil(math.Log(value) * scaleFactor) - 1
}

使用math.Log()计算桶索引并不保证在2的幂附近是精确正确的。由于不精确,接近边界的值可能会被映射到错误的桶中。定义精确的映射函数超出了本文档的范围。

然而,当输入是2的精确幂时,可以计算出精确正确的桶索引。由于检查2的精确幂相对简单,实现应(SHOULD)应用此类特殊情况。

// MapToIndex for any scale, exact for powers of two.
func MapToIndex(value float64) int {
    // Special case for power-of-two values.
    if frac, exp := math.Frexp(value); frac == 0.5 {
        return ((exp - 1) << scale) - 1
    }
    scaleFactor := math.Ldexp(math.Log2E, scale)
    // Note: math.Floor(value) equals math.Ceil(value)-1 when value
    // is not a power of two, which is checked above.
    return math.Floor(math.Log(value) * scaleFactor)
}

scale的反向映射函数是

// LowerBoundary computes the bucket boundary for positive scales.
func LowerBoundary(index, scale int) float64 {
    inverseFactor := math.Ldexp(math.Ln2, -scale)
    return math.Exp(index * inverseFactor)
}

实现应(expected)验证其映射函数和逆映射函数在IEEE浮点数的最低和最高值附近是正确的。数学上正确的公式可能产生错误的结果,因为累积的浮点计算误差或中间结果的下溢/上溢。例如,在Golang参考实现中,上述公式计算最大索引桶的+Inf。在这种情况下,适当的做法是将索引减去1<<scale并将结果乘以2

func LowerBoundary(index, scale int) float64 {
    // Use this form in case the equation above computes +Inf
    // as the lower boundary of a valid bucket.
    inverseFactor := math.Ldexp(math.Ln2, -scale)
    return 2.0 * math.Exp((index - (1 << scale)) * inverseFactor)
}

例如,在Golang参考实现中,上述公式不能精确计算最小索引桶的下边界(它是亚正常值)。在这种情况下,适当的做法是将索引增加1<<scale并将结果除以2

请注意,为提高可读性,已从上面的代码片段中省略了浮点到整数类型的转换。

ExponentialHistogram:生产者建议

在64位IEEE浮点数的最低端或最高端,桶的范围可能只能被浮点数格式部分表示。在映射这些桶中的数字时,生产者可以正确返回部分可表示桶的索引。这被认为是一种正常情况。

对于正scale,首选对数方法,因为它需要的代码很少,易于验证,并且几乎与查找表方法一样快和准确。对于零scale和负scale,直接从浮点表示计算索引更有效。

使用内置对数函数可能导致结果与使用任意精度或查找表计算的桶索引不同,但是生产者不需要执行精确计算。因此,ExponentialHistogram的示例(exemplars)可能会映射到计数为零的桶中。我们预计会发现这些值包含在相邻的桶中。

ExponentialHistogram:消费者建议

ExponentialHistogram桶索引预计会映射到可以使用IEEE 754双精度浮点值表示其上下边界的桶中。消费者可以(MAY)将部分可表示桶索引的不可表示边界四舍五入到最近的可表示值。

消费者应(SHOULD)拒绝scale和桶索引溢出或欠溢的ExponentialHistogram数据。拒绝此类数据的消费者应(SHOULD)通过错误日志向用户发出警告,表明收到了超出范围的数据。

ExponentialHistogram:桶包含性

为显式边界Histogram数据制定的关于桶包含性的规范同样适用于ExponentialHistogram数据。

总结(遗留)

Summary指标数据点传达分位数摘要,例如“我的HTTP服务器的99百分位延迟是多少?”。与OpenTelemetry中的其他点类型不同,Summary点并不总是可以以有意义的方式合并。不建议在新应用程序中使用此点类型,它用于与其他格式兼容。

Summary包含以下内容:

  • 一组数据点,每个数据点包含:
    • 一组独立的属性名称-值对。
    • 采样值的时间戳(`time_unix_nano`)
    • (可选)一个时间戳(start_time_unix_nano),表示摘要观测数据收集的开始时间。
    • 数据点总体中观测值的计数。
    • 总体值的总和。
    • 一组分位数(按严格递增顺序)组成:
      • 分布的分位数,在区间[0.0, 1.0]内。例如,值0.9代表90百分位数。
      • 分位数的值。此值必须(MUST)为非负数。

分位数0.0和1.0分别定义为最小值和最大值。

分位数不需要代表在start_time_unix_nanotime_unix_nano之间观测到的值,并且预期是针对最近的时间窗口(通常是过去5-10分钟)计算的。

Exemplars

状态: 稳定

Exemplar是记录的值,它将OpenTelemetry上下文关联到Metric中的Metric事件。一个用例是允许用户将Trace信号与Metrics链接。

Exemplars包含:

  • (可选)与记录关联的Trace(trace_id, span_id)。
  • 观测时间(time_unix_nano)。
  • 记录的值(value)。
  • 一组过滤后的属性(filtered_attributes),它们提供了对观测发生时的Context的额外见解。

对于Histograms,当Exemplar存在时,其值已参与直方图点报告的bucket_countscountsum

对于Sums,当Exemplar存在时,其值已包含在总体总和中。

对于Gauges,当Exemplar存在时,其值在Gauge区间内对于同一来源的某个时间点被观测到。

数据点标志

状态: 稳定

某些标志可用于表示特殊数据点。标志是布尔属性,可以设置为truefalse。目前支持以下标志。

无记录值

如果此标志设置为数据点,则该数据点反映系列中明确丢失的数据。它作为指示符,表明先前存在的时间序列已被删除,并且在收到此类指示符后,不应(SHOULD NOT)在查询中返回此时间序列。它相当于Prometheus陈旧标记

如果设置了此标志,则除属性、时间戳或时间窗口之外的所有其他数据点属性都应(SHOULD)被忽略。

此标志默认为false

单写入器

状态: 稳定

OTLP中的所有指标数据流必须(MUST)有一个逻辑写入器。这意味着,从概念上讲,由协议创建的任何时间序列都必须(MUST)有一个唯一的真实来源。在实际操作中,这意味着以下内容:

  • OTel SDK生成的所有指标数据流在任何给定时间点都应(SHOULD)具有全局唯一标识。Metric标识在上面定义。Metric identity is defined above.
  • 指标流的聚合必须(MUST)在任何给定时间点仅从单个逻辑源写入。注意:这意味着聚合的指标流必须到达一个目的地

在系统中,存在多个写入器发送相同指标流数据的可能性(重复)。例如,如果SDK实现无法为组件找到唯一标识其的资源属性,那么该组件的所有实例都可能将指标报告为来自同一资源。在这种情况下,指标将以不一致的时间间隔报告。对于累积总和之类的指标,这可能导致成对的点重置累积总和,从而导致指标无法使用。

指标流的多个写入器被视为错误状态或系统行为不当。接收者应(SHOULD)假定意图是单写入器,并消除重叠/重复。

注意:身份是大多数指标系统中的一个重要概念。例如,Prometheus直接强调唯一性

小心使用labeldroplabelkeep,以确保在删除标签后指标仍然是唯一标记的。

对于OTLP,单写入器原则提供了一种推理错误场景并采取纠正措施的方法。此外,它确保行为良好的系统可以执行指标流操作,而不会出现不期望的降级或可见性损失。

请注意,违反单写入器原则不是语义错误,通常是由于配置错误造成的。而语义错误有时可以通过配置视图(Views)来纠正,违反单写入器原则的错误可以通过区分使用的Resource或确保给定ResourceAttribute集的流在时间上不重叠来纠正。

时序性

状态: 稳定

时序性是指加法量相对于时间的方式,表明报告的值是否包含先前的测量值。Sum、Histogram和ExponentialHistogram数据点尤其支持聚合时序性的选择。

每个OTLP指标数据点都有两个关联的时间戳。第一个强制时间戳是与观测相关联的时间戳,即测量变为当前或生效的时刻,称为TimeUnixNano。第二个可选时间戳用于指示点序列是否连续,称为StartTimeUnixNano

强烈建议为Sum、Histogram和ExponentialHistogram点使用第二个时间戳,因为它对于正确解释OTLP流的速率至关重要,并且能够感知重启。使用StartTimeUnixNano来指示连续点序列的开始意味着它也可以用于编码流中的隐式间隙。

  • 累积时序性意味着连续的数据点会重复起始时间戳。例如,从开始时间T0开始,累积数据点覆盖时间范围(T0, T1]、(T0, T2]、(T0, T3]等等。
  • 增量时序性意味着连续的数据点会推进起始时间戳。例如,从开始时间T0开始,增量数据点覆盖时间范围(T0, T1]、(T1, T2]、(T2, T3]等等。

单调求和使用累积时序性很常见,例如Prometheus。基于累积单调求和的系统在可靠性添加成本方面自然更简单。当收集间歇性失败时,数据中的间隙会自然地从累积测量中平均。累积数据需要发送者记住所有先前的测量值,这是一种与基数成比例的“前期”内存成本。

为指标总和使用增量时序性也很常见,例如Statsd。OpenTelemetry跟踪(tracing)与此相关,其中Span事件通常会转换为两个指标事件(一个计数为1的事件和一个测量时间的事件)。增量时序性支持采样,并将基数成本移出进程。

重置和间隙

状态: 开发中

当存在StartTimeUnixNano字段时,它允许消费者观察流中的间隙和重叠写入器。正确使用时,消费者可以观察到单写入器原则的暂时性和持续性违规以及重置事件。在一个连续的观测序列中,StartTimeUnixNano始终等于同一序列中其他点的TimeUnixNanoStartTimeUnixNano。对于连续序列的初始点:

  • StartTimeUnixNano小于TimeUnixNano时,一个新的连续观测序列开始,并带有“真实”重置,其开始时间已知。零值是隐式的,不需要记录起始点。
  • StartTimeUnixNano等于TimeUnixNano时,一个新的连续观测序列开始,并带有未知开始时间的重置。记录初始观测值以指示连续观测序列已恢复。这些点具有零持续时间,并表示先前报告的点未知,并且数据可能已丢失。

对于连续序列中的后续点:

  • 对于具有增量聚合时序性的点,每个点的StartTimeUnixNano等于前一个点的TimeUnixNano
  • 否则,每个点的StartTimeUnixNano等于初始观测的StartTimeUnixNano

度量流中存在一个间隙,其中它隐式未定义,只要存在一个时间范围,该范围的时间段内没有点覆盖该范围,并且具有StartTimeUnixNanoTimeUnixNano字段。

累积流:处理未知开始时间

观测的连续流以零持续时间点和非零值恢复,如上所述。对于具有累积聚合时序性的点,每个点对时间序列的速率贡献取决于流中先前点的值。

要正确计算连续序列中第一个点的速率贡献,需要知道它是否是第一个点。未知开始时间的重置点出现时,其TimeUnixNano等于点流的StartTimeUnixNano,在这种情况下,第一个点的速率贡献被视为零。预期在此类间隙之前的连续观测序列已报告相同的累积状态。

是否存在TimeUnixNano等于StartTimeUnixNano的点会影响第一个点如何计算速率贡献。如果第一个未知开始时间的重置点丢失,数据消费者可能会高估第二个点的速率贡献,因为它看起来像一个“真实”重置。

可以采用各种方法来避免高估。例如,系统可以利用流早期状态来解决开始时间歧义。

累积流:插入真实重置点

累积计数器的绝对值通常被认为是重要的,但当累积值仅用于计算速率函数时,可以丢弃初始未知开始时间的重置点,但记住初始观测值以修改后续观测值。在累积序列的后面,输出相对于初始值,因此显示为真实重置,并偏移一个未知常数。

这个过程称为插入真实重置点,是累积系列重新聚合的一种特殊情况。

重叠

状态: 开发中

当一个时间窗口内为指标流定义了多个指标数据点时,就会发生重叠。重叠通常是由于配置错误引起的,并且可能导致对数据的严重误解。建议使用StartTimeUnixNano,以便消费者可以识别并响应重叠点。

我们定义了处理重叠的三项原则:

  • 分辨率(通过丢弃点进行纠正)
  • 可观测性(允许数据流向后端)
  • 插值(通过数据操作进行纠正)

重叠分辨率

当多个进程写入相同的指标数据流时,OTLP数据点可能会出现重叠。这种情况通常是由于配置错误引起的,但也可能由于运行相同的进程(表明操作系统或SDK错误,如缺少进程属性)引起。当存在重叠点时,接收者应(SHOULD)消除点,以避免重叠。重叠点中选择哪个数据未指定。

重叠可观测性

当OpenTelemetry收集器观察到数据流中的重叠点时,应(SHOULD)导出遥测数据,以便用户可以监控错误的配置。

重叠插值

当一个进程开始而另一个进程退出时,可能会出现重叠点。在这种情况下,OpenTelemetry收集器应(SHOULD)通过插值Sum数据点来修改变化点,以在这种情况下将间隙减小到零宽度,而没有任何重叠。

流操作

状态: 开发中

待引入。

Sums: Delta转Cumulative

虽然OpenTelemetry(以及一些指标后端)允许报告Delta和Cumulative总和,但我们目标的时间序列模型不支持delta计数器。因此,需要定义从delta转换为cumulative的方法,以便后端可以使用此机制。

注意

这不是唯一可能的Delta转Cumulative算法。它只是一个符合OTel数据模型的可能实现。

从delta点转换为cumulative点本质上是一个有状态的操作。为了成功翻译,我们需要所有传入的delta点到达一个目的地,该目的地可以保持当前的计数器状态并生成新的累积数据流(参见单写入器原则)。

该算法按以下方式计划:

  • 收到给定计数器的第一个Delta点后,我们进行如下设置:
    • 一个新的计数器,存储累积总和,设置为初始计数器。
    • 一个与第一个点开始时间对齐的开始时间。
    • 一个与第一个点时间对齐的“最后看到”时间。
  • 收到未来的Delta点后,我们执行以下操作:
    • 如果下一个点与预期的下一个时间窗口对齐(参见检测Delta重置)。
      • 将“最后看到”时间更新为与当前点时间对齐。
      • 将当前值添加到累积计数器。
      • 输出一个具有原始开始时间、当前最后看到时间和计数的新的累积点。
    • 如果当前点早于开始时间,则丢弃此点。注意:存在可以处理延迟到达点的算法。
    • 如果下一个点与预期的下一个时间窗口不对齐,则重置计数器,执行与看到第一个点时相同的步骤。

Sums: 检测对齐问题

当给定指标流的下一个delta总和与我们预期的不符时,可能发生了几种情况:

  • 报告指标的进程已重新启动,导致指标的新报告间隔。
  • 违反单写入器原则,多个进程正在报告相同的指标流。
  • 丢失了数据点,或丢失了信息。

在所有这些情况下,我们尽力让任何累积指标知道丢失了一些数据,并重置计数器。

我们通过两种机制检测对齐:

  • 如果传入的delta时间间隔与前一个时间间隔有显著重叠,我们假设违反了单写入器原则,并且可以采取以下选项之一来处理:
    • 简单地报告时间间隔中的不一致,因为错误条件可能是由配置错误引起的。
    • 在接收方消除重叠/重复。
    • 通过区分重叠时间的给定ResourceAttribute集来纠正不一致的时间间隔。
  • 如果传入的delta时间间隔与最后看到的时间有显著的间隙,我们假设是某种重启/重置,并重置累积计数器。

Sums: 丢失时间戳

delta转cumulative算法的一个退化情况是当度量数据点丢失时间戳时。虽然使用OpenTelemetry生成的度量时这种情况不应发生,但当适应其他度量格式时可能会发生,例如StatsD计数

在这种情况下,上述算法将在每个数据点上重置累积总和,因为无法确定对齐或点重叠。作为比较,请参阅statsd sums中使用的简单逻辑,其中所有点都被加在一起,而丢失的点则被忽略。

参考

脚注

[1] OTLP支持不满足这些条件的 Data Point 类型;它们有明确的定义,但不支持标准指标数据转换。