OTEP-4738: テレメトリーポリシー

OpenTelemetryのための新しい概念であるテレメトリーポリシー(Telemetry Policy)を定義します。

動機

OpenTelemetryは、宣言的設定、リモート制御のためのOpAMP、言語横断的なSDK拡張ポイント、そしてテレメトリー処理のためのOpenTelemetry Collectorを提供しています。 しかし、大規模なテレメトリー動作の制御は依然として困難です。 現在のモデル——処理パイプラインを定義する設定ファイル——は、予測可能な形で破綻します。

設定は有機的に肥大化する

処理ルールは時間とともに蓄積していきます。 50行から始まった設定が、やがて数千行になります。 各行は解決された問題を表していますが、その文脈は失われています。 847行目には存在理由がありますが、その理由は設定自体には記録されていません。

設定には、新しいチームメンバーや外部ツールには読み取れない組織的な知識が組み込まれているため、変更はリスクを伴うものになります。

設定はグローバルな推論を必要とする

一部分を安全に変更するには、全体を理解する必要があります。 データはコンポーネントのDAG(有向非巡回グラフ)を流れていきます——この時点でどのような形をしているのか、その前には何があったのか、これを変更すると何が壊れるのか、といったことです。 認知的な負荷は設定のサイズとともに増大し、やがて変更はリスクを伴うものになり、レビューは表面的なものになります。

OpenTelemetry CollectorとともにOpAMPを使う場合、制御サーバーはその特定のCollectorの設定レイアウトを理解する必要があります。 もしユーザーがサーバーに対して「x.で始まるすべての属性をフィルタリングして除外してほしい」と依頼した場合、サーバーはCollectorの設定を理解しパースしなければなりません。 同じサーバーがOpenTelemetry SDKも管理している場合、属性フィルタリング機能の2つ目の実装——SDK用とCollector用——が必要になります。 各コンポーネントは、それぞれ独自の設定フォーマットとセマンティクスを持っています。

設定はスケールしない

ノイズの多いログパターンを100個ドロップすることは現実的です。 1000個のパターンではパフォーマンスが劣化します。 1万個になると非現実的です。 逐次処理モデルは、これほどの粒度の細かさを想定して設計されていません。 組織は、広いカテゴリごとドロップするという妥協をし、ノイズとともにシグナルまで失うことになります。

リモート制御には保証がない

OpenTelemetryの既存のリモート制御機能は、利用可能であることが保証されていません。 Jaeger Remote Samplerは、OpenTelemetry SDKおよびCollectorのJaeger remote sampler拡張と連携して動作します。 しかし、ファイルベースの設定は動的な再読み込みを必須としていません。 OpAMPもファイルベースの設定も、受信側が変更を動的に適用することを義務付けてはいません——実装は、動的な適応をサポートしなくても両方の仕様に準拠できてしまいます。 動的な振る舞いを明示的に要求するコンポーネントがなければ、リモートでの設定変更が完全な再起動なしに反映される保証はありません。

OpenTelemetry Collectorは、カスタムの設定ファイルフォーマットを許容しています。 制御サーバーは、遭遇しうるすべての設定フォーマットを理解しない限り、任意のCollectorディストリビューションと連携して動作することはできません。

異なるモデル

これらの目標は、OpAMPやファイルベースの設定の方向性を変えることなく達成できます。 その解決策は、「設定」(configuration)と「ポリシー」(policy)を分離することです。

ポリシーは独立したルールです。 各ポリシーはアトミックで自己完結しており、単体で理解できます。 この実行モデルは、劣化することなく数万件のポリシーをサポートします。 ポリシーは、それがSDKで動作するのか、Collectorで動作するのか、あるいはこの仕様を実装する他の任意のコンポーネントで動作するのかにかかわらず、同じように機能します。

説明

ここでは、Telemetry Policy(テレメトリーポリシー)と呼ばれる新しい概念を定義します。 ポリシーとは、OpenTelemetryのユーザーによる意図ベースの仕様です。

  • 型付き(Typed): ポリシーは、そのターゲットシグナルを通じて自身の「タイプ」を自己識別します。 protoスキーマでは、これは oneof target フィールドによって強制されます。 ポリシーは正確に1つのシグナル(例: ログ、メトリック、プロファイル、トレース)をターゲットとします。 異なるタイプのポリシー同士はマージできませんが、同じタイプのポリシーはマージされなければなりません(MUST)。
  • 明確に規定された振る舞い(Clearly specified behavior): ポリシーのタイプは、特定のユースケース——たとえばトレースサンプリング、メトリック集約、属性フィルタリングなど——に対して明確な振る舞いを強制します。
  • 実装非依存(Implementation Agnostic): 私は、CollectorでもSDKでも、OpenTelemetryのエコシステムをサポートする他の任意のコンポーネントでも、まったく同じポリシーを使用できます。
  • スタンドアロン(Standalone): ポリシーを定義するために、パイプラインがどのように設定されているかを理解する必要はありません。
  • 動的(Dynamic): ポリシーは、単一のCollectorやSDKのライフサイクルの外側で定義され、駆動されることを想定しています。 これは、SDKの振る舞いがインスタンス化後に変更できる必要があることを意味します。
  • 冪等(Idempotent): 私は、テレメトリープレーン内の複数のコンポーネントに同じポリシーを安全に渡すことができます。 たとえば、SDKとCollectorの両方が属性フィルターポリシーを取得したとしても、それは一度しか発生しません。

