# OTel 戰地筆記：破解 Delta to Cumulative 的兩大夢魘 (ErrOutOfOrder & ErrOlderStart)

# [OTel 深水區] 徹底拆解 deltatocumulative 的兩大夢魘：ErrOlderStart 與 ErrOutOfOrder

接續[上一篇我們對 `deltatocumulative` Processor 的架構剖析](https://ganhua.wang/otel-collector-delta-to-cumulative-processor)，今天我們要進入「深水區」。

在生產環境中，你可能遇過這種情況：Metrics 莫名其妙開始 Drop，去查 `deltatocumulative_datapoints` 指標時，看到 `error` 標籤出現了 `delta.ErrOutOfOrder` 或 `delta.ErrOlderStart`。

這不僅僅是報錯，這是 Processor 在告訴你：**你的數據流違反了物理定律**。本文將從 Stream Identity (身份識別) 的源碼層級出發，徹底拆解這兩個錯誤的根源。

---

## 1. 核心根源：Stream Identity (身份識別)

要讀懂錯誤，首先得搞懂 Processor 怎麼判定「這是同一條 Stream」。許多人誤以為只要 Metric Name 一樣就是同一條線，這是大錯特錯。

在源碼中，**Stream Identity** 是一個 Hash 值，其組成結構非常嚴謹：

```text
Stream Identity = Hash of:
┌─────────────────────────────────────────────────────────────────────┐
│  Metric                                                             │
│  ├── Scope                                                          │
│  │   ├── Resource                                                   │
│  │   │   └── resource.attributes (hash)                             │
│  │   │       (關鍵!: service.name, host.name, k8s.pod.name)          │
│  │   ├── scope.name       (例: "otelcol/prometheus")                 │
│  │   ├── scope.version    (例: "v0.90.0")                           │
│  │   └── scope.attributes (hash)                                    │
│  ├── metric.name          (例: "http_requests_total")               │
│  ├── metric.unit          (例: "ms")                                │
│  ├── metric.type          (例: Sum, Histogram)                      │
│  ├── metric.monotonic     (true/false)                              │
│  └── metric.temporality   (Delta/Cumulative)                        │
└─────────────────────────────────────────────────────────────────────┘
      +
datapoint.attributes (hash)   (例: method="GET", status="200")

```

### 實驗室發現：身份判定的黃金法則

我們在單元測試 (`TestStreamIdentity`) 中驗證了以下關鍵行為，這直接決定了你會不會踩坑：

1. **相同 Attributes = 相同 Stream**：
即使 Timestamp 或 Value 不同，只要上述方框內的屬性完全一樣，它們就是同一條 Stream。這就是為什麼多個 Pod 如果沒有帶 `pod.name` 會導致 Collision（衝突）。
2. **Resource 不同 = 不同 Stream**：
來自 `host-1` 和 `host-2` 的數據，即使 Metric Name 一樣，也是兩條獨立的平行線，互不干擾。
3. **Timestamp 與 Value 不影響 Identity**：

| 欄位 | 是否影響 Identity |
| --- | --- |
| `datapoint.timestamp` | ❌ **不影響** |
| `datapoint.start_timestamp` | ❌ **不影響** |
| `datapoint.value` | ❌ **不影響** |
| `datapoint.attributes` | ✅ **影響** |

這解釋了為什麼同一條 Stream 的多個 Datapoints 會被聚合——因為它們共享同一個 Identity。

---

## 2. 決策樹：Processor 是怎麼思考的？

Processor 在處理數據時是有優先順序的：**先查身世 (Start)，再查時序 (Time)**。

```mermaid
graph TD
    A[數據進站 DataPoint] --> B{檢查 1: 出生證明 <br>StartTimestamp}
    B -- New < Stored --> C[💥 ErrOlderStart <br> 丟棄 Drop]
    B -- New >= Stored --> D{檢查 2: 結束時間 <br> Timestamp}
    D -- New <= Stored --> E[💥 ErrOutOfOrder <br> 丟棄 Drop]
    D -- New > Stored --> F[✅ 更新狀態 Update State]
    
    C -.-> G[懷疑有多個 Process 撞名]
    E -.-> H[單純的網路亂序或重送]

```

理解了這個流程，我們就能針對兩個錯誤進行深度解析。

---

## 3. 第一道防線：ErrOlderStart (身份錯亂)

這是最嚴重、也最常被誤判的錯誤。

* **錯誤代碼**：`ErrOlderStart`
* **直白翻譯**：「你明明比我年輕，為什麼出生日期比我還老？」
* **觸發條件**：`New.StartTimestamp < Stored.StartTimestamp`

### 為什麼會發生？ (The "Highlander" Rule)

這通常不是時間穿越，而是**「身份撞車 (Identity Collision)」**。Processor 預設一個 Stream（Identity）在同一時間只能有一個來源。

試想這個場景：你有兩個 Pod (A 和 B) 都在跑同一個 Service，但你忘記在 Metric Label 裡加上 `pod_name`。

1. **Pod A (較新)**：`StartTimestamp = 1000`
2. **Pod B (較舊)**：`StartTimestamp = 500`

Processor 的視角：

1. 收到 Pod A 數據 → 記住：「這個 Stream 是 1000 出生的」。
2. 收到 Pod B 數據（500 出生）。
3. 檢查：`500 < 1000`？**報錯 `ErrOlderStart` 並丟棄**。

> **診斷結論**：
> 只要看到這個錯誤，**90% 都是 Label 設定問題**。你的數據來源不單純（Multiple Processes），有多個實例在搶同一個身份。

---

## 4. 第二道防線：ErrOutOfOrder (時光倒流)

如果通過了第一關，數據會來到第二關。

* **錯誤代碼**：`ErrOutOfOrder`
* **直白翻譯**：「現在已經 12 點了，你怎麼還在送 11 點的數據？」
* **觸發條件**：`New.Timestamp <= Stored.Timestamp`

### 為什麼會發生？

這通常是網路或傳輸層的問題，而非配置問題：

1. **網路亂序**：數據包迷路了，晚送出的反而早到。
2. **重試風暴 (Retry)**：Prometheus Remote Write 因為發送失敗而重送舊數據。
3. **時鐘不同步**：不同機器上的時間沒對準。

---

## 5. 深度情境分析：那些詭異的組合

這部分是高手進階的關鍵，我們來看兩種特殊組合。

### 情境 A：有 ErrOlderStart 但「沒有」ErrOutOfOrder

**這完全可能發生，而且是診斷「多實例衝突」的鐵證。**

假設有兩個進程 A (新, Start=1000) 和 B (舊, Start=500) 交錯發送：

1. 收到 A (Start=1000, Time=1100) → **OK**
2. 收到 B (Start=500, Time=600) → **Fail** (因為 500 < 1000，第一關直接擋下)

**結論**：B 的數據在第一關就被擋下來了，根本沒機會觸發第二關的 `ErrOutOfOrder`。如果你只看到 `ErrOlderStart`，請直接檢查你的 Label 設定。

### 情境 B：有 ErrOutOfOrder 但「沒有」ErrOlderStart

**這反而是好消息：這代表你的 Stream Identity 是乾淨的。**

這表示沒有多個 Pod 在互搶身份，純粹是單一來源的數據傳輸有點顛簸（網路延遲、Batch Flush 順序或重送）。

**結論**：

* 資料來自同一個 `series`。
* StartTimestamp 完全一致（不可能觸發 `ErrOlderStart`）。
* 問題僅在於傳輸順序。

---

## 6. 實戰除錯 SOP

當你在 Grafana 看到 `deltatocumulative_datapoints` 的 drop rate 飆高時，請依照以下步驟排查：

### Step 1: 區分敵我

用這句 PromQL 撈撈看錯誤分佈：

```sql
sum by (error) (rate(otelcol_deltatocumulative_datapoints{error!=""}[5m]))

```

### Step 2: 對症下藥

| 錯誤類型 | 嫌疑犯 (Root Cause) | 處置行動 (Action Item) |
| --- | --- | --- |
| **ErrOlderStart** | **配置架構問題**<br>多個實例 (Pod/Process) 共享了相同的 Identity。 | **檢查 Resource/Attributes**<br><br>查看 `resource_detection` 或 `k8s_attributes` processor。確保每個 Metric 都帶有能區分實例的標籤 (如 `k8s.pod.name`)。 |
| **ErrOutOfOrder** | **環境傳輸問題**<br><br>網路抖動、Client 重試。 | **通常無需過度介入**<br><br>如果是持續發生，檢查發送端的 batch 設定或 NTP 時間同步。 |
| **Limit** | **資源限制**<br><br>Stream 總數爆了。 | **調整配置**<br><br>調大 `max_streams` 上限（參考上一篇文章）。 |

---

## 7. 附錄：測試驗證結構

為了確保上述理論正確，我們參考了以下的測試案例結構，[測試程式碼](https://gist.github.com/tedmax100/0a4a03fc3269b452f11344787c644491)：

### Part 1: Stream Identity 測試

| 測試案例 | 驗證內容 |
| --- | --- |
| `TestStreamIdentity_SameAttributes_SameStream` | 相同 attrs = 相同 stream |
| `TestStreamIdentity_DifferentAttributes_DifferentStream` | 不同 attrs = 不同 stream |
| `TestStreamIdentity_Collision_MultiplePodsWithoutInstanceLabel` | 展示 identity collision |
| `TestStreamIdentity_Components` | 各組成部分對 identity 的影響 |
| `TestStreamIdentity_NotAffectedBy` | timestamp/value 不影響 identity |

### Part 2: Delta Aggregate 錯誤測試

| 測試案例 | 驗證內容 |
| --- | --- |
| `TestDeltaAggregate_FirstSample` | 第一筆數據直接存入 state |
| `TestDeltaAggregate_NormalSequence` | 正常數據序列不報錯 |
| `TestDeltaAggregate_ErrOutOfOrder` | 驗證 ErrOutOfOrder 觸發條件 |
| `TestDeltaAggregate_ErrOlderStart` | 驗證 ErrOlderStart 觸發條件 |
| `TestDeltaAggregate_ErrorPriority` | ErrOlderStart 優先於 ErrOutOfOrder |
| `TestDeltaAggregate_RealWorldScenario_MultiplePodsCollision` | 模擬多 Pod collision |
| `TestDeltaAggregate_RealWorldScenario_NetworkDelay` | 模擬網路延遲亂序 |

### 關鍵測試日誌輸出範例

**ErrOlderStart 場景:**

```text
Pod B rejected: dropped sample with start_time=...0000005..., 
because series only starts at start_time=...000001... 
consider checking for multiple processes sending the exact same series

```

**ErrOutOfOrder 場景:**

```text
Delayed data rejected: out of order: dropped sample from time=...0000011..., 
because series is already at time=...0000012...

```

---

### 總結

`ErrOlderStart` 和 `ErrOutOfOrder` 雖然都會導致丟包，但意義截然不同。
**ErrOlderStart 是你的設定錯了（撞名），ErrOutOfOrder 是網路環境亂了。**

下次看到數據掉點，別急著重啟 Collector，先看看 `error` label 告訴你什麼故事。

---

為解決 `deltatocumulative` Processor 中 `ErrOlderStart` 錯誤而設計的 `k8s_attributes` 實踐配置。

### 核心策略：唯一性 (Uniqueness)

要根除 `ErrOlderStart`，我們必須確保來自不同 Pod 的數據流擁有不同的 **Stream Identity**。根據上一篇的分析，Resource Attributes 是 Identity 的重要組成部分。

因此，我們的目標是：**強勢注入 `k8s.pod.name` 和 `k8s.pod.uid` 到每一筆數據的 Resource 中。**

---

## 1. Collector 配置範例 (config.yaml)

這份配置適用於以 **DaemonSet** 方式運行的 OTEL Collector Agent。這是最推薦的部署模式，因為 Agent 可以直接透過來源 IP 關聯到 Pod。

```yaml
receivers:
  otlp:
    protocols:
      grpc:
      http:

processors:
  # 1. Kubernetes 屬性增強 (關鍵步驟)
  k8sattributes:
    auth_type: "serviceAccount"
    passthrough: false
    filter:
      node_from_env_var: K8S_NODE_NAME # 只處理本節點的 Pod，提升效能
    
    # 這裡定義要抓取哪些 K8s Metadata 塞進 Resource Attribute
    extract:
      metadata:
        - k8s.pod.name       # <--- 絕對必要：區分不同 Pod
        - k8s.pod.uid        # <--- 強烈建議：區分同名 Pod 重啟前後
        - k8s.deployment.name
        - k8s.namespace.name
        - k8s.node.name
    
    # 定義如何透過網路連線找到對應的 Pod
    pod_association:
      - sources:
          - from: connection # 透過來源 IP 自動關聯

  # 2. 記憶體限制 (標準配置)
  memory_limiter:
    check_interval: 1s
    limit_percentage: 75
    spike_limit_percentage: 15

  # 3. 轉化為 Cumulative (我們的主角)
  deltatocumulative:
    max_streams: 100000 # 記得根據上一篇建議調整
    max_stale: 5m

exporters:
  prometheus:
    endpoint: "0.0.0.0:8889"

service:
  pipelines:
    metrics:
      receivers: [otlp]
      # 注意順序：先加標籤 (k8sattributes)，再做聚合 (deltatocumulative)
      processors: [memory_limiter, k8sattributes, deltatocumulative] 
      exporters: [prometheus]

```

---

## 2. 為什麼這樣配能解決 ErrOlderStart？

讓我們回到源碼層級的 Hash 公式：

**修正前 (Collision):**

```text
Stream ID = Hash(
  Resource: { service.name="payment" },  <-- 所有 Pod 都一樣
  Metric:   { name="http_requests" }
)
==> 結果：Pod A 和 Pod B 產生相同的 Hash，Processor 以為它們是同一個 Stream，導致時間戳衝突。

```

**修正後 (Unique):**

```text
Stream ID (Pod A) = Hash(
  Resource: { service.name="payment", k8s.pod.name="payment-zx9q", k8s.pod.uid="uuid-1" },
  Metric:   { name="http_requests" }
)

Stream ID (Pod B) = Hash(
  Resource: { service.name="payment", k8s.pod.name="payment-ab12", k8s.pod.uid="uuid-2" },
  Metric:   { name="http_requests" }
)
==> 結果：Hash 不同，Processor 視為兩條平行線，ErrOlderStart 消失。

```

### 特別說明：為什麼需要 `k8s.pod.uid`？

雖然 `k8s.pod.name` 解決了不同 Pod 的衝突，但如果同一個 Pod 被殺掉重建（Recreated），名稱可能不變（例如 StatefulSet 或某些 Deployment 策略）。加上 `uid` 可以確保即使是「同名的轉世」也會被視為全新的 Stream，徹底杜絕舊數據干擾。


---

如果你不想賦予 Collector 複雜的 K8s RBAC 權限（讀取 Pods/Nodes），或者你的 Collector 是以 Sidecar 模式運作，**「K8s Downward API + Resource Processor」** 是完全可以取代 `k8s_attributes` 的最佳替代方案。

這個組合拳的邏輯是：**「與其讓 Collector 去問 API Server 我是誰，不如我在啟動時直接把身分證塞進它的口袋。」**

以下是完整的配置範例：

---

### 1. K8s YAML 修改 (注入身份)

首先，我們需要利用 [K8s 的 Downward API](https://kubernetes.io/docs/concepts/workloads/pods/downward-api/)，將 Pod 的 metadata 變成環境變數 (Environment Variables) 注入到 Collector 的容器中。

修改你的 Deployment 或 DaemonSet：

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app-sidecar
spec:
  template:
    spec:
      containers:
        - name: otel-collector
          image: otel/opentelemetry-collector-contrib:latest
          env:
            # --- 關鍵：利用 Downward API 注入變數 ---
            - name: K8S_POD_NAME
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name
            - name: K8S_POD_UID
              valueFrom:
                fieldRef:
                  fieldPath: metadata.uid
            - name: K8S_NAMESPACE
              valueFrom:
                fieldRef:
                  fieldPath: metadata.namespace
            - name: K8S_NODE_NAME
              valueFrom:
                fieldRef:
                  fieldPath: spec.nodeName
            # -------------------------------------
          volumeMounts:
            - mountPath: /etc/otelcol
              name: otel-config

```

---

### 2. OTel Collector Config (讀取身份)

接下來，在 Collector 的配置中，我們不使用 `k8s_attributes`，而是改用 **`resource` processor** (注意：不是 `attributes` processor)。

**為什麼是 `resource` processor？**
因為上一篇提到，Stream Identity 的判定依賴於 **Resource Attributes**。普通的 `attributes` processor 預設是修改 DataPoint 屬性，這對 Stream Identity 的修正無效。我們必須修改 Resource 層級。

```yaml
processors:
  # 使用 Resource Processor 抓取環境變數
  resource:
    attributes:
      - key: k8s.pod.name
        value: "${env:K8S_POD_NAME}"  # 讀取 K8s 注入的變數
        action: upsert
      - key: k8s.pod.uid
        value: "${env:K8S_POD_UID}"   # 解決 ErrOlderStart 的核心
        action: upsert
      - key: k8s.namespace.name
        value: "${env:K8S_NAMESPACE}"
        action: upsert
      - key: k8s.node.name
        value: "${env:K8S_NODE_NAME}"
        action: upsert

  deltatocumulative:
    max_streams: 100000

service:
  pipelines:
    metrics:
      receivers: [otlp]
      # 順序很重要：先 Resource (貼標籤) -> 再 Delta (計算)
      processors: [resource, deltatocumulative] 
      exporters: [prometheus]

```

---

### 3. 這個方案 vs `k8s_attributes` 的比較

這兩種方法都能達到讓 Stream Identity 唯一化的目的，消除 `ErrOlderStart`。

| 特性 | Downward API + Resource Processor | k8s_attributes Processor |
| --- | --- | --- |
| **RBAC 權限** | ✅ **不需要** (由 K8s 自身注入) | ❌ **需要** (List/Watch Pods) |
| **API Server 負載** | ✅ **零負載** (靜態變數) | ⚠️ **有負載** (需建立 Watch 連線) |
| **適用場景** | **Sidecar 模式首選** | **DaemonSet / Gateway 模式首選** |
| **即時性** | 啟動即生效，無延遲 | 可能有啟動延遲 (需等待 List 完成) |
| **資料豐富度** | 受限於 Downward API 支援的欄位 | 可抓取所有 Labels 與 Annotations |

### 點評：為什麼這也能防 `ErrOlderStart`？

`deltatocumulative` 並不在乎標籤是「誰」貼上去的。它只在乎當它計算 Hash 時，Resource 裡有沒有這兩個欄位：

1. **`k8s.pod.name`**: 區分了 Pod A 和 Pod B。
2. **`k8s.pod.uid`**: 區分了 Pod A (昨天死的) 和 Pod A (今天重啟的)。

透過 Downward API 注入這兩個值，Stream ID 就會變成全宇宙唯一，徹底解決身份衝突與 `ErrOlderStart` 問題，而且完全不需要跟 K8s API Server 打交道，效能更好、安全性更高。