すべてのポリシーは、以下の要素で定義されます。

  • ポリシーのユースケースを示す type
  • ポリシーの有効な定義に何が含まれるかを示すスキーマ。サーバーがそのポリシーを顧客にどのように提示すべきかを記述します。
  • ポリシーが強制する振る舞いを示す仕様
    • 仕様はprotobuf構造を明確にします
    • 実装に期待される振る舞い
    • その振る舞いを検証するための一連の例とテストケース

ポリシーは以下を行ってはなりません(MUST NOT)。

  • 基盤となるポリシー適用者(applier)の実装に関する設定を指定すること。
    • ポリシーは、どの実装がそのポリシーを強制するかを知ることができません。
  • 自身のトランスポート方式を指定すること。
  • 失敗時にテレメトリーに干渉すること。
    • ポリシーはフェイルオープン(fail-open)でなければなりません(MUST)。
  • 論理的なウォーターフォールを含むこと。
    • 各ポリシーの適用は互いに独立しており、現時点では他のポリシーが実行されていることに依存してはなりません(MUST NOT)。 これは冪等性の原則に沿ったものです。

ポリシータイプの例には、以下のようなものがあります。

  • trace-sampling: トレースのサンプリング方法を定義します
  • metric-rate: メトリックのサンプリング周期を定義します
  • log-filter: ログのサンプリング/フィルタリング方法を定義します
  • attribute-redaction: 秘匿化/削除が必要な属性を定義します
  • metric-aggregation: メトリックの集約方法(すなわちビュー)を定義します
  • exemplar-sampling: エグゼンプラーのサンプリング方法を定義します
  • attribute-filter: 属性に基づいて拒否すべきデータを定義します
ポリシーの例

コスト制御 — デバッグログのドロップ

{
  "id": "drop-debug-logs",
  "name": "Drop debug and trace logs",
  "log": {
    "match": [
      {
        "log_field": "severity_text",
        "regex": "^(DEBUG|TRACE)$"
      }
    ],
    "keep": "none"
  }
}

PCIコンプライアンス — クレジットカード番号の秘匿化

{
  "id": "redact-ccs",
  "name": "Redact credit card numbers",
  "log": {
    "match": [
      {
        "log_attribute": ["ccn"],
        "exists": true
      }
    ],
    "transform": {
      "redact": [
        {
          "log_attribute": ["ccn"]
        }
      ]
    }
  }
}

トレースサンプリング — データベーススパンを5%でサンプリング

{
  "id": "sample-database-spans-5-percent",
  "name": "Sample database spans at 5%",
  "description": "Aggressively samples database spans which are typically high volume. Uses equalizing mode to balance sampling across different query types.",
  "trace": {
    "match": [
      {
        "span_attribute": ["db.system"],
        "exists": true
      }
    ],
    "keep": {
      "percentage": 5.0,
      "mode": "equalizing",
      "sampling_precision": 6
    }
  }
}

ポリシーエコシステム

ポリシーは、それ自体にほとんど、あるいはまったくロジックを持たない、単純明快なオブジェクトとして設計されています。 ポリシーはまた、トランスポート、実装、データタイプに対して非依存であるように設計されています。 このエコシステムの目標は、さまざまな方法でポリシーをサポートすることです。 ポリシーは加算的でなければならず(MUST)、既存の標準を壊してはなりません(MUST NOT)。 したがって、私たちの目標は、以下のアーキテクチャを通じて実装を推奨することでエコシステムを拡張することです。

アーキテクチャに関する意思決定は、ユーザーが自分のインフラストラクチャにおいて選択の自由を持てるよう、柔軟であることを意図しています。 たとえば、あるユーザーは、SDK、デーモンCollector、ゲートウェイCollectorが連携して動作し、SDKとデーモンには固定のポリシーが与えられる一方でゲートウェイはリモートで管理される、というマルチステージのポリシーアーキテクチャを採用するかもしれません。 別のユーザーは、SDKのみをリモートで管理することを選ぶかもしれません。 このようなスケーラブルなアーキテクチャの結果として、ポリシープロバイダーの更新は非同期であることが推奨されます。 古くなったポリシー(すなわち、ポリシープロバイダー側では更新されているが、適用者(applier)側にはまだ反映されていないもの)は、システムの機能にとって致命的であるべきではありません。

アーキテクチャ図
---
title: Policy Architecture
---
flowchart TB
    subgraph providers ["Policy Providers"]
        direction TB
        PP["«interface» Policy Provider"]

        File["File Provider"]
        HTTP["HTTP Server Provider"]
        OpAMP["OpAMP Server Provider"]
        Custom["Custom Provider"]

        PP -.->|implements| File
        PP -.->|implements| HTTP
        PP -.->|implements| OpAMP
        PP -.->|implements| Custom
    end

    subgraph aggregator ["Policy Aggregator"]
        PA["Policy Aggregator (Special Provider)"]
    end

    subgraph implementation ["Policy Implementation"]
        PI["Policy Implementation"]
        PT["Supported Policy Types"]
        PI --- PT
    end

    subgraph policies ["Policies"]
        P1["Policy 1"]
        P2["Policy 2"]
        P3["Policy N..."]
    end

    %% Provider relationships
    PP -.->|implements| PA

    %% Aggregator pulls from providers
    File -->|policies| PA
    HTTP -->|policies| PA
    OpAMP -->|policies| PA
    Custom -->|policies| PA

    %% Providers supply policies to implementation
    File -->|supplies policies| PI
    HTTP -->|supplies policies| PI
    OpAMP -->|supplies policies| PI
    Custom -->|supplies policies| PI
    PA -->|supplies policies| PI

    %% Policies relationship
    PP -->|provides| policies
    PI -->|runs| policies

    %% Optional type info
    PP -.->|"may supply supported policy types (optional)"| PI

エコシステム実装の例

以下の考察と推奨事項は、コミュニティがこの仕様とどのように統合できるかを説明するものです。

OpenTelemetry SDK

SDKの宣言的設定は、ポリシープロバイダーのリストをサポートするよう拡張できます。 ポリシープロバイダーが設定されていないSDKは、現在と同じように振る舞います——ポリシーはフェイルオープンです。 もっとも単純なポリシープロバイダーは、ファイルプロバイダーです。 SDKは起動時にこのファイルを読み込み、任意で変更を監視します。

ポリシープロバイダーはSDKにポリシーをプッシュし、SDKをポリシー実装として機能させます。 SDKはいつでも更新を受け取る可能性があるため、拡張ポイントにおいて再読み込みをサポートしなければなりません。 SDKの拡張ポイントの例を以下に示します。

  • PolicySampler: PolicyProviderから関連する trace-sampling ポリシーを取得します
  • PolicyLogProcessor: PolicyProviderから関連する log-filter ポリシーを取得します
  • PolicyPeriodicMetricReader: PolicyProviderから関連する metric-rate ポリシーを取得します

OpenTelemetry Collector

Collectorは、ポリシーを実行する自然な場所です。 ポリシーを実行するためのポリシープロセッサーを導入することができます。 Collectorは、ポリシープロバイダーの設定について、SDKと同じ宣言的設定を使用すべきです(SHOULD)。 Collectorは、外部プロバイダーから受け取るポリシーに加えて、デフォルトポリシーのためのインラインポリシープロバイダーを導入してもよいでしょう。

Collectorは、ポリシー拡張を通じてポリシーアグリゲーターとしても機能できます。 この拡張は複数のポリシープロバイダーから取得を行う一方、他のポリシー実装はCollectorを自らのポリシープロバイダーとして設定します。 このパターンにより、すべての拡張が最終的に同じポリシーを報告する、水平方向にスケール可能なアーキテクチャが実現します。

OpAMP

この仕様は、ポリシープロバイダーのトランスポート層について要件を設けていません。 OpAMPは、カスタムメッセージを通じてポリシープロバイダーとして機能する場合があります。 OpAMPをサポートするポリシー実装は、OpAMP接続を使ってポリシーを転送してもよいでしょう。 この仕様は、カスタムメッセージフォーマットについて推奨事項を設けていません。

まとめ

この仕様は、これらのグループに対して要件を設けていません。 ポータビリティを高めるために、ユーザーにとって一貫した体験に従うことが推奨されます。 他のSIGとの調整により、設定についての合意が確保されるでしょう。 後続の仕様では、HTTP/gRPC定義のようなポリシープロバイダーの具体的な仕様を推奨する可能性があり、それはOpAMPのようなカスタム実装の基盤となるでしょう。 詳細は「将来の可能性」を参照してください。

内部の詳細

型付きスキーマ

以下は、protobuf形式で定義されたポリシーのスキーマのサンプルです。 私たちは、OpenTelemetryのprotobuf規約と過去の仕様に準拠するよう努めています。 注意: これらのproto定義は、本OTEPが承認された後に変更される可能性があります。

message Policy {
  // Unique identifier for this policy
  string id = 1;

  // Human-readable name
  string name = 2;

  // Optional description
  string description = 3;

  // Whether this policy is enabled
  bool enabled = 4;

  // Timestamp when this policy was created (Unix epoch nanoseconds)
  fixed64 created_at_unix_nano = 5;

  // Timestamp when this policy was last modified (Unix epoch nanoseconds)
  fixed64 modified_at_unix_nano = 6;

  // Labels for metadata and routing
  repeated opentelemetry.proto.common.v1.KeyValue labels = 7;

  // Target configuration. Exactly one must be set.
  oneof target {
    LogTarget log = 10;
    MetricTarget metric = 11;
    TraceTarget trace = 12;
    ...
  }
}

すべてのポリシーはIDと名前を持たなければなりません(MUST)。 各ポリシーは、関連するラベルや作成に関するメタデータを指定してもよいです(MAY)。 各ポリシーは、ユーザーがポリシーを作成する際の具体性を高めるために、ターゲット設定を1つだけ指定しなければなりません(MUST)。

ターゲットのProto定義

LogTarget

message LogTarget {
  // At least one matcher is required
  repeated LogMatcher match = 1;

  // Keep behavior: "all" (default), "none", or a sampling percentage
  string keep = 2;

  // Optional transformations applied after keep
  LogTransform transform = 3;
}

message LogTransform {
  repeated LogRemove remove = 1;
  repeated LogRedact redact = 2;
  repeated LogRename rename = 3;
  repeated LogAdd add = 4;
}

message LogRemove {
  oneof field {
    LogField log_field = 1;
    AttributePath log_attribute = 2;
    AttributePath resource_attribute = 3;
    AttributePath scope_attribute = 4;
  }
}

message LogRedact {
  oneof field {
    LogField log_field = 1;
    AttributePath log_attribute = 2;
    AttributePath resource_attribute = 3;
    AttributePath scope_attribute = 4;
  }
  string replacement = 10; // defaults to "[REDACTED]"
}

message LogRename {
  oneof from {
    LogField log_field = 1;
    AttributePath log_attribute = 2;
    AttributePath resource_attribute = 3;
    AttributePath scope_attribute = 4;
  }
  string to = 10;
  bool upsert = 11;
}

message LogAdd {
  oneof field {
    LogField log_field = 1;
    AttributePath log_attribute = 2;
    AttributePath resource_attribute = 3;
    AttributePath scope_attribute = 4;
  }
  string value = 10;
  bool upsert = 11;
}

MetricTarget

message MetricTarget {
  // At least one matcher is required
  repeated MetricMatcher match = 1;

  // Whether to keep matching metrics
  bool keep = 2;
}

message MetricMatcher {
  oneof field {
    MetricField metric_field = 1;
    AttributePath datapoint_attribute = 2;
    AttributePath resource_attribute = 3;
    AttributePath scope_attribute = 4;
    MetricType metric_type = 5;
  }

  oneof match {
    string exact = 10;
    string regex = 11;
    bool exists = 12;
    string starts_with = 13;
    string ends_with = 14;
    string contains = 15;
  }

  bool negate = 20;
  bool case_insensitive = 21;
}

TraceTarget

message TraceTarget {
  // At least one matcher is required
  repeated TraceMatcher match = 1;

  // Probabilistic sampling configuration
  TraceSamplingConfig keep = 2;
}

message TraceMatcher {
  oneof field {
    TraceField trace_field = 1;
    AttributePath span_attribute = 2;
    AttributePath resource_attribute = 3;
    AttributePath scope_attribute = 4;
    SpanKind span_kind = 5;
    SpanStatusCode span_status = 6;
    string event_name = 7;
    AttributePath event_attribute = 8;
    string link_trace_id = 9;
  }

  oneof match {
    string exact = 10;
    string regex = 11;
    bool exists = 12;
    string starts_with = 13;
    string ends_with = 14;
    string contains = 15;
  }

  bool negate = 20;
  bool case_insensitive = 21;
}

message TraceSamplingConfig {
  float percentage = 1;          // 0-100
  string mode = 2;               // "hash_seed", "proportional", or "equalizing"
  int32 sampling_precision = 3;  // hex digits for threshold encoding (1-14)
  int32 hash_seed = 4;           // hash seed for deterministic sampling
  bool fail_closed = 5;          // reject items on sampling errors
}

このスキーマ全体を通じて、無効な設定(たとえば、type: traceと指定しながらメトリック専用の設定を行うなど)を防ぐために oneof を活用しています。

ポリシーマッチャー

ポリシーのパフォーマンスを最適化し、上記のポリシーに関する要件に従うために、各ポリシーのターゲット設定は、AND条件で結合されたマッチャーのリストを設定することから始まります。 以下の LogMatcher 設定は、ログで利用可能な任意のフィールドを通じて、ユーザーが単一のログやログのグループを容易にターゲットできるようにします。 ポリシーは、少なくとも1つのマッチャーを含まなければなりません(MUST)。 正規表現は、実装間の一貫性のためにRE2構文を使用しなければなりません(MUST)。

message LogMatcher {
  // The field to match against. Exactly one must be set.
  oneof field {
    // Simple fields (body, severity_text, trace_id, span_id, etc.)
    LogField log_field = 1;

    // Log record attribute by key or path
    AttributePath log_attribute = 2;

    // Resource attribute by key or path
    AttributePath resource_attribute = 3;

    // Scope attribute by key or path
    AttributePath scope_attribute = 4;
  }

  // Match type. Exactly one must be set.
  oneof match {
    // Exact string match
    string exact = 10;

    // Regular expression match
    string regex = 11;

    ...
  }

  // If true, inverts the match result
  bool negate = 20;

  // If true, applies case-insensitive matching to all match types
  bool case_insensitive = 21;
}

ポリシー設計

ポリシーは、実装が動的に解釈する汎用言語ではありません。 各ポリシーステージは、実装が明示的にサポートしなければならない、具体的でバージョン管理された機能です。 これは、新しいステージには実装の更新が必要であることを意味します——実装は、理解していないステージを実行することはできません。

現在のステージ

この仕様では現在、固定された順序で実行される2つのステージを定義しています。

  1. Keep(保持) — テレメトリーを保持するか、サンプリングするか、ドロップするかを決定します。 マッチしたすべてのポリシーがそれぞれの keep 値を提供し、ランタイムはもっとも制限の厳しい結果を適用します。 テレメトリーがドロップされるか、サンプリングアウトされた場合、処理はそこで停止します。 Keepは、すべてのシグナルタイプ(ログ、メトリック、トレース)でサポートされます。 トレースのKeepは、設定可能なモード(hash_seed、proportional、equalizing)とW3C tracestateの伝播を伴う確率的サンプリングをサポートします。

  2. Transform(変換) — Keepステージを通過したテレメトリーを変更します。 操作は remove → redact → rename → add という固定順序で実行されます。 現時点では、変換はログに対してのみ定義されています。 各操作タイプ内で複数のポリシーが同じフィールドをターゲットにしている場合、その結果は実装定義ですが、決定的でなければなりません(MUST)。

新しいステージの追加

新しいポリシーステージ(たとえば、メトリックのリネーム、メトリック集約、スパンのロールアップなど)は、現在のステージと同じプロセス——仕様での定義、適合性スイートによる検証、各言語ライブラリでの実装——をたどります。 ポリシーは汎用言語ではないため、新しいステージにはそれぞれ以下が必要です。

  • ステージのスキーマ、振る舞い、マージのセマンティクスを定義する仕様の更新
  • 新しいステージの振る舞いを網羅する適合性テスト
  • 各言語ライブラリでの実装の更新

実装は、ステージのサブセットのみをサポートしてもよいですが(MAY)、サポートしていないステージを明確に文書化しなければなりません(MUST)。 サポートされていないステージを持つポリシーに遭遇した実装は、フェイルオープンの振る舞いに従わなければなりません(MUST)——スキップされるのはテレメトリーではなく、ポリシーです。

このアプローチは、汎用言語の柔軟性を予測可能性と引き換えにしています。 すべてのステージは明確に定義されたセマンティクスを持ち、すべての実装が振る舞いについて合意し、適合性スイートが一貫性を保証します。 新しいステージは、仕様、テスト、そして少なくとも1つの実装の準備が整ったときにリリースされます。

ランタイムの要件

評価

実装は、ポリシーを並行して評価してもよいです(MAY)。 ポリシーの独立性により、調整なしの並列マッチングが可能になります。

エラー処理

実装はフェイルオープンでなければなりません(MUST)。

  • ポリシーのパースに失敗した場合、そのポリシーはスキップされなければなりません(MUST)。 他のポリシーは実行を継続しなければなりません(MUST)。
  • ポリシーの評価に失敗した場合(たとえば、実行時に無効な正規表現が検出された場合など)、テレメトリーはそのポリシーによって変更されることなく通過しなければなりません(MUST)。
  • ポリシーの失敗が、テレメトリーの損失を引き起こしてはなりません(MUST NOT)。

実装は、デバッグのためにポリシー評価エラーをログに記録すべきです(SHOULD)。

無効化されたポリシー

enabled: false が設定されたポリシーは評価されてはなりません(MUST NOT)。 実装は、無効化されたポリシーを、存在しないものとして扱わなければなりません(MUST)。

ポリシーのマージ

ポリシーのマージには、2つの異なる関心事があります。 1つは、プロバイダーがポリシーの更新をクライアントにどのように転送するかであり、もう1つは、ランタイムが評価時に重複するポリシーをどのように解決するかです。 以下、それぞれについて順に説明します。

トランスポートレベルの同期

ポリシー自体がトランスポートの仕組みやフォーマットを強制しないため、同期の仕組みもポリシーによって強制されることはありません。 しかし、すべてのトランスポート実装は、以下の原則に従うべきです(SHOULD)。

パッチ適用よりも全件置き換えを優先する。 同期のたびに完全なポリシーセットを送信することで、フィールド順序の曖昧さ、部分更新の競合、配列操作の非互換性といった、従来のマージにおける落とし穴を回避できます。 プロバイダーはアクティブなポリシーの完全なリストを送信すべきであり(SHOULD)、クライアントはローカルのセットをアトミックに置き換えるべきです(SHOULD)。 実装は、ポリシーセットに変更がない場合にクライアントが処理をスキップできるよう、変更検出のためのハッシュやバージョン識別子をサポートすべきです(SHOULD)。

増分更新は要件としてではなく最適化としてサポートする。 トランスポートプロトコルは、帯域幅の最適化として、増分差分(たとえばIDによる個々のポリシーの追加・削除)をサポートしてもよいです(MAY)。 増分更新をサポートする場合、プロトコルは、ドリフトや更新の見落としから回復するために、クライアントが完全な同期を要求できる仕組みも提供しなければなりません(MUST)。

ポリシーのステータスをプロバイダーに報告する。 トランスポートプロトコルは、クライアントがポリシーごとのステータス(マッチ数、エラー)をプロバイダーに報告できる仕組みを提供すべきです(SHOULD)。 このフィードバックループにより、プロバイダーは設定が誤っていたり効果のなかったりするポリシーを検出できます。 ステータスは各プロバイダーの範囲に限定されるべきです(SHOULD)——プロバイダーは、自身が供給したポリシーのステータスのみを受け取ります。 各プロバイダーは、自身のポリシーがシステムを妨げないようにする責任を負います。

重複するポリシーIDはプロバイダーの優先度で解決する。 複数のプロバイダーが同じIDを持つポリシーを供給した場合、クライアントはどちらを残すかを決定する必要があります。 実装は、各プロバイダーに優先度を割り当てるべきです(SHOULD)——たとえば、OpAMP(1)、HTTP(2)、FILE(3)、CUSTOM(ユーザー定義)のように、番号が小さいほど優先度が高くなります。 2つのポリシーが同じIDを共有している場合、優先度の高いプロバイダーからのポリシーが採用され、もう一方はドロップされます。 優先度の低いプロバイダーからのポリシーが、優先度の高いバージョンと一貫性を保った形でマージできない場合、優先度の低いポリシーはまるごとドロップされるべきです(SHOULD)。

具体的な仕組みは、PolicyProvider の実装に依存します。

  • FileProvider は、ディスク(YAML、JSON、またはprotoバイナリ)から完全なポリシーセットを読み込みます。 各読み込みは完全なスナップショットを生成するため、パッチのセマンティクスは不要です。
  • HTTPまたはgRPCプロバイダーは、ハッシュベースの変更検出と、クライアントメタデータ(サポートされているポリシーステージ、リソース属性)のサポートを伴うリクエスト/レスポンス方式の同期を実装すべきです(SHOULD)。
  • OpAMPプロバイダーは、OpAMPのカスタムメッセージやagent-configペイロードにポリシーセットを埋め込むことができ、OpAMPの既存の変更検出の仕組みを再利用できます。

ランタイムでの競合解決

ポリシーは独立していて自己完結しているため、複数のポリシーが同じテレメトリーにマッチすることがあります。 このような場合、ランタイムはそれらの効果を組み合わせる必要があります。 実装がどのように評価を構造化するかにかかわらず、以下の性質が保たれなければなりません(MUST)。

  • 可換性(Commutativity)。 マッチしたポリシー群を適用した結果は、それらが処理される順序に依存してはなりません(MUST NOT)。
  • 冪等性(Idempotency)。 同じポリシーを2回適用しても、1回適用した場合と同じ結果を生成しなければなりません(MUST)。
  • 決定性(Determinism)。 同じマッチするポリシー群と同じテレメトリーが与えられた場合、すべてのインスタンスは同じ出力を生成しなければなりません(MUST)。

これらの性質により、ポリシーはエージェントやCollector間で調整なしに分散させることができ、処理順序にかかわらず結果が再現可能であることが保証されます。

具体例として、ランタイムが競合する keep 値をどのように解決しうるかを考えてみましょう。 素朴なアプローチ——最後に書き込んだものが勝つ(last write wins)——は、可換性に違反します。

# Bad: result depends on processing order
def resolve_keep_naive(matching_policies):
    result = "all"
    for policy in matching_policies:
        result = policy.keep  # last one wins
    return result

代わりに、ランタイムは、常に同じ答えに収束する**可換な縮約(commutative reduction)**を適用できます。 keep については、「もっとも制限の厳しいものが勝つ」という選択が自然です。

def restrictiveness(keep):
    """Returns a numeric rank for a keep value. Lower = more restrictive.

    Ranking:
      none       → 0   (drop everything)
      N/s        → 1   (N per second, rate limited)
      N/m        → 2   (N per minute, rate limited)
      N%         → 3   (percentage sampling, ordered by percentage ascending)
      all        → 4   (keep everything)
    """
    if keep.value == "none":
        return (0,)
    if keep.unit == "per_second":
        return (1, keep.amount)
    if keep.unit == "per_minute":
        return (2, keep.amount)
    if keep.unit == "percent":
        return (3, keep.percentage)
    if keep.value == "all":
        return (4,)

def most_restrictive(a, b):
    """Commutative merge: returns whichever keep value is more restrictive."""
    return a if restrictiveness(a) <= restrictiveness(b) else b

def resolve_keep(matching_policies):
    """Resolve conflicting keep values across all matching policies.

    The result is independent of policy ordering because most_restrictive
    is commutative: most_restrictive(a, b) == most_restrictive(b, a).
    """
    result = Keep("all")
    for policy in matching_policies:
        if policy.keep is None:
            continue
        result = most_restrictive(result, Keep(policy.keep))
    return result

同じ原則は、複数のポリシーが値を提供しうる任意のポリシーフィールドにも当てはまります。 そのようなフィールドごとに、実装は可換なマージ操作——たとえば最小値を取る、和集合を取る、決定的な優先順位を適用するなど——を定義すべきです。 自然な可換操作が存在しない場合(たとえば、2つのポリシーが同じ属性に異なる値を設定する場合)、実装は、インスタンス間で再現可能な結果を保証するために、一貫した順序(たとえばポリシーIDのアルファベット順)でポリシーを処理しなければなりません(MUST)。

トレードオフと緩和策

この仕様は、シンプルさとスケーラビリティを優先して、意図的なトレードオフを行っています。

ユーザー定義の順序付けなし。 ポリシーAをポリシーBより先に実行するよう指定することはできません。 これは意図的なものです——順序付けは依存関係を生み、依存関係はポリシーをスケールさせる独立性を損なうからです。 そのトレードオフは、柔軟性の低下です。 厳密な順序付けが必要な場合は、ポリシーシステムの外側に別の処理ステージを設ける必要があります。

条件分岐ロジックなし。 ポリシーはif/elseや分岐をサポートしていません。 各ポリシーは、単純な述語(predicate)とアクションです。 複雑な条件分岐ロジックは、テレメトリー処理ではなく、アプリケーションコードに属するべきものです。 これにより、ポリシーは理解しやすく、生成しやすいものであり続けます。

ポリシー間の参照なし。 ポリシーは、他のポリシーの出力を参照したり、他のポリシーが実行されたことに依存したりすることはできません。 これは合成の自由度を制限しますが、すべてのポリシーが自己完結していることを保証し、ユーザーがどこでもポリシーを実行してその正しさを検証できるようにします。 各ポリシーについて、単独で推論することができます。

これらの制約が存在するのは、主要な目標がスケール——数万件のポリシーを効率的に実行すること——だからです。 複雑さを追加する機能はすべて、その目標の達成を難しくします。 この仕様は、意図的に最小限にとどめられています。

先行技術と代替技術

このセクションでは、テレメトリーの処理と制御に対する既存のアプローチを検証し、ここで提案するポリシーモデルとの比較でそれらの強みと限界を分析します。

パイプライン設定

パイプラインベースの設定は、テレメトリー処理における支配的なモデルです。 Vector、Fluent Bit、Logstash、OpenTelemetry Collectorといったツールは、処理をコンポーネントの有向非巡回グラフ(DAG)として定義します。 データは、定義された順序でレシーバー、プロセッサー、エクスポーターを流れ、各コンポーネントが次に渡す前にデータを変換します。

利点:

  • 表現力が高く柔軟: 各ステージで任意の変換が可能です。
  • 広く理解されているモデルであり、豊富なツールとコミュニティの知見があります。
  • 複雑なルーティング、ファンアウト、条件分岐ロジックをサポートします。
  • 本番環境で実証された信頼性を持つ、成熟した実装があります。

欠点:

  • グローバルな推論を必要とします。1つのコンポーネントを変更すると、下流の振る舞いに影響する可能性があります。
  • 設定の複雑さはルール数とともに増大し、数千のルールは管理不能になります。
  • 逐次実行は、ルールが増えるにつれてパフォーマンスのボトルネックを生み出します。
  • ポータブルではありません。各ツールが独自の設定フォーマットとセマンティクスを持っています。
  • 相互依存関係があるため、ポリシーを効果的にリモートで変更することが困難です。

OPA(Open Policy Agent)

OPAは、Regoクエリ言語を使用する汎用のポリシーエンジンを提供します。 もともとクラウドネイティブ環境における認可とアドミッションコントロールのために設計されたOPAは、構造化データに対して任意のポリシーを評価できます。 Kubernetesのアドミッションコントロール、API認可、インフラストラクチャのポリシー強制などで広く利用されています。

利点:

  • チューリング完全なポリシー言語により、複雑な条件分岐ロジックが可能です。
  • ポリシーと強制を分離します。ポリシーはコードではなくデータです。
  • テスト、デバッグ、配布のためのツールを備えた強力なエコシステムがあります。
  • 集中管理のためのポリシーバンドルをサポートします。

欠点:

  • Regoは学習曲線が急で、ほとんどのエンジニアにとって直感的ではありません。
  • 汎用的な設計であるため、テレメトリー特有の最適化がありません。
  • ポリシーが任意のロジックを持つことができるため、振る舞いの予測が難しくなります。
  • 評価のオーバーヘッドが、高スループットなテレメトリーストリームにとって許容できないほど大きくなる可能性があります。

Datadog Processing Pipelines(先行技術)

Datadogは、ログ処理に対してUI主導のアプローチを提供します。 ユーザーは、ログをパース、エンリッチ、フィルタリング、変換するプロセッサーを含むパイプラインを定義します。 各プロセッサーは、フィルター(マッチャー)とアクションを持ちます。 UIは基盤となる設定を抽象化し、エンジニアでなくても利用できるようにしています。

利点:

  • ユーザーフレンドリーなインターフェースにより、処理ルールの作成の敷居が下がります。
  • 各プロセッサーは、概念的にはポリシー——マッチャーとアクション——に似ています。
  • Datadogの広範なオブザーバビリティプラットフォームと統合されています。
  • マネージドサービスであるため、運用負荷がありません。

欠点:

  • ベンダーロックイン: ルールはDatadog固有であり、ポータブルではありません。
  • Datadogがサポートする変換とマッチャーに限定されます。
  • 大規模なルールの一括管理のためのプログラマティックなAPIがありません。
  • 実行モデルが不透明であるため、デバッグが困難です。

OpenTelemetry Collector Processors / OTTL

OpenTelemetry Collectorには、一般的なテレメトリー変換のためのプロセッサーが含まれています。 データのドロップのための filter、属性の変更のための attributes、OTTLベースの変換のための transform などです。 これらは、Collectorパイプラインの一部としてYAMLで設定されます。

利点:

  • OpenTelemetryエコシステムにネイティブであり、強力なコミュニティサポートがあります。
  • OTTL(OpenTelemetry Transformation Language)は、構造化された変換構文を提供します。
  • プロセッサーは、パイプラインモデル内で組み合わせ可能です。
  • オープンソースであり、振る舞いが透明です。

欠点:

  • ルールはパイプライン設定に埋め込まれており、スタンドアロンではありません。
  • ルールを追加するには、パイプライン全体の文脈を理解する必要があります。
  • 別の実装なしには、SDKや他のランタイムに移植できません。
  • 設定の再読み込みなしの動的な更新は、ネイティブにはサポートされていません。
  • スケールは、逐次処理モデルによって制限されます。
  • OTTLには定義された文法がなく、Collectorの外で実行することはできません。

テレメトリーの唯一の制御手段としての宣言的設定 + OpAMP

宣言的設定 + OpAMPは、OpenTelemetry内の任意のコンポーネントに任意の設定を送信するために使用できます。 ここでは、OpAMPの設定伝達と、宣言的設定のオープンな拡張性・定義を活用して、SDKやCollectorの振る舞い全体を、OpAMPの「制御サーバー」からコンポーネントへと伝達し、動的に振る舞いを再読み込みさせることになります。

このソリューションが答えていないのは、どの設定をどのコンポーネントに送信できるかをどう理解するか、そして実装やパイプラインの構成から独立して制御/ポリシーをどう駆動するか、という点です。 例として、シンプルなCollector設定を考えてみましょう。

receivers:
  otlp:
  prometheus:
    # ... config ...
processors:
  batch:
  memorylimiter:
  transform/drop_attribute:
    # config to drop an attribute
exporters:
  otlp:
pipelines:
  metrics/critical:
    receivers: [otlp]
    processors: [batch, transform/drop_attribute]
    exporters: [otlp]
  metrics/all:
    receivers: [prometheus]
    processors: [memorylimiter]
    exporters: [otlp]

ここには、それぞれ意図した目的とチューニングされた設定を持つ2つのパイプラインがあります。 1つはメモリ制限に達してもメトリックを ドロップしない もので、もう1つはドロップするものです。 さて、特定のメトリックがレポートされないようにドロップしたい場合、どちらのパイプラインを変更すればよいのでしょうか。 その目的のために新しいプロセッサーを構築すべきでしょうか。 常にそうすべきなのでしょうか。

さらに、宣言的設定で制御しているSDKも 同時に 持っていると想像してください。 そのSDKにおけるメトリックの包含を制御したい場合、以下のようにまったく見た目の異なる設定ファイルを生成する必要があります。

file_format: "1.0-rc.1"
# ... other config ...
meter_provider:
  readers:
    - my_custom_metric_filtering_reader:
        my_filter_config: # defines what to filter
        wrapped:
          periodic:
            exporter:
              otlp_http:
                endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://localhost:4318}/v1/metric

ここでは、読み込むメトリックをフィルタリングできるようにするためのカスタムコンポーネントをJavaで作成しました。 しかし、このコンポーネントを挿入・使用するには、以下のすべてが必要です。

  • このコンポーネントがJava SDKに存在することを知っていること
  • 既存の任意のメトリックエクスポートパイプラインへの組み込み方を知っていること(たとえば、私のリーダーは、実際のエクスポート設定を持つ別のリーダーをラップします)。 注意: これはおそらく、エクスポーター設定の残りの部分を理解するか、パースできる必要があることを意味します。

これにはいくつかの理由から理想的ではありません。

  • テレメトリーフローを制御できるサーバーを設計する人は誰でも、自身が制御しうるすべてのコンポーネントとその実装について深い理解を持たなければなりません(MUST)。
  • どの設定がサポートされているか、あるいは特定のコンポーネントに送信できるかを宣言する「安全な」仕組みがありません(注: これは設計できます)。
  • テレメトリーシステムから公開することになる制御のレベルは 広範 であり、危険を伴う可能性があります。
    • リモートの設定がシステムの動作に与える影響を制限することができません。プロセスをダウンさせる可能性のある変更を防ぐことができません。
    • 設定の実行オーバーヘッドや、リモートで許可される変更に対するきめ細かい制御を制限することができません。

まとめ

この仕様はこれらすべてのアプローチから着想を得ていますが、柔軟性よりも独立性、ポータビリティ、スケールを優先しています。 パイプライン設定が最大限の表現力を提供する一方で、ポリシーは予測可能性を提供します。 OPAが汎用言語を提供する一方で、ポリシーは最小限で目的に特化したモデルを提供します。 ベンダーソリューションがユーザーをロックインする一方で、ポリシーはポータビリティのためにOpenTelemetryのデータモデルを使用します。

未解決の質問

このOTEPではまだ解決されていないとわかっている疑問には、どのようなものがあるでしょうか。 これらは、さらなる議論、実装実験、あるいは将来にもたらされるその他の何かを通じて答えが得られる可能性のある疑問です。

プロトタイプ

  • usetero/policy
    • ポリシー仕様。スキーマ、マッチングの振る舞い、マージのセマンティクス、適合性要件を定義します。
  • usetero/policy-go
    • ポリシー仕様のGo実装。OpenTelemetry Collectorや他のGoベースのテレメトリーコンポーネントとの統合を目的として設計されています。
  • usetero/policy-rs
    • ポリシー仕様のRust実装。高性能な正規表現マッチングのためにHyperscanを活用しています。
  • usetero/policy-zig
    • ポリシー仕様のZig実装。ホットパスでのヒープアロケーションをゼロにすることと、最大限のポータビリティを目標としています。
  • usetero/policy-conformance
    • フィルタリング、サンプリング、変換、実装間の一貫した振る舞いを網羅する、160以上のテストからなる言語横断的な適合性テストスイートです。

将来の可能性

この提案によって可能になる将来の変更には、どのようなものがあるでしょうか。