From 343b2ea4105331345153be0871a2dab27137bab9 Mon Sep 17 00:00:00 2001 From: Arthur Silva Sens Date: Mon, 8 Dec 2025 14:24:10 -0300 Subject: [PATCH 1/5] wip Signed-off-by: Arthur Silva Sens --- proposals/0071-Entity/01-context.md | 455 ++++++++ .../0071-Entity/02-exposition-formats.md | 587 ++++++++++ proposals/0071-Entity/03-service-discovery.md | 793 ++++++++++++++ proposals/0071-Entity/04-storage.md | 998 ++++++++++++++++++ .../0071-Entity/04b-storage-entity-native.md | 934 ++++++++++++++++ proposals/0071-Entity/05-querying.md | 555 ++++++++++ proposals/0071-Entity/06-web-ui-and-apis.md | 566 ++++++++++ ...ireframe - Simple idea - Complete flow.png | Bin 0 -> 218678 bytes 8 files changed, 4888 insertions(+) create mode 100644 proposals/0071-Entity/01-context.md create mode 100644 proposals/0071-Entity/02-exposition-formats.md create mode 100644 proposals/0071-Entity/03-service-discovery.md create mode 100644 proposals/0071-Entity/04-storage.md create mode 100644 proposals/0071-Entity/04b-storage-entity-native.md create mode 100644 proposals/0071-Entity/05-querying.md create mode 100644 proposals/0071-Entity/06-web-ui-and-apis.md create mode 100644 proposals/0071-Entity/wireframes/Wireframe - Simple idea - Complete flow.png diff --git a/proposals/0071-Entity/01-context.md b/proposals/0071-Entity/01-context.md new file mode 100644 index 00000000..f0c0a6ad --- /dev/null +++ b/proposals/0071-Entity/01-context.md @@ -0,0 +1,455 @@ +# Supporting Entities in Prometheus + +## Abstract + +This proposal introduces native support for **Entities** in Prometheus—a first-class representation of the things that produce telemetry, distinct from the telemetry they produce. + +Today, Prometheus relies on Info-type metrics to represent metadata about monitored objects: gauges with an `_info` suffix, a constant value of `1`, and labels containing the metadata. But this approach is fundamentally flawed: **the thing that produces metrics is not itself a metric**. A Kubernetes pod, a service instance, or a host are entities with their own identity and lifecycle—they should not be stored as time series with sample values. + +This conflation forces users to rely on verbose `group_left` joins to attach metadata to metrics, creates storage inefficiency for constant values, and loses the semantic distinction between what identifies an entity and what describes it. + +By introducing Entities as a native concept, Prometheus can provide cleaner query ergonomics, optimized storage for metadata, explicit lifecycle management, and proper semantics that distinguish between identifying labels (what makes an entity unique) and descriptive labels (additional context about that entity). + +This proposal also aligns with Prometheus's commitment to being the default store for OpenTelemetry metrics. OpenTelemetry's Entity model provides a well-defined structure for representing monitored objects, and native Entity support enables seamless translation between OTel Entities and Prometheus. + +--- + +## Terminology + +Before diving into the problem and proposed solution, let's establish a shared vocabulary: + +#### Info Metric + +A metric that exposes metadata about a monitored entity rather than a measurement. In current Prometheus convention, these are gauges with a constant value of `1` and labels containing the metadata. Examples include `node_uname_info`, `kube_pod_info`, and `target_info`. + +``` +build_info{version="1.2.3", revision="abc123", goversion="go1.21"} 1 +``` + +#### Entity + +An **Entity** represents a distinct object of interest that produces or is associated with telemetry. Unlike Info metrics, Entities are not metrics—they are first-class objects with their own identity, labels, and lifecycle. + +In OpenTelemetry, an entity is an object of interest that produces telemetry data. Entities represent things like services, hosts, containers, or Kubernetes pods. Each entity has a type (e.g., `k8s.pod`, `host`, `service`) and a set of attributes that describe it. + +This proposal adopts the Entity concept as the native Prometheus representation for what was previously expressed through Info metric conventions. + +#### Resource Attributes + +In OpenTelemetry, **resource attributes** are key-value pairs that describe the entity producing telemetry. These attributes are attached to all telemetry (metrics, logs, traces) from that entity. When OTel metrics are exported to Prometheus, resource attributes typically become labels on a `target_info` metric. + +#### Identifying Labels + +**Identifying labels** uniquely distinguish one entity from another of the same type. These labels: +- Must remain constant for the lifetime of the entity +- Together form a unique identifier for the entity +- Are required to identify which entity produced the telemetry + +Examples: +- `k8s.pod.uid` or (`k8s.pod.name`,`k8s.namespace.name`) for a Kubernetes pod +- `host.id` for a host +- `service.instance.id` for a service instance + +#### Descriptive Labels + +**Descriptive labels** provide additional context about an entity but do not serve to uniquely identify it. These labels: +- May change during the entity's lifetime +- Provide useful metadata for querying and visualization +- Are optional and supplementary + +Examples: +- `k8s.pod.label.app_name` (pods labels can change) +- `host.name` (hostnames can change) +- `service.version` (versions change with deployments) + +--- + +## Problem Statement + +### Entities Are Not Metrics + +At the heart of the Info metric pattern lies a conceptual mismatch: **the thing that produces metrics is not itself a metric**. + +Consider a Kubernetes pod. It has an identity (namespace, UID), labels that describe it (name, node, pod labels), a lifecycle (creation time, termination), and it produces telemetry (CPU usage, memory consumption, request counts). The pod is the *source* of metrics—it is not *a* metric. + +Yet today, we represent this pod as a metric: + +```promql +kube_pod_info{namespace="production", pod="api-server-7b9f5", uid="550e8400", node="worker-2"} 1 +``` + +This representation has several conceptual problems: + +1. **The value is meaningless**: The `1` carries no information. It exists only because Prometheus's data model requires a numeric value. +2. **Identity is conflated with data**: All labels are treated equally. There's no distinction between `uid` (which identifies the pod) and `node` (which describes where it's running and could change). +3. **Lifecycle is implicit**: When a pod is deleted and recreated with the same name, Prometheus sees label churn. There's no explicit representation of "this entity ended, a new one began." +4. **Correlation requires workarounds**: To associate the pod's metadata with its metrics, users must write complex `group_left` joins—essentially reconstructing a relationship that should be built into the data model. + +The Prometheus data model was designed for metrics: measurements that change over time, represented as (timestamp, value) pairs with identifying labels. Entities don't fit this model. They have: + +- **Stable identity** (not a stream of values) +- **Mutable descriptions** (labels that change independently of any "sample") +- **Explicit lifecycle** (creation and termination events) +- **Correlation relationships** (many metrics belong to one entity) + +**This proposal introduces Entities as a first-class concept in Prometheus**, separate from metrics, with their own storage, lifecycle management, and query semantics. Info metrics will continue to work for backward compatibility, but new instrumentation and the OTel integration can use proper Entity semantics. + +### The Current Workaround: Info Metrics as Gauges + +Prometheus does not have a native Entity type. Instead, users follow a convention: create a gauge with an `_info` suffix, set its value to `1`, and encode metadata as labels. + +```promql +node_uname_info{nodename="server-1", release="5.15.0", version="#1 SMP", machine="x86_64"} 1 +``` + +While OpenMetrics formally defines an Info type, the Prometheus text exposition format does not support it. This means: +- Info metrics consume storage for a constant value (`1`) that carries no information +- There's no semantic distinction between info metrics and regular gauges +- Query engines cannot optimize for the unique characteristics of metadata + +### Joining Info Metrics Requires `group_left` + +The most common use case for info metrics is attaching their labels to other metrics. For example, adding Kubernetes pod metadata to container CPU metrics: + +```promql +container_cpu_usage_seconds_total + * on(namespace, pod) group_left(node, created_by_kind, created_by_name) + kube_pod_info +``` + +This pattern has several problems: + +1. **Verbose**: Every query that needs pod metadata must include the full `group_left` clause. Dashboards with dozens of panels repeat this join logic everywhere. +2. **Error-Prone**: The `on()` clause must list exactly the right matching labels. Miss one, and the join fails silently or produces incorrect results. List too many, and you get "many-to-many matching not allowed" errors. +3. **Confusing Semantics**: The `group_left` modifier is one of the most confusing aspects of PromQL for new users. "Many-to-one matching" and "group modifiers" require significant mental overhead to understand and use correctly. +4. **Fragile to label changes**: If `kube_pod_info` adds a new label, existing queries may break. If a label is removed, dashboards silently lose data. There's no contract about which labels are stable identifiers vs. which are descriptive metadata. + +### No Distinction Between Identifying and Descriptive Labels + +Current info metrics treat all labels equally. There's no way to express that some labels are stable identifiers while others are mutable metadata: + +```promql +kube_pod_info{ + namespace="production", # Identifying: part of pod identity + pod="api-server-7b9f5", # Identifying: part of pod identity + uid="abc-123-def", # Identifying: globally unique + node="worker-2", # Descriptive: can change if rescheduled + created_by_kind="Deployment", # Descriptive: additional context + created_by_name="api-server" # Descriptive: additional context +} 1 +``` + +This lack of distinction causes problems: +- Queries cannot reliably join on "the identity" of an entity +- OTel Entities cannot be accurately translated (OTel's identifying vs descriptive attributes map to our identifying vs descriptive labels) + +### Storage and Lifecycle Are Not Optimized + +Info metrics are stored like any other time series, despite their unique characteristics: +- The value is always `1`—storing it repeatedly wastes space +- Metadata changes infrequently, but samples are scraped every interval +- Staleness handling treats info metrics like measurements, not metadata + +--- + +## Motivation + +### Prometheus's Commitment to OpenTelemetry + +In March 2024, Prometheus announced its commitment to being the default store for OpenTelemetry metrics. This includes: +- Native OTLP ingestion +- UTF-8 support for metric and label names +- Native support for resource attributes + +OpenTelemetry's data model distinguishes between **metric attributes** (dimensions on individual metrics) and **resource attributes** (properties of the entity producing metrics). Currently, Prometheus flattens resource attributes into `target_info` labels, losing the semantic distinction. + +Native Entity support is a important step toward proper resource attribute handling. + +### The Entity Model + +OpenTelemetry's Entity model provides a structured way to represent monitored objects: + +``` +Entity { + type: "k8s.pod" + identifying_attributes: { + "k8s.namespace.name": "production", + "k8s.pod.uid": "abc-123-def" + } + descriptive_attributes: { + "k8s.pod.name": "api-server-7b9f5", + "k8s.node.name": "worker-2", + "k8s.deployment.name": "api-server" + } +} +``` + +This model enables: +- Clear semantics about what identifies an entity +- Lifecycle management (entities can be created, updated, deleted) +- Correlation across telemetry signals (metrics, logs, traces) + +Prometheus can benefit from similar semantics. In this proposal, OTel's "identifying attributes" map to Prometheus identifying labels, and OTel's "descriptive attributes" map to descriptive labels. + +### Users Already Rely on Info Metrics + +Info metrics are a well-established pattern in the Prometheus ecosystem: + +| Metric | Source | Labels | +|--------|--------|--------| +| `node_uname_info` | Node Exporter | `nodename`, `release`, `version`, `machine`, `sysname` | +| `kube_pod_info` | kube-state-metrics | `namespace`, `pod`, `uid`, `node`, `created_by_*`, etc. | +| `kube_node_info` | kube-state-metrics | `node`, `kernel_version`, `os_image`, `container_runtime_version` | +| `target_info` | OTel SDK | All resource attributes | +| `build_info` | Various | `version`, `revision`, `branch`, `goversion` | + +These metrics are used in thousands of dashboards and alerts. Introducing native Entities improves the ergonomics and semantics while maintaining the utility users depend on. + +--- + +## Use Cases + +### Enriching Metrics with Producer Metadata + +A common need in observability is to enrich metrics with information about what produced them. When analyzing CPU usage, you often want to know which version of the software is running, what node a container is scheduled on, or what deployment owns a pod. This context transforms raw numbers into actionable insights. + +**The Problem:** + +Today, this requires complex `group_left` joins between metrics and info metrics: + +```promql +sum by (namespace, pod, node) ( + rate(container_cpu_usage_seconds_total{namespace="production"}[5m]) + * on(namespace, pod) group_left(node) + kube_pod_info +) +``` + +This pattern appears everywhere: adding `build_info` labels to application metrics, enriching host metrics with `node_uname_info`, correlating service metrics with `target_info` from OTel. Every query must: + +- Know which labels to match on (`namespace`, `pod`, `job`, `instance`, etc.) +- Explicitly list which metadata labels to bring in +- Handle edge cases when labels change (pod rescheduling, version upgrades) + + +Users should be able to say "give me this metric, enriched with information about its producer" without writing complex joins. The query engine should understand the relationship between metrics and the entities that produced them. + +With native Entity support, the query engine knows which labels identify an entity and which describe it. Enrichment becomes automatic or requires minimal syntax—no need to manually specify join keys or enumerate which labels to include. + +### OpenTelemetry Resource Translation + +**Current State:** + +When OTel metrics are exported to Prometheus, resource attributes become labels on `target_info`: + +```promql +target_info{ + job="otel-collector", + instance="collector-1:8888", + service_name="payment-service", + service_version="2.1.0", + service_instance_id="i-abc123", + deployment_environment="production", + host_name="prod-vm-42", + host_id="550e8400-e29b-41d4-a716-446655440000" +} 1 +``` + +To use these attributes with application metrics: + +```promql +http_request_duration_seconds_bucket + * on(job, instance) group_left(service_name, service_version, deployment_environment) + target_info +``` + +**Pain Points:** +- OTel distinguishes identifying vs. descriptive attributes; Prometheus loses this +- Entity lifecycle (creation, updates) is not represented +- Every query must know the OTel schema to write correct joins + +**Desired State:** + +Native translation of OTel Entities to Prometheus Entities, where OTel's identifying attributes (like `k8s_pod_uid`) become identifying labels, and OTel's descriptive attributes (like `k8s_pod_annotation_created_by`, `k8s_pod_status`) become descriptive labels. This would preserve the semantic richness of the OTel data model and enable better query ergonomics. + +### Collection Architectures: Direct Scraping vs. Gateways + +Prometheus deployments follow two main patterns for collecting metrics, and this proposal must support both. + +**Direct Scraping** + +In direct scraping, Prometheus discovers and scrapes each target individually. Service Discovery provides accurate metadata about each target, because the target *is* the entity producing metrics. + +``` +┌─────────────┐ +│ Service A │◀────┐ +│ (pod-xyz) │ │ +└─────────────┘ │ + │ scrape ┌───────────┐ +┌─────────────┐ ├──────────▶│ │ +│ Service B │◀────┤ │Prometheus │ +│ (pod-abc) │ │ │ │ +└─────────────┘ │ └───────────┘ + │ +┌─────────────┐ │ +│ Service C │◀────┘ +│ (pod-def) │ +└─────────────┘ +``` + +Here, Kubernetes SD knows that `pod-xyz` runs Service A with specific labels, resource limits, and node placement. This metadata accurately describes the entity producing metrics—SD-derived entities work well. + +**Gateway and Federation** + +In gateway architectures, metrics flow through an intermediary before reaching Prometheus. The intermediary aggregates metrics from multiple sources. + +``` +┌───────────┐ ┌───────────┐ ┌───────────┐ +│ Service A │────▶│ │ │ │ +│ │push │ OTel │──────▶│Prometheus │ +├───────────┤ │ Collector │scrape │ │ +│ Service B │────▶│ │ │ │ +│ │ │(gateway) │ │ │ +├───────────┤ │ │ │ │ +│ Service C │────▶│ │ │ │ +└───────────┘ └───────────┘ └───────────┘ +``` + +Here, SD only sees the OTel Collector—not Services A, B, or C. Any SD-derived metadata would describe the collector, not the actual metric producers. The same applies to Prometheus federation and pushgateway patterns. + +| What SD Sees | What Actually Produced Telemetry | +|--------------|----------------------------------| +| `otel-collector-pod-xyz` | `payment-service`, `auth-service`, `user-service` | +| `prometheus-federation-1` | Hundreds of scraped targets from regional Prometheus | +| `pushgateway-xyz` | Various batch jobs and short-lived processes | +| `kube-state-metrics-0` | Workloads running in K8s and K8s API itself | + +**Supporting Both Models** + +This proposal must support both architectures: + +1. **Direct scraping**: Entity information can be derived from Service Discovery metadata, since SD accurately describes each target. +2. **Gateway/federation**: Entity information must be embedded in the exposition format to travel with the metrics through intermediaries. + +Users choose the appropriate approach for their architecture. See [Service Discovery](./03-service-discovery.md) for configuration details. + +--- + +## Goals + +This proposal aims to achieve the following: + +### 1. Define Entity as a Native Concept + +Prometheus should recognize Entities as a distinct concept with their own semantics, separate from metrics. Entities represent the things that produce telemetry, not the telemetry itself. + +### 2. Support Identifying and Descriptive Label Semantics + +Entities should allow declaring which labels are identifying (forming the entity's identity) and which are descriptive (providing additional context that may change over time). + +### 3. Improve Query Ergonomics + +Reduce or eliminate the need for `group_left` when attaching entity labels to related metrics. The common case should be simple. + +### 4. Optimize Storage for Metadata + +Entities store string labels and change infrequently. Storage and ingestion should be optimized for this pattern, rather than treating them as time series with constant values. + +### 5. Enable OTel Entity Translation + +Provide a natural mapping between OpenTelemetry Entities and Prometheus Entities, translating OTel's identifying and descriptive attributes to Prometheus's identifying and descriptive labels. + +### 6. Support Both Direct and Gateway Collection Models + +Entity information must work correctly whether Prometheus scrapes targets directly (where SD metadata is accurate) or through intermediaries like OTel Collector or federation. + +--- + +## Non-Goals + +The following are explicitly out of scope for this proposal: + +### Changing behavior for existing `*_info` Gauges + +This proposal defines new semantics for Entities. Existing gauges with `_info` suffix will continue to work as gauges and joins will continue to work. Migration or automatic conversion is not in scope. + +### Complete OTel Data Model Parity + +This proposal focuses on Entities. Full parity with OTel's data model (exemplars, exponential histograms, etc.) is addressed elsewhere. + +--- + +## Related Work + +### OpenMetrics Specification + +OpenMetrics 1.0 (November 2020) formally defines the Info metric type. The specification describes Info as "used to expose textual information which SHOULD NOT change during process lifetime." + +- [OpenMetrics 1.0 Specification](https://prometheus.io/docs/specs/om/open_metrics_spec/) +- [OpenMetrics 2.0 Draft](https://prometheus.io/docs/specs/om/open_metrics_spec_2_0/) + +### The `info()` PromQL Function + +Prometheus 2.x introduced an experimental `info()` function in PromQL to simplify joins between metrics and info metrics. Instead of writing verbose `group_left` queries, users can write: + +```promql +info(rate(http_requests_total[5m])) +``` + +This automatically enriches the result with labels from `target_info`. The function reduces boilerplate and makes queries more readable. + +However, the current implementation hardcodes `job` and `instance` as identifying labels—the labels used to correlate metrics with their info series. This works for `target_info` but fails for other entity types like `kube_pod_info` (which uses `namespace` and `pod`) or `kube_node_info` (which uses `node`). The community is actively discussing improvements to make the function more flexible. + +More fundamentally, `info()` still operates on info metrics—it makes joins easier but doesn't change the underlying model where entity information is encoded as a metric with a constant value. Native Entity support would allow the query engine to understand entity relationships directly, making enrichment automatic without needing explicit function calls or hardcoded identifying labels. + +- [PromQL info() function documentation](https://prometheus.io/docs/prometheus/latest/querying/functions/#info) + +### OpenTelemetry Entity Data Model + +OpenTelemetry defines Entities as "objects of interest associated with produced telemetry." The data model specifies: +- Entity types and their schemas +- Identifying vs. descriptive attributes +- Entity lifecycle events + +- [OTel Entities Data Model](https://opentelemetry.io/docs/specs/otel/entities/data-model/) +- [Resource and Entity Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/how-to-write-conventions/resource-and-entities/) + +### OpenTelemetry Prometheus Compatibility + +OpenTelemetry provides specifications for bidirectional conversion between OTel and Prometheus formats: +- Resource attributes → `target_info` labels +- Metric attributes → metric labels +- Handling of Info and StateSet types + +- [Prometheus and OpenMetrics Compatibility](https://opentelemetry.io/docs/specs/otel/compatibility/prometheus_and_openmetrics/) +- [Prometheus Exporter Specification](https://opentelemetry.io/docs/specs/otel/metrics/sdk_exporters/prometheus/) + +### Prometheus Commitment to OpenTelemetry + +In March 2024, Prometheus announced plans to be the default store for OpenTelemetry metrics: +- OTLP ingestion +- UTF-8 metric and label name support +- Native resource attribute support + +As of late 2024, most of this work has been implemented: OTLP ingestion is generally available in Prometheus 3.0 and UTF-8 support for metric and label names is complete. The notable exception is **native support for resource attributes**—which is precisely what this proposal aims to address through proper Entity semantics. + +- [Prometheus Commitment to OpenTelemetry](https://prometheus.io/blog/2024/03/14/commitment-to-opentelemetry/) + +--- + +## What's Next + +This document establishes the context and motivation for native Entity support in Prometheus. The following documents detail the implementation: + +- **[Exposition Formats](./02-exposition-formats.md)**: How entities are represented in text and protobuf formats +- **[Service Discovery](./03-service-discovery.md)**: How entities relate to Prometheus targets and discovered metadata +- **[Storage](./04-storage.md)**: How entities are stored efficiently in the TSDB +- **[Querying](./05-querying.md)**: PromQL extensions for working with entities +- **[Web UI and APIs](./06-web-ui-and-apis.md)**: How entities are displayed and accessed +- **Remote Write (TBD)**: Protocol changes for transmitting entities over remote write +- **Alerting (TBD)**: How entities interact with alerting rules and Alertmanager +- **SDKs (TBD)**: How entities can be generated by Prometheus SDKs + +--- + +*This proposal is a work in progress. Feedback from Prometheus maintainers, users, and the broader observability community is welcome.* diff --git a/proposals/0071-Entity/02-exposition-formats.md b/proposals/0071-Entity/02-exposition-formats.md new file mode 100644 index 00000000..6547ee55 --- /dev/null +++ b/proposals/0071-Entity/02-exposition-formats.md @@ -0,0 +1,587 @@ +# Exposition Formats + +## Abstract + +This document specifies how Prometheus exposition formats should be extended to support the Entity concept introduced in [01-context.md](./01-context.md). It covers syntax additions to the text format and new protobuf message definitions. + +The goal is to enable first-class representation of entities—the things that produce telemetry—while maintaining backward compatibility with existing scrapers that don't understand entities. + +--- + +## The Entity Concept + +An **Entity** represents a distinct object of interest that produces or is described by telemetry. Examples include: + +| Component | Description | +|-----------|-------------| +| **Type** | The entity type this instance belongs to (e.g., `k8s.pod`) | +| **Identifying Labels** | Labels that uniquely identify this entity instance. Must remain constant for the entity's lifetime. | +| **Descriptive Labels** | Additional context about the entity. May change over time. | + +Examples of entities: + +- A Kubernetes pod (`k8s.pod`) identified by namespace and UID +- A host or node (`k8s.node`) identified by node UID +- A service instance (`service`) identified by namespace, name, and instance ID + +--- + +## Text Format + +### New Syntax Elements + +| Element | Syntax | Description | +|---------|--------|-------------| +| Entity type declaration | `# ENTITY_TYPE ` | Declares an entity type for subsequent entities | +| Identifying labels | `# ENTITY_IDENTIFYING ...` | Lists which labels form the identity | +| Entity instance | `{}` | An entity instance (no value) | + +### Complete Example + +``` +# ENTITY_TYPE k8s.pod +# ENTITY_IDENTIFYING k8s.namespace.name k8s.pod.uid +k8s.pod{k8s.namespace.name="default",k8s.pod.uid="550e8400-e29b-41d4-a716-446655440000",k8s.pod.name="nginx-7b9f5"} +k8s.pod{k8s.namespace.name="default",k8s.pod.uid="660e8400-e29b-41d4-a716-446655440001",k8s.pod.name="redis-cache-0"} +k8s.pod{k8s.namespace.name="kube-system",k8s.pod.uid="770e8400-e29b-41d4-a716-446655440002",k8s.pod.name="coredns-5dd5756b68-abcde"} + +# ENTITY_TYPE k8s.node +# ENTITY_IDENTIFYING k8s.node.uid +k8s.node{k8s.node.uid="node-uid-001",k8s.node.name="worker-1",k8s.node.os="linux",k8s.node.kernel="5.15.0"} +k8s.node{k8s.node.uid="node-uid-002",k8s.node.name="worker-2",k8s.node.os="linux",k8s.node.kernel="5.15.0"} + +# ENTITY_TYPE service +# ENTITY_IDENTIFYING service.namespace service.name service.instance.id +service{service.namespace="production",service.name="payment-service",service.instance.id="i-abc123",service.version="2.1.0"} + +--- + +# TYPE container_cpu_usage_seconds counter +# HELP container_cpu_usage_seconds Total CPU usage in seconds +# This metric correlates with BOTH k8s.pod and k8s.node entities +# (it contains the identifying labels of both) +container_cpu_usage_seconds_total{k8s.namespace.name="default",k8s.pod.uid="550e8400-e29b-41d4-a716-446655440000",k8s.node.uid="node-uid-001",container="nginx"} 1234.5 +container_cpu_usage_seconds_total{k8s.namespace.name="default",k8s.pod.uid="660e8400-e29b-41d4-a716-446655440001",k8s.node.uid="node-uid-002",container="redis"} 567.8 + +# TYPE http_requests counter +# HELP http_requests Total HTTP requests +http_requests_total{service.namespace="production",service.name="payment-service",service.instance.id="i-abc123",method="GET",status="200"} 9999 + +# EOF +``` + +### Parsing Rules + +1. `# ENTITY_TYPE` starts a new entity family block +2. `# ENTITY_IDENTIFYING` must follow `# ENTITY_TYPE` before any entity instances +3. Entity instances (lines matching `{...}` with no value) are ONLY valid after an `# ENTITY_TYPE` declaration. A line like `foo{bar="baz"}` without a preceding entity type declaration is a parse error. +4. Entity instances MUST contain all identifying labels declared in `# ENTITY_IDENTIFYING` +5. The entity type name in the instance line MUST match the declared `# ENTITY_TYPE` + +### Entity Section Ordering + +**All entities MUST appear at the beginning of the scrape response, before any metrics.** The entity section ends with a `---` delimiter on its own line. + +This ordering requirement exists for practical reasons: when Prometheus parses a metric, it needs to immediately correlate that metric with any relevant entities. If entities could appear anywhere in the response, Prometheus would need to either buffer all metrics until the entire response is parsed, or make a second pass through the data. Both approaches add complexity and memory overhead. + +By requiring entities first, the parser can process the exposition in a single pass. When it encounters a metric, all potentially correlated entities are already in memory and correlation can happen immediately. + +If no entities are present, the `---` delimiter may be omitted. If entities are present but metrics appear before the `---` delimiter (or without one), the scrape fails with a parse error. + +--- + +## Protobuf Format + +### New Message Definitions + +```protobuf +syntax = "proto2"; + +package io.prometheus.client; + +// EntityFamily groups entities of the same type +message EntityFamily { + // Entity type name (e.g., "k8s.pod", "service", "build") + required string type = 1; + + // Names of labels that form the unique identity + repeated string identifying_label_names = 2; + + // Entity instances of this type + repeated Entity entity = 3; +} + +// Entity represents a single entity instance +message Entity { + // All labels (both identifying and descriptive) + repeated LabelPair label = 1; +} +``` + +### Integration with Existing Messages + +The existing `MetricFamily` structure remains unchanged. A new top-level message wraps both: + +```protobuf +// MetricPayload is the top-level message for scrape responses +// that include both entities and metrics +message MetricPayload { + // Entity families + repeated EntityFamily entity_family = 1; + + repeated MetricFamily metric_family = 2; +} +``` + +### Content-Type + +For protobuf with entity support: + +``` +application/vnd.google.protobuf;proto=io.prometheus.client.MetricPayload;encoding=delimited +``` + +For protobuf with entity support, the `proto` parameter changes from `MetricFamily` to `MetricPayload` to indicate the new top-level message type. + +--- + +## Entity-Metric Correlation + +### How Correlation Works + +Entities correlate with metrics through **shared identifying labels**: + +- If a metric has labels that match ALL identifying labels of an entity (same names, same values), that metric is associated with that entity. +- A single metric can correlate with multiple entities (of different types) if it contains the identifying labels of each. + +**Example:** + +``` +# ENTITY_TYPE k8s.pod +# ENTITY_IDENTIFYING k8s.namespace.name k8s.pod.uid +k8s.pod{k8s.namespace.name="default",k8s.pod.uid="550e8400",k8s.pod.name="nginx"} + +--- + +# This metric correlates with the entity above (has both identifying labels) +container_cpu_usage_seconds_total{k8s.namespace.name="default",k8s.pod.uid="550e8400",container="app"} 1234.5 +``` + +Correlation is computed at ingestion time when Prometheus parses the exposition format. See [04-storage.md](./04-storage.md#correlation-index) for how Prometheus builds and maintains these correlations in storage. + +### Conflict Detection + +When a metric correlates with an entity, the query engine enriches the metric's labels with the entity's descriptive labels (see [05-querying.md](./05-querying.md)). This creates the possibility of label conflicts—a metric might have a label with the same name as an entity's descriptive label. + +A conflict occurs when: +- A metric correlates with an entity (has all identifying labels) +- The metric has a label with the same name as one of the entity's descriptive labels +- The values differ + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Label Conflict Detection │ +└─────────────────────────────────────────────────────────────────────────────┘ + +Entity (k8s.pod) Metric (my_metric) +┌─────────────────────────────────┐ ┌─────────────────────────────────┐ +│ Identifying Labels: │ │ Labels: │ +│ k8s.namespace.name = "default"│◄─────────►│ k8s.namespace.name = "default"│ ✓ Match +│ k8s.pod.uid = "abc-123" │◄─────────►│ k8s.pod.uid = "abc-123" │ ✓ Match +├─────────────────────────────────┤ ├─────────────────────────────────┤ +│ Descriptive Labels: │ │ │ +│ version = "2.0" │◄────╳────►│ version = "1.0" │ ✗ CONFLICT! +│ k8s.pod.name = "nginx" │ │ │ +└─────────────────────────────────┘ │ Value: 42 │ + └─────────────────────────────────┘ + +Correlation established via matching identifying labels, +but "version" exists in both with different values → Scrape fails! +``` + +**Example conflict in exposition format:** +``` +# ENTITY_TYPE k8s.pod +# ENTITY_IDENTIFYING k8s.namespace.name k8s.pod.uid +k8s.pod{k8s.namespace.name="default",k8s.pod.uid="abc-123",version="2.0"} + +--- + +# This metric has k8s.pod identifying labels, so it correlates with the entity. +# But it also has a "version" label that conflicts with the entity's "version" label! +my_metric{k8s.namespace.name="default",k8s.pod.uid="abc-123",version="1.0"} 42 +``` + +When a conflict is detected during scrape, **the scrape fails with an error**. + +Note that **identifying labels cannot conflict** because they must be present on the metric for correlation to occur—if the metric has the same label name with a different value, it simply won't correlate with that entity. + +--- + +## Technical Implementation + +This section provides detailed implementation guidance for parsing entities and integrating with the scrape loop. The implementation should align with the storage layer defined in [04-storage.md](./04-storage.md). + +### Parser Interface Extensions + +The existing `Parser` interface in `model/textparse/interface.go` needs new methods and entry types to handle entities: + +#### New Entry Types + +The `Entry` type is extended with new values for entity handling: + +```go +// Current Entry types (model/textparse/interface.go:206-213) +const ( + EntryInvalid Entry = -1 + EntryType Entry = 0 + EntryHelp Entry = 1 + EntrySeries Entry = 2 + EntryComment Entry = 3 + EntryUnit Entry = 4 + EntryHistogram Entry = 5 + + // New entity entry types + EntryEntityType Entry = 6 // # ENTITY_TYPE + EntryEntityIdentifying Entry = 7 // # ENTITY_IDENTIFYING ... + EntryEntity Entry = 8 // {} (no value) + EntryEntityDelimiter Entry = 9 // --- (marks end of entity section) +) +``` + +When the parser encounters `---`, it returns `EntryEntityDelimiter`. After this point, any entity declarations are a parse error—all entities must appear before the delimiter. + +#### New Parser Methods + +```go +// Parser interface additions +type Parser interface { + // ... existing methods (Series, Histogram, Help, Type, Unit, etc.) ... + + // EntityType returns the entity type name from an ENTITY_TYPE declaration. + // Must only be called after Next() returned EntryEntityType. + // The returned byte slice becomes invalid after the next call to Next. + EntityType() []byte + + // EntityIdentifying returns the list of identifying label names. + // Must only be called after Next() returned EntryEntityIdentifying. + // The returned slice becomes invalid after the next call to Next. + EntityIdentifying() [][]byte + + // EntityLabels writes the entity labels into the passed labels. + // Must only be called after Next() returned EntryEntity. + // All labels (both identifying and descriptive) are included. + EntityLabels(l *labels.Labels) +} +``` + +### Scrape Loop Integration + +The scrape loop in `scrape/scrape.go` needs significant changes to process entities alongside metrics. + +#### Entity Cache + +Extend `scrapeCache` to track entities similar to how it tracks series: + +```go +// Entity cache entry (analogous to cacheEntry for series) +type entityCacheEntry struct { + ref storage.EntityRef + lastIter uint64 + hash uint64 + identifyingLabels labels.Labels + descriptiveLabels labels.Labels +} + +type scrapeCache struct { + // ... existing fields (series, droppedSeries, seriesCur, seriesPrev, metadata) ... + + // Entity parsing state (reset each scrape) + currentEntityType string + currentIdentifyingNames []string + + // Entity tracking (persists across scrapes) + entities map[string]*entityCacheEntry // key: hash of identifying attrs + entityCur map[storage.EntityRef]*entityCacheEntry + entityPrev map[storage.EntityRef]*entityCacheEntry +} + +func newScrapeCache(metrics *scrapeMetrics) *scrapeCache { + return &scrapeCache{ + // ... existing initialization ... + entities: map[string]*entityCacheEntry{}, + entityCur: map[storage.EntityRef]*entityCacheEntry{}, + entityPrev: map[storage.EntityRef]*entityCacheEntry{}, + } +} +``` + +#### Entity Processing in append() + +The main append loop in `scrapeLoop.append()` is extended: + +```go +func (sl *scrapeLoop) append(app storage.Appender, b []byte, contentType string, ts time.Time) (total, added, seriesAdded int, err error) { + defTime := timestamp.FromTime(ts) + + // ... existing parser creation ... + + var ( + // ... existing variables ... + entitiesTotal int + entitiesAdded int + ) + +loop: + for { + et, err := p.Next() + if err != nil { + if errors.Is(err, io.EOF) { + err = nil + } + break + } + + switch et { + case textparse.EntryEntityType: + sl.cache.currentEntityType = string(p.EntityType()) + sl.cache.currentIdentifyingNames = nil + continue + + case textparse.EntryEntityIdentifying: + names := p.EntityIdentifying() + sl.cache.currentIdentifyingNames = make([]string, len(names)) + for i, name := range names { + sl.cache.currentIdentifyingNames[i] = string(name) + } + continue + + case textparse.EntryEntity: + entitiesTotal++ + if err := sl.processEntity(app, p, defTime); err != nil { + sl.l.Debug("Entity processing error", "err", err) + // Depending on error type, may break or continue + if isEntityLimitError(err) { + break loop + } + continue + } + entitiesAdded++ + continue + + case textparse.EntryType: + // ... existing handling ... + case textparse.EntryHelp: + // ... existing handling ... + case textparse.EntrySeries, textparse.EntryHistogram: + // ... existing metric handling ... + // ADD: conflict detection before appending + } + } + + // Update stale markers for both series AND entities + if err == nil { + err = sl.updateStaleMarkers(app, defTime) + sl.updateEntityStaleMarkers(app, defTime) + } + + return total, added, seriesAdded, err +} +``` + +#### Entity Processing Method + +```go +func (sl *scrapeLoop) processEntity(app storage.Appender, p textparse.Parser, ts int64) error { + var allLabels labels.Labels + p.EntityLabels(&allLabels) + + // Validate: all identifying labels must be present + identifying, descriptive := sl.splitEntityLabels(allLabels) + if len(identifying) != len(sl.cache.currentIdentifyingNames) { + return fmt.Errorf("entity missing required identifying labels: expected %v", + sl.cache.currentIdentifyingNames) + } + + // Check entity limit + if sl.entityLimit > 0 && len(sl.cache.entities) >= sl.entityLimit { + return errEntityLimit + } + + hash := identifying.Hash() + hashKey := fmt.Sprintf("%s:%d", sl.cache.currentEntityType, hash) + + // Check cache for existing entity + ce, cached := sl.cache.entities[hashKey] + if cached { + ce.lastIter = sl.cache.iter + + // Check if descriptive labels changed + if !labels.Equal(ce.descriptiveLabels, descriptive) { + ce.descriptiveLabels = descriptive + // Will trigger a WAL write via AppendEntity + } + } + + // Call storage appender + ref, err := app.AppendEntity( + sl.cache.currentEntityType, + identifying, + descriptive, + ts, + ) + if err != nil { + return err + } + + // Update cache + if !cached { + ce = &entityCacheEntry{ + ref: ref, + lastIter: sl.cache.iter, + hash: hash, + identifyingLabels: identifying, + descriptiveLabels: descriptive, + } + sl.cache.entities[hashKey] = ce + } else { + ce.ref = ref + } + + sl.cache.entityCur[ref] = ce + return nil +} + +func (sl *scrapeLoop) splitEntityLabels(allLabels labels.Labels) (labels.Labels, labels.Labels) { + identifyingSet := make(map[string]struct{}) + for _, name := range sl.cache.currentIdentifyingNames { + identifyingSet[name] = struct{}{} + } + + var identifying, descriptive labels.Labels + allLabels.Range(func(l labels.Label) { + if _, ok := identifyingSet[l.Name]; ok { + identifying = append(identifying, l) + } else { + descriptive = append(descriptive, l) + } + }) + + return identifying, descriptive +} +``` + +#### Entity Staleness + +Entity staleness works similarly to series staleness, but marks entities as dead rather than writing StaleNaN: + +```go +func (sl *scrapeLoop) updateEntityStaleMarkers(app storage.Appender, ts int64) error { + for ref, ce := range sl.cache.entityPrev { + if _, ok := sl.cache.entityCur[ref]; ok { + continue // Entity still present + } + + // Entity disappeared - mark it dead + // The storage layer handles this by setting endTime + if err := app.MarkEntityDead(ref, ts); err != nil { + sl.l.Debug("Error marking entity dead", "ref", ref, "err", err) + } + + // Remove from cache + for hashKey, e := range sl.cache.entities { + if e.ref == ref { + delete(sl.cache.entities, hashKey) + break + } + } + } + + return nil +} + +func (c *scrapeCache) entityIterDone(flush bool) { + // Swap current and previous (same pattern as series) + c.entityPrev, c.entityCur = c.entityCur, c.entityPrev + clear(c.entityCur) +} +``` + +### Scrape Configuration + +New configuration options in `config/config.go`: + +```go +type ScrapeConfig struct { + // ... existing fields ... + + // EnableEntityScraping enables parsing of entity declarations. + // Default: false for backward compatibility. + EnableEntityScraping bool `yaml:"enable_entity_scraping,omitempty"` + + // EntityLimit is the maximum number of entities per scrape target. + // 0 means no limit. + EntityLimit int `yaml:"entity_limit,omitempty"` +} +``` + +### Data Flow Summary + +``` +┌───────────────────────────────────────────────────────────────────────────────┐ +│ Scrape Data Flow │ +└───────────────────────────────────────────────────────────────────────────────┘ + + Target /metrics Prometheus Scrape Loop + ┌─────────────────┐ ┌─────────────────────────────────────────┐ + │ # ENTITY_TYPE │ │ │ + │ # ENTITY_IDENT │ ──HTTP GET──► │ 1. Create Parser (textparse.New) │ + │ entity{...} │ │ │ + │ │ │ 2. Loop: p.Next() │ + │ # TYPE metric │ │ ├─ EntryEntityType → cache type │ + │ metric{...} 123 │ │ ├─ EntryEntityIdent → cache names │ + │ # EOF │ │ ├─ EntryEntity → processEntity() │ + └─────────────────┘ │ │ └─ app.AppendEntity() │ + │ ├─ EntrySeries → checkConflicts() │ + │ │ └─ app.Append() │ + │ └─ EntryHistogram → ... │ + │ │ + │ 3. updateStaleMarkers() │ + │ ├─ Series: Write StaleNaN │ + │ └─ Entities: app.MarkEntityDead() │ + │ │ + │ 4. app.Commit() │ + │ ├─ Write WAL records │ + │ ├─ Update Head structures │ + │ └─ Build correlation index │ + └─────────────────────────────────────────┘ + │ + ▼ + ┌─────────────────────────────────────────┐ + │ Storage (TSDB) │ + │ ┌─────────────┐ ┌─────────────────┐ │ + │ │ WAL Records │ │ Head Block │ │ + │ │ - Series │ │ - memSeries │ │ + │ │ - Samples │ │ - memEntity │ │ + │ │ - Entities │ │ - Correlation │ │ + │ └─────────────┘ │ Index │ │ + │ └─────────────────┘ │ + └─────────────────────────────────────────┘ +``` + +In the [storage](04-storage.md) document, we go over the correlation index, WAL and memEntity struct in greater details. + +--- + +## Related Documents + +- [01-context.md](./01-context.md) - Problem statement and motivation +- [03-service-discovery.md](./03-service-discovery.md) - How entities relate to Prometheus targets +- [04-storage.md](./04-storage.md) - How entities are stored in the TSDB +- [05-querying.md](./05-querying.md) - PromQL extensions for working with entities +- [06-web-ui-and-apis.md](./06-web-ui-and-apis.md) - How entities are displayed and accessed + +--- + +*This proposal is a work in progress. Feedback is welcome.* + diff --git a/proposals/0071-Entity/03-service-discovery.md b/proposals/0071-Entity/03-service-discovery.md new file mode 100644 index 00000000..a9b0eba3 --- /dev/null +++ b/proposals/0071-Entity/03-service-discovery.md @@ -0,0 +1,793 @@ +# Service Discovery and Entities + +## Abstract + +This document specifies how Prometheus Service Discovery (SD) integrates with the Entity concept introduced in this proposal. SD already collects rich metadata about scrape targets—metadata that naturally maps to entity labels. This document provides a comprehensive technical specification for deriving entities from SD metadata, including implementation details and resolution of the interaction between relabeling, entity generation, and metric correlation. + +The document also addresses **attribute mapping standards**—how `__meta_*` labels translate to entity type names and attribute names. Rather than prescribing a specific convention, this document presents the available options (OpenTelemetry semantic conventions, Prometheus-native conventions, etc.) and their trade-offs. Standardized, non-customizable mappings are essential for enabling ecosystem-wide interoperability; the specific convention choice is left as an open decision for the Prometheus community. + +Entities can come from two sources: the **exposition format** (embedded in scraped data) or **Service Discovery** (derived from target metadata). Each approach has trade-offs, and users choose based on their architecture. + +--- + +## Background: How Service Discovery Works + +### Discovery Manager Architecture + +The Discovery Manager (`discovery/manager.go`) coordinates all service discovery mechanisms: + +```go +type Manager struct { + // providers keeps track of SD providers + providers []*Provider + + // targets maps (setName, providerName) -> source -> TargetGroup + targets map[poolKey]map[string]*targetgroup.Group + + // syncCh sends updates to the scrape manager + syncCh chan map[string][]*targetgroup.Group +} +``` + +Each `Provider` wraps a `Discoverer` that implements: + +```go +type Discoverer interface { + // Run sends TargetGroups through the channel when changes occur + Run(ctx context.Context, up chan<- []*targetgroup.Group) +} +``` + +### Target Group Structure + +The fundamental unit of discovery is the `targetgroup.Group`: + +```go +// From discovery/targetgroup/targetgroup.go +type Group struct { + // Targets is a list of targets identified by a label set. + // Each target is uniquely identifiable by its address label. + Targets []model.LabelSet + + // Labels is a set of labels common across all targets in the group. + Labels model.LabelSet + + // Source is an identifier that describes this group of targets. + Source string +} +``` + +**Key insight**: SD mechanisms populate `__meta_*` labels into these `LabelSet` objects. These labels contain the raw metadata that will become entity attributes. + +### Label Flow: Discovery to Scrape + +The complete flow from discovery to metric labels: + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Service Discovery Flow │ +└─────────────────────────────────────────────────────────────────────────────┘ + + 1. DISCOVERY PHASE + ┌─────────────────────────────────────────────────────────────────────────┐ + │ Kubernetes API / AWS API / Consul / etc. │ + │ │ │ + │ ▼ │ + │ ┌─────────────────────────────────────────────────────────────────────┐ │ + │ │ Discoverer.Run() builds targetgroup.Group with: │ │ + │ │ │ │ + │ │ Targets[0] = { │ │ + │ │ __address__: "10.0.0.1:8080" │ │ + │ │ __meta_kubernetes_namespace: "production" │ │ + │ │ __meta_kubernetes_pod_name: "nginx-7b9f5" │ │ + │ │ __meta_kubernetes_pod_uid: "550e8400-e29b-..." │ │ + │ │ __meta_kubernetes_pod_node_name: "worker-1" │ │ + │ │ __meta_kubernetes_pod_phase: "Running" │ │ + │ │ ... │ │ + │ │ } │ │ + │ │ │ │ + │ │ Labels = { │ │ + │ │ __meta_kubernetes_namespace: "production" (group-level) │ │ + │ │ } │ │ + │ └─────────────────────────────────────────────────────────────────────┘ │ + └─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ + 2. SCRAPE MANAGER RECEIVES TARGET GROUPS + ┌─────────────────────────────────────────────────────────────────────────┐ + │ scrapePool.Sync(tgs []*targetgroup.Group) │ + │ │ │ + │ ▼ │ + │ TargetsFromGroup() → PopulateLabels() │ + └─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ + 3. LABEL POPULATION (scrape/target.go:PopulateLabels) + ┌─────────────────────────────────────────────────────────────────────────┐ + │ a) Merge target labels + group labels │ + │ b) Add scrape config defaults (job, __scheme__, __metrics_path__, etc.) │ + │ c) Apply relabel_configs │ + │ d) Delete all __meta_* labels │ + │ e) Default instance to __address__ │ + │ │ + │ Result: Target with final label set │ + │ {job="kubernetes-pods", instance="10.0.0.1:8080", namespace="prod"} │ + └─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ + 4. SCRAPE LOOP + ┌─────────────────────────────────────────────────────────────────────────┐ + │ HTTP GET target → Parse metrics → Apply metric_relabel_configs │ + │ → Append to storage with final labels │ + └─────────────────────────────────────────────────────────────────────────┘ +``` + +**Critical observation**: The `__meta_*` labels are deleted in step 3d. With entity support, we intercept these labels *before* deletion to generate entities. + +--- + +## Entity Sources + +Entities can originate from two sources, each suited to different deployment patterns: + +### Source 1: Service Discovery + +When Prometheus scrapes targets directly, SD metadata accurately describes the entity producing metrics: + +| SD Mechanism | What It Discovers | Entity It Can Generate | +|--------------|-------------------|------------------------| +| Kubernetes pod SD | Pods | `k8s.pod` | +| Kubernetes node SD | Nodes | `k8s.node` | +| Kubernetes service SD | Services | `k8s.service` | +| EC2 SD | EC2 instances | `host`, `cloud.instance` | +| Azure VM SD | Azure VMs | `host`, `cloud.instance` | +| GCE SD | GCE instances | `host`, `cloud.instance` | +| Consul SD | Services | `service` | + +**When to use**: Direct scraping where the target IS the entity. + +### Source 2: Exposition Format + +When metrics flow through intermediaries, SD sees the intermediary, not the actual sources: + +``` +┌───────────┐ ┌───────────┐ ┌───────────┐ +│ Service A │────▶│ OTel │◀─────▶│Prometheus │ +│ (pod-xyz) │push │ Collector │scrape │ │ +└───────────┘ │ │ │ SD sees: │ +┌───────────┐ │ (pod-abc) │ │ pod-abc │ +│ Service B │────▶│ │ │ │ +└───────────┘ └───────────┘ └───────────┘ + │ + Entity info must travel │ + WITH the metrics ─────────┘ +``` + +**When to use**: Gateways, federation, pushgateway, kube-state-metrics. + +See [01-context.md](./01-context.md#collection-architectures-direct-scraping-vs-gateways) for detailed use cases. + +--- + +## Configuration + +### ScrapeConfig Extension + +The `ScrapeConfig` struct in `config/config.go` is extended: + +```go +type ScrapeConfig struct { + // ... existing fields ... + + // EntityFromSD controls SD-derived entity generation. + // When true, Prometheus generates entities from __meta_* labels + // according to the built-in mappings for each SD type. + // Default: false (for backward compatibility) + EntityFromSD bool `yaml:"entity_from_sd,omitempty"` + + // EntityLimit is the maximum number of distinct entities per target. + // A single target may correlate with multiple entities (e.g., pod + node). + // 0 means no limit. + EntityLimit int `yaml:"entity_limit,omitempty"` +} +``` + +### Configuration Examples + +```yaml +scrape_configs: + # Direct scraping with entity generation enabled + - job_name: 'kubernetes-pods' + kubernetes_sd_configs: + - role: pod + entity_from_sd: true + + # Gateway pattern - entities come from exposition format + - job_name: 'otel-collector' + static_configs: + - targets: ['otel-collector:8889'] + entity_from_sd: false # Default + + # Federation - entities flow through metrics + - job_name: 'federate' + honor_labels: true + metrics_path: '/federate' + static_configs: + - targets: ['prometheus-regional:9090'] + entity_from_sd: false +``` + +--- + +## Attribute Mapping Standards + +A critical design decision for SD-derived entities is how `__meta_*` labels translate to entity type names and attribute names. This section outlines the requirements, available options, and trade-offs for establishing a mapping standard. + +### The Problem + +Service Discovery mechanisms produce `__meta_*` labels with provider-specific naming: + +``` +__meta_kubernetes_pod_uid +__meta_kubernetes_namespace +__meta_ec2_instance_id +__meta_azure_machine_id +``` + +These must be transformed into entity attributes. The key questions are: + +1. **Entity type names**: What should we call the entity? (`k8s.pod`? `kubernetes_pod`? `pod`?) +2. **Attribute names**: How should attributes be named? (`k8s.pod.uid`? `pod_uid`? `uid`?) +3. **Which labels become identifying vs. descriptive?** + +The answers to these questions affect: +- **Correlation**: Metrics and entities must share the same identifying label names and values +- **Interoperability**: Other systems querying Prometheus data need predictable attribute names +- **Ecosystem alignment**: Conventions should facilitate integration with dashboards, alerting, and other tools + +### Design Requirements + +Whatever convention is chosen, the mapping must satisfy these requirements: + +1. **Deterministic**: Given the same `__meta_*` labels, the resulting entity attributes must always be identical +2. **Complete**: All meaningful metadata should be captured—useful information should not be silently dropped +3. **Unambiguous**: Each `__meta_*` label maps to exactly one attribute; no conflicts or overlaps +4. **Stable**: Once established, mappings should not change without a clear migration path + +### Available Options + +#### Option 1: OpenTelemetry Semantic Conventions + +Adopt attribute names from [OpenTelemetry Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/), which define standardized names for resource attributes across the industry. + +**Example mappings:** + +| SD Label | OTel-style Entity Attribute | +|----------|----------------------------| +| `__meta_kubernetes_pod_uid` | `k8s.pod.uid` | +| `__meta_kubernetes_namespace` | `k8s.namespace.name` | +| `__meta_ec2_instance_id` | `host.id` | +| `__meta_ec2_instance_type` | `host.type` | +| `__meta_azure_machine_id` | `host.id` | +| `__meta_gce_project` | `cloud.account.id` | + +**Advantages:** +- Industry-wide standardization enables correlation across tools (Grafana, OTel Collector, etc.) +- Reduces cognitive load for teams already using OTel conventions +- Future-proofs Prometheus for deeper OTel integration +- Extensive documentation and community support + +**Disadvantages:** +- Not all conventions are stable; Kubernetes conventions are currently "Experimental" and may change +- Introduces dot-separated names (e.g., `k8s.pod.uid`) which differ from Prometheus's traditional underscore convention +- Requires Prometheus to track and potentially adapt to external convention changes + +**Stability considerations:** + +If OTel conventions are adopted, Prometheus should consider: +- Only adopting conventions that have reached **Stable** status +- For widely-used Experimental conventions (like Kubernetes), accepting the risk with clear user documentation +- Establishing a migration strategy for when conventions change + +#### Option 2: Prometheus-Native Conventions + +Define Prometheus-specific conventions that align with existing Prometheus naming patterns (lowercase, underscore-separated). + +**Example mappings:** + +| SD Label | Prometheus-style Entity Attribute | +|----------|----------------------------------| +| `__meta_kubernetes_pod_uid` | `kubernetes_pod_uid` | +| `__meta_kubernetes_namespace` | `kubernetes_namespace` | +| `__meta_ec2_instance_id` | `ec2_instance_id` | +| `__meta_ec2_instance_type` | `ec2_instance_type` | +| `__meta_azure_machine_id` | `azure_machine_id` | +| `__meta_gce_project` | `gce_project` | + +**Advantages:** +- Consistent with existing Prometheus label naming conventions +- Full control over naming without external dependencies +- No risk of upstream convention changes +- Simpler—direct transformation from `__meta_*` labels + +**Disadvantages:** +- No industry standardization; correlation with OTel-based systems requires translation +- Prometheus would need to define and maintain its own convention documentation +- May diverge from where the broader observability ecosystem is heading +- Less intuitive for teams already using OTel conventions + +#### Option 3: Minimal Transformation + +Strip the `__meta_` prefix and SD-type prefix, keeping attribute names close to the original. + +**Example mappings:** + +| SD Label | Minimal Entity Attribute | +|----------|-------------------------| +| `__meta_kubernetes_pod_uid` | `pod_uid` | +| `__meta_kubernetes_namespace` | `namespace` | +| `__meta_ec2_instance_id` | `instance_id` | +| `__meta_ec2_instance_type` | `instance_type` | +| `__meta_azure_machine_id` | `machine_id` | +| `__meta_gce_project` | `project` | + +**Advantages:** +- Simplest transformation logic +- Shortest attribute names +- Easy to understand and predict + +**Disadvantages:** +- No namespace to distinguish provider-specific attributes +- Poor interoperability with any external standard + +### Identifying vs. Descriptive Label Classification + +Beyond naming, each mapping must classify labels as **identifying** (immutable, define identity) or **descriptive** (mutable, provide context). This classification must be: + +1. **Consistent with the data source**: If the underlying resource uses a UID for identity, so should the entity +2. **Globally unique when combined**: Identifying labels together must uniquely identify one entity +3. **Stable over the entity's lifetime**: Identifying label values must not change + +### SD Mechanisms Without Entity Mappings + +The following SD mechanisms do not generate entities automatically because they lack sufficient metadata to construct meaningful entities: + +| SD Mechanism | Reason | +|--------------|--------| +| `static_configs` | No metadata—just addresses | +| `file_sd_configs` | User-defined, no standard schema | +| `http_sd_configs` | User-defined, no standard schema | +| `dns_sd_configs` | Only provides addresses | + +Users requiring entities from these sources should embed entity information in the exposition format (see [02-exposition-formats.md](./02-exposition-formats.md)). + +### Non-Customizable by Design + +**Attribute mappings are not user-configurable.** This is intentional: + +1. **Standardization requires consistency**: If every deployment uses different attribute names, the benefits of entities (correlation, interoperability, ecosystem tooling) are lost +2. **Ecosystem tooling depends on predictability**: Dashboards, alerting rules, and integrations assume specific attribute names +3. **Reduced cognitive load**: Users don't need to understand or maintain mapping configurations +4. **Simpler implementation**: No configuration parsing, validation, or per-scrape-config mapping logic + +Users who need different attribute names can transform data downstream (e.g., in recording rules or remote write pipelines), but the source of truth in Prometheus uses the standard mappings. + +### Open Decision + +This proposal does not prescribe which naming convention Prometheus should adopt. The choice between OTel alignment, Prometheus-native conventions, or another approach should be made by the Prometheus community based on: + +- Strategic direction for OTel integration +- Compatibility requirements with existing tooling +- Long-term maintenance considerations +- Community feedback + +The implementation will be straightforward once a convention is chosen—the technical complexity is in the entity infrastructure, not the naming. + +--- + +## Implementation Details + +### Entity Generation in the Scrape Pipeline + +Entity generation happens during target creation, before `__meta_*` labels are discarded: + +```go +// In scrape/target.go - modified PopulateLabels +func PopulateLabels(lb *labels.Builder, cfg *config.ScrapeConfig, + tLabels, tgLabels model.LabelSet) (labels.Labels, []*Entity, error) { + PopulateDiscoveredLabels(lb, cfg, tLabels, tgLabels) + + // NEW: Generate entities from __meta_* labels BEFORE relabeling + var entities []*Entity + if cfg.EntityFromSD { + entities = generateEntitiesFromMeta(lb, cfg) + } + + // Apply relabeling (existing behavior) + keep := relabel.ProcessBuilder(lb, cfg.RelabelConfigs...) + if !keep { + return labels.EmptyLabels(), nil, nil + } + + // ... rest of existing validation ... + + // Delete __meta_* labels (existing behavior) + lb.Range(func(l labels.Label) { + if strings.HasPrefix(l.Name, model.MetaLabelPrefix) { + lb.Del(l.Name) + } + }) + + // ... rest of existing code ... + + return res, entities, nil +} + +// generateEntitiesFromMeta extracts entities based on SD-specific mappings +func generateEntitiesFromMeta(lb *labels.Builder, cfg *config.ScrapeConfig) []*Entity { + var entities []*Entity + + // Detect SD type from __meta_* prefix + // Kubernetes: __meta_kubernetes_* + // EC2: __meta_ec2_* + // etc. + + if hasKubernetesLabels(lb) { + if entity := generateK8sPodEntity(lb); entity != nil { + entities = append(entities, entity) + } + if entity := generateK8sNodeEntity(lb); entity != nil { + entities = append(entities, entity) + } + // ... other K8s entity types + } + + if hasEC2Labels(lb) { + if entity := generateHostEntityFromEC2(lb); entity != nil { + entities = append(entities, entity) + } + } + + // ... other SD types + + return entities +} +``` + +### Target Structure Extension + +The `Target` struct is extended to hold generated entities: + +```go +// In scrape/target.go +type Target struct { + labels labels.Labels + scrapeConfig *config.ScrapeConfig + tLabels model.LabelSet + tgLabels model.LabelSet + + // NEW: Entities generated from SD metadata + sdEntities []*Entity + + // ... existing fields ... +} +``` + +### Entity Transmission to Storage + +When a target is scraped, its SD-derived entities are appended alongside metrics: + +```go +// In scrape/scrape.go - within scrapeLoop.append() +func (sl *scrapeLoop) append(app storage.Appender, b []byte, + contentType string, ts time.Time) (...) { + defTime := timestamp.FromTime(ts) + + // NEW: Append SD-derived entities for this target + if sl.sdEntities != nil { + for _, entity := range sl.sdEntities { + if _, err := app.AppendEntity( + entity.Type, + entity.IdentifyingLabels, + entity.DescriptiveLabels, + defTime, + ); err != nil { + sl.l.Debug("Error appending SD entity", "type", entity.Type, "err", err) + } + } + } + + // ... existing metric parsing and appending ... +} +``` + +### Data Flow Diagram + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Entity Generation Data Flow │ +└─────────────────────────────────────────────────────────────────────────────┘ + +┌───────────────┐ ┌───────────────┐ ┌───────────────┐ +│ Kubernetes │ │ EC2 │ │ Consul │ +│ API │ │ API │ │ API │ +└───────┬───────┘ └───────┬───────┘ └───────┬───────┘ + │ │ │ + ▼ ▼ ▼ +┌───────────────────────────────────────────────────────────────────────────┐ +│ Discovery Manager │ +│ ┌─────────────────────────────────────────────────────────────────────┐ │ +│ │ targetgroup.Group │ │ +│ │ Targets: [ { __meta_kubernetes_pod_uid: "abc", ... } ] │ │ +│ │ Labels: { __meta_kubernetes_namespace: "prod" } │ │ +│ └─────────────────────────────────────────────────────────────────────┘ │ +└───────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌───────────────────────────────────────────────────────────────────────────┐ +│ Scrape Manager │ +│ │ +│ scrapePool.Sync(tgs) → TargetsFromGroup() → PopulateLabels() │ +│ │ │ +│ ┌────────────────────┴────────────────────┐ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌─────────────────────────┐ ┌─────────────────────────┐ │ +│ │ Entity Generation │ │ Label Processing │ │ +│ │ (from __meta_* labels)│ │ (relabel_configs) │ │ +│ │ │ │ │ │ +│ │ IF entity_from_sd: │ │ 1. Apply relabel rules │ │ +│ │ Extract identifying │ │ 2. Delete __meta_* │ │ +│ │ Extract descriptive │ │ 3. Set instance default│ │ +│ │ Create Entity struct │ │ │ │ +│ └───────────┬─────────────┘ └──────────┬──────────────┘ │ +│ │ │ │ +│ │ ┌──────────────────────────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────────┐ │ +│ │ Target │ │ +│ │ │ │ +│ │ labels: { job="k8s-pods", instance="10.0.0.1:8080", ns="prod" } │ │ +│ │ │ │ +│ │ sdEntities: [ │ │ +│ │ Entity{ │ │ +│ │ type: "k8s.pod", │ │ +│ │ identifyingLabels: {namespace="prod", pod_uid="abc-123"} │ │ +│ │ descriptiveLabels: {pod_name="nginx", node_name="worker-1"} │ │ +│ │ } │ │ +│ │ ] │ │ +│ └─────────────────────────────────────────────────────────────────────┘ │ +└───────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌───────────────────────────────────────────────────────────────────────────┐ +│ Scrape Loop │ +│ │ +│ For each scrape: │ +│ 1. HTTP GET target │ +│ 2. Parse exposition format │ +│ 3. Extract exposition-format entities (if any) │ +│ 4. Merge SD entities + exposition entities │ +│ 5. app.AppendEntity() for each entity │ +│ 6. app.Append() for each metric (with correlation via shared labels) │ +│ 7. app.Commit() │ +└───────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌───────────────────────────────────────────────────────────────────────────┐ +│ Storage (TSDB) │ +│ │ +│ ┌─────────────────────┐ ┌─────────────────────┐ │ +│ │ Entity Storage │ │ Series Storage │ │ +│ │ │ │ │ │ +│ │ memEntity │◄──►│ memSeries │ │ +│ │ stripeEntities │ │ stripeSeries │ │ +│ │ EntityMemPostings │ │ postings │ │ +│ │ │ │ │ │ +│ │ Correlation Index │────┤ │ │ +│ └─────────────────────┘ └─────────────────────┘ │ +└───────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Relabeling and Entities + +This section specifies how relabeling interacts with entity generation. + +### Principle: Entities Are Generated Before Relabeling + +Entity generation uses the **raw** `__meta_*` labels before any relabeling is applied. This ensures: + +1. **Predictability**: Entity structure is consistent regardless of user relabeling rules +2. **Correctness**: Identifying labels match the actual resource identity +3. **Simplicity**: Users don't need to coordinate relabeling with entity generation + +### relabel_configs Do Not Affect Entity Labels + +```yaml +scrape_configs: + - job_name: 'kubernetes-pods' + kubernetes_sd_configs: + - role: pod + entity_from_sd: true + relabel_configs: + # This ONLY affects metric labels, NOT entity labels + - source_labels: [__meta_kubernetes_namespace] + target_label: ns # Metric label becomes "ns" + # Entity attribute uses the standard mapping (unchanged) +``` + +**Rationale**: Entity identifying labels are derived from `__meta_*` labels using the standard mapping, independent of `relabel_configs`. This ensures entity structure is predictable regardless of user relabeling rules. + +### metric_relabel_configs and Entity Labels + +`metric_relabel_configs` operates on metrics **after** they're scraped but **before** correlation happens. Entity-enriched labels (descriptive labels added during query) are **not** subject to `metric_relabel_configs`. + +```yaml +scrape_configs: + - job_name: 'kubernetes-pods' + entity_from_sd: true + metric_relabel_configs: + # This drops metrics, but entities remain + - source_labels: [__name__] + regex: 'go_.*' + action: drop +``` + +### honor_labels Interaction + +When `honor_labels: true`, labels from the scraped payload take precedence over target labels. This affects correlation: + +```yaml +scrape_configs: + - job_name: 'federate' + honor_labels: true + entity_from_sd: false # Entities come from federated metrics +``` + +If `entity_from_sd: true` with `honor_labels: true`: +- SD-derived entities are still generated +- Correlation uses the **final** metric labels (which may come from the payload) +- This could cause correlation mismatches if payload labels differ from SD labels + +**Recommendation**: When using `honor_labels: true`, set `entity_from_sd: false` and rely on exposition-format entities. + +--- + +## Conflict Resolution + +> **TODO**: This section needs further design work. When entities come from both SD and the exposition format for the same scrape, we need to define: +> - How to detect that two entities refer to the same resource +> - Whether to merge, prefer one source, or treat them as distinct +> - How to handle conflicting descriptive labels +> - Edge cases around timing and ordering +> +> This interacts with the exposition format design in [02-exposition-formats.md](./02-exposition-formats.md) and needs to be addressed holistically. + +--- + +## Entity Lifecycle with SD + +### Entity Creation + +An SD-derived entity is created when a target with matching `__meta_*` labels first appears in discovery. + +### Entity Updates + +When a target is re-discovered (on each SD refresh) and `entity_from_sd: true`: +1. Entity identifying labels are checked against existing entities +2. If entity exists, descriptive labels are compared +3. If descriptive labels changed, a new snapshot is recorded (see [04-storage.md](./04-storage.md)) + +### Entity Staleness + +When a target disappears from SD: + +1. **Immediate behavior**: The target's scrape loop is stopped +2. **Entity marking**: The SD-derived entity associated with that target receives an `endTime` timestamp +3. **Grace period**: Entities remain queryable for historical analysis + +**Implementation**: + +```go +// In scrape/scrape.go - when target is removed +func (sp *scrapePool) sync(targets []*Target) { + // ... existing target diff logic ... + + // For removed targets, mark their entities as potentially stale + for fingerprint, loop := range sp.loops { + if _, ok := uniqueLoops[fingerprint]; !ok { + // Target removed + if loop.sdEntities != nil { + for _, entity := range loop.sdEntities { + // Don't immediately mark dead - other targets might use same entity + sp.entityRefCounts[entity.Hash()]-- + if sp.entityRefCounts[entity.Hash()] == 0 { + // No more targets reference this entity + app.MarkEntityDead(entity.Ref, timestamp.FromTime(time.Now())) + } + } + } + loop.stop() + } + } +} +``` + +### Entity Deduplication + +Multiple targets may correlate with the same entity (e.g., multiple containers in a pod). The entity is only created once: + +```go +// Entity identity is determined by type + identifying labels +func entityHash(entityType string, identifyingLabels labels.Labels) uint64 { + h := fnv.New64a() + h.Write([]byte(entityType)) + identifyingLabels.Range(func(l labels.Label) { + h.Write([]byte(l.Name)) + h.Write([]byte(l.Value)) + }) + return h.Sum64() +} +``` + +When the same entity is discovered from multiple targets: +- First discovery creates the entity +- Subsequent discoveries update `lastSeen` timestamp +- Descriptive labels are merged (last write wins for conflicts) + +--- + +## Open Questions Resolved + +### Q: Entity deduplication across multiple discovery mechanisms + +**Answer**: Entities are deduplicated by their identifying labels. If Kubernetes pod SD and endpoints SD both discover the same pod, only one entity is stored. The entity's descriptive labels are updated from whichever source provides the most recent data. + +### Q: SD entity lifecycle when target disappears + +**Answer**: When the last target referencing an entity disappears from SD, the entity's `endTime` is set to the current timestamp. The entity remains in storage for historical queries until retention deletes it. + +## Open Questions + +### Q: Which naming convention should Prometheus adopt for entity attributes? + +This proposal presents the available options (OTel semantic conventions, Prometheus-native, minimal transformation) and their trade-offs, but does not prescribe a specific choice. The decision should be made by the Prometheus community considering: + +- Strategic alignment with OpenTelemetry +- Existing ecosystem tooling and dashboards +- Long-term maintenance burden +- Community preferences + +### Q: How should Prometheus handle OTel conventions that are not yet stable? + +If OTel semantic conventions are chosen, Prometheus must decide how to handle conventions that haven't reached "Stable" status (e.g., Kubernetes conventions are currently "Experimental"). Options include: + +1. **Strict stability requirement**: Only adopt stable conventions; define Prometheus-specific names for unstable areas +2. **Pragmatic adoption**: Adopt widely-used experimental conventions with clear documentation about potential future changes +3. **Hybrid approach**: Use stable OTel conventions where available, Prometheus-native names elsewhere + +### Q: Should entity types be namespaced by SD mechanism? + +When multiple SD mechanisms can discover similar resources (e.g., EC2, Azure, GCE all discover "hosts"), should entity types be: + +- **Generic**: `host` (requires merging semantics across providers) +- **Provider-specific**: `ec2.instance`, `azure.vm`, `gce.instance` (clearer provenance, no collision risk) +- **Hierarchical**: `host` with `cloud.provider` as an identifying label + +--- + +## Related Documents + +- [01-context.md](./01-context.md) - Problem statement, motivation, and use cases +- [02-exposition-formats.md](./02-exposition-formats.md) - How entities are represented in wire formats +- [04-storage.md](./04-storage.md) - How entities are stored in the TSDB +- [05-querying.md](./05-querying.md) - PromQL extensions for working with entities +- [06-web-ui-and-apis.md](./06-web-ui-and-apis.md) - How entities are displayed and accessed + +--- + +*This proposal is a work in progress. Feedback is welcome.* + diff --git a/proposals/0071-Entity/04-storage.md b/proposals/0071-Entity/04-storage.md new file mode 100644 index 00000000..6cbbd1a1 --- /dev/null +++ b/proposals/0071-Entity/04-storage.md @@ -0,0 +1,998 @@ +# Entity Storage + +> **Recommended Approach**: This document describes the correlation-based storage design, which we recommend for initial implementation due to its incremental nature and backward compatibility. An alternative design that fundamentally changes how series identity works is described in [04b-storage-entity-native.md](04b-storage-entity-native.md). + +## Abstract + +This document specifies how Prometheus stores entities reliably and efficiently. Entities represent the things that produce telemetry (pods, nodes, services) and need different storage semantics than traditional time series: they have immutable identifying labels, mutable descriptive labels that change over time, and lifecycle boundaries (creation and deletion). This document covers the in-memory structures, Write-Ahead Log integration, block persistence, and the correlation index that links entities to their associated metrics. + +## Background + +### Current Prometheus Storage Architecture + +Prometheus uses a time series database (TSDB) optimized for append-heavy workloads with the following key components: + +**Head Block**: The in-memory component that stores the most recent data. New samples are appended here first. The Head contains: +- `memSeries`: In-memory representation of each time series, holding recent samples in chunks +- `stripeSeries`: A sharded map for concurrent access to series by ID or label hash +- `MemPostings`: An inverted index mapping label name/value pairs to series references + +**Write-Ahead Log (WAL)**: Ensures durability by writing all incoming data to disk before acknowledging. On crash recovery, the WAL is replayed to reconstruct the Head. WAL records include: +- Series records (new series with their labels) +- Sample records (timestamp + value for a series) +- Metadata records (type, unit, help for metrics) +- Exemplar and histogram records + +**Persistent Blocks**: Periodically, the Head is compacted into immutable blocks stored on disk. Each block contains: +- Chunk files (compressed time series data) +- Index file (label index, postings lists, series metadata) +- Meta file (time range, stats) + +**Appender Interface**: The primary interface for writing data to storage: + +```go +type Appender interface { + Append(ref SeriesRef, l labels.Labels, t int64, v float64) (SeriesRef, error) + Commit() error + Rollback() error + // ... other methods for histograms, exemplars, metadata +} +``` + +The scrape loop uses Appender to write scraped metrics. Each scrape creates an Appender, appends all samples, then calls Commit() to atomically persist everything to the WAL. + +### Why Entities Need Different Storage + +Entities differ from time series in fundamental ways: + +| Aspect | Time Series | Entities | +|--------|-------------|----------| +| Identity | Labels (all mutable in theory) | Identifying labels (immutable) | +| Values | Numeric samples over time | String labels (descriptive) | +| Cardinality | High (many series per entity) | Lower (one entity, many series) | +| Lifecycle | Implicit (staleness) | Explicit (start/end timestamps) | +| Correlation | Self-contained | Links to multiple series | + +These differences motivate a dedicated storage approach rather than trying to fit entities into the existing series model. + +## Entity Data Model + +### The memEntity Structure + +Each entity in memory is represented by the following structure: + +```go +type memEntity struct { + // Immutable after creation - no lock needed for these fields + ref EntityRef // Unique identifier (uint64, auto-incrementing) + entityType string // e.g., "k8s.pod", "service", "k8s.node" + identifyingLabels labels.Labels // Immutable labels that define identity + + // Lifecycle timestamps + startTime int64 // When this entity incarnation was created + endTime int64 // When deleted (0 if still alive) + + // Mutable - requires lock + sync.Mutex + descriptiveSnapshots []labelSnapshot // Historical descriptive labels + lastSeen int64 // Last scrape timestamp (for staleness checking) +} + +type labelSnapshot struct { + timestamp int64 + labels labels.Labels +} +``` + +### Identifying vs Descriptive Labels + +**Identifying Labels** define what an entity *is*. They are immutable for the lifetime of an entity incarnation: + +``` +Entity Type: k8s.pod +Identifying Labels: + - k8s.namespace.name = "production" + - k8s.pod.uid = "550e8400-e29b-41d4-a716-446655440000" +``` + +Two entities with the same identifying labels are considered the same entity (within their lifecycle bounds). + +**Descriptive Labels** provide additional context that may change over time: + +``` +Descriptive Labels (at t1): + - k8s.pod.name = "nginx-7b9f5" + - k8s.node.name = "worker-1" + - k8s.pod.status = "Running" + +Descriptive Labels (at t2, pod migrated): + - k8s.pod.name = "nginx-7b9f5" + - k8s.node.name = "worker-2" ← changed + - k8s.pod.status = "Running" +``` + +### Snapshot Storage for Descriptive Labels + +Descriptive labels are stored as complete snapshots at each change point. When new descriptive labels arrive: + +1. Compare with the most recent snapshot +2. If different, append a new snapshot with current timestamp +3. If identical, update `lastSeen` but don't create new snapshot + +``` +descriptiveSnapshots: [ + { t1, {name="nginx-7b9f5", node="worker-1", status="Running"} }, + { t5, {name="nginx-7b9f5", node="worker-2", status="Running"} }, // node changed + { t9, {name="nginx-7b9f5", node="worker-2", status="Terminating"} }, // status changed +] +``` + +**Why snapshots instead of an event log?** + +An event log (storing only deltas) would save storage space but impose query-time costs. To answer "what were the descriptive labels at time T?", a query would need to: +1. Find all change events before T +2. Replay them to reconstruct the state + +With snapshots, the query simply finds the latest snapshot where `snapshot.timestamp <= T`. + +### Entity Lifecycle + +Each entity has explicit lifecycle boundaries: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Entity Lifecycle │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ startTime endTime │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ Entity is "alive" │ │ +│ │ - Correlates with metrics in this time range │ │ +│ │ - Descriptive labels tracked │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ │ +│ Before startTime: Entity doesn't exist │ +│ After endTime: Entity is "dead" (historical only) │ +│ endTime == 0: Entity is currently alive │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +**Entity Staleness** + +An entity's `endTime` is determined by staleness, similar to series staleness: +- Each scrape updates `lastSeen` timestamp +- If `now - lastSeen > staleness_threshold`, entity is marked dead +- `endTime` is set to `lastSeen + staleness_threshold` + +**Entity Reincarnation** + +The same identifying labels can appear again after an entity ends: + +``` +Timeline: + t1: Entity A created (ref=1, identifying={pod.uid="abc"}, startTime=t1) + t5: Entity A deleted (ref=1, endTime=t5) + t10: Entity B created (ref=2, identifying={pod.uid="abc"}, startTime=t10) +``` + +Entity A and Entity B have the same identifying labels but different EntityRefs and non-overlapping lifecycles. At any point in time, at most one entity with a given set of identifying labels should be alive. + +## Storage Components + +### In-Memory Structures + +#### Entity Storage in Head + +The Head block is extended with entity storage: + +```go +type Head struct { + // ... existing fields ... + + // Entity storage + entities *stripeEntities // All entities by ref or identifying attrs hash + entityPostings *EntityMemPostings // Inverted index for entity labels + + // Correlation index + seriesToEntities map[HeadSeriesRef][]EntityRef + entitiesToSeries map[EntityRef][]HeadSeriesRef + correlationMtx sync.RWMutex + + lastEntityID atomic.Uint64 // For generating EntityRefs +} +``` + +#### stripeEntities + +Similar to `stripeSeries`, provides sharded concurrent access to entities: + +```go +type stripeEntities struct { + size int + series []map[EntityRef]*memEntity + hashes []map[uint64][]*memEntity // hash(identifyingAttrs) -> entities + locks []sync.RWMutex +} + +// Get entity by ref +func (s *stripeEntities) getByRef(ref EntityRef) *memEntity + +// Get entity by identifying labels (may return multiple for historical) +func (s *stripeEntities) getByIdentifyingLabels(hash uint64, lbls labels.Labels) []*memEntity + +func (s *stripeEntities) getAliveByIdentifyingLabels(hash uint64, lbls labels.Labels) *memEntity +``` + +#### EntityMemPostings + +An inverted index mapping label name/value pairs to entity references: + +```go +type EntityMemPostings struct { + mtx sync.RWMutex + m map[string]map[string][]EntityRef // label name -> label value -> entity refs +} + +// Example contents: +// "k8s.namespace.name" -> "production" -> [EntityRef(1), EntityRef(5), EntityRef(12)] +// "k8s.node.name" -> "worker-1" -> [EntityRef(1), EntityRef(3)] +``` + +This enables efficient lookups like "find all entities in namespace X" or "find all entities on node Y". + +#### Correlation Index + +The correlation index maintains the many-to-many relationship between series and entities: + +```go +// Series -> Entities: "which entities does this series correlate with?" +seriesToEntities map[HeadSeriesRef][]EntityRef + +// Entities -> Series: "which series are associated with this entity?" +entitiesToSeries map[EntityRef][]HeadSeriesRef +``` + +**Building the correlation at ingestion time:** + +When a new series is created: +``` +series.labels = {__name__="container_cpu", k8s.namespace.name="prod", k8s.pod.uid="abc", k8s.node.uid="xyz"} + +For each registered entity type: + k8s.pod: requires {k8s.namespace.name, k8s.pod.uid} + → series has both → find entity with these identifying attrs + → if found and alive: add to correlation index + + k8s.node: requires {k8s.node.uid} + → series has this → find entity with this identifying attr + → if found and alive: add to correlation index + +Result: seriesToEntities[series.ref] = [podEntityRef, nodeEntityRef] +``` + +When a new entity is created: +``` +entity.identifyingAttrs = {k8s.namespace.name="prod", k8s.pod.uid="abc"} + +Find all series whose labels contain ALL of entity's identifying attrs: + → Use postings index: intersect(postings["k8s.namespace.name"]["prod"], + postings["k8s.pod.uid"]["abc"]) + → For each matching series: add to correlation index +``` + +**Correlation and Entity Lifecycle** + +When an entity becomes stale (endTime set), it remains in the correlation index. This preserves historical correlations for queries over past time ranges. The query layer filters based on timestamp overlap between the query range and entity lifecycle. + +### Write-Ahead Log + +#### New WAL Record Type + +A single new record type captures all entity state: + +```go +const ( + // ... existing types ... + Entity Type = 11 // Entity record +) + +type RefEntity struct { + Ref EntityRef + EntityType string + IdentifyingLabels []labels.Label + DescriptiveLabels []labels.Label + StartTime int64 + EndTime int64 // 0 if alive + Timestamp int64 // When this record was written +} +``` + +#### Record Encoding + +Entity records follow the same encoding pattern as other WAL records: + +``` +┌───────────┬──────────┬────────────┬──────────────┐ +│ type <1b> │ len <2b> │ CRC32 <4b> │ data │ +└───────────┴──────────┴────────────┴──────────────┘ +``` + +The data section for an Entity record: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Entity Record Data │ +├─────────────────────────────────────────────────────────────────────┤ +│ ref <8b, big-endian> │ +│ entityType │ +│ numIdentifyingLabels │ +│ ┌─ name │ +│ └─ value │ +│ ... repeated for each identifying label │ +│ numDescriptiveLabels │ +│ ┌─ name │ +│ └─ value │ +│ ... repeated for each descriptive label │ +│ startTime │ +│ endTime │ +│ timestamp │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +#### When Entity Records Are Written + +Entity records are written to WAL in these situations: + +1. **New entity created**: Full record with startTime set, endTime=0 +2. **Descriptive labels changed**: Full record with updated labels and new timestamp +3. **Entity marked dead**: Full record with endTime set + +Writing full records (not deltas) simplifies replay and allows any single record to fully describe entity state at that point. + +#### WAL Replay Behavior + +On startup, entity records are replayed to reconstruct the Head's entity state: + +```go +func (h *Head) replayEntityRecord(rec RefEntity) error { + existing := h.entities.getByRef(rec.Ref) + + if existing == nil { + // New entity - create it + entity := &memEntity{ + ref: rec.Ref, + entityType: rec.EntityType, + identifyingLabels: rec.IdentifyingLabels, + startTime: rec.StartTime, + endTime: rec.EndTime, + } + if len(rec.DescriptiveLabels) > 0 { + entity.descriptiveSnapshots = []labelSnapshot{ + {timestamp: rec.Timestamp, labels: rec.DescriptiveLabels}, + } + } + h.entities.set(entity) + } else { + // Update existing entity + existing.Lock() + existing.endTime = rec.EndTime + if len(rec.DescriptiveLabels) > 0 { + // Check if labels changed from last snapshot + if shouldAddSnapshot(existing, rec.DescriptiveLabels) { + existing.descriptiveSnapshots = append( + existing.descriptiveSnapshots, + labelSnapshot{timestamp: rec.Timestamp, labels: rec.DescriptiveLabels}, + ) + } + } + existing.Unlock() + } + + // Update lastEntityID if needed + if uint64(rec.Ref) > h.lastEntityID.Load() { + h.lastEntityID.Store(uint64(rec.Ref)) + } + + return nil +} +``` + +The correlation index is rebuilt after all WAL records are replayed, by iterating all entities and series and computing correlations. + +### Block Persistence + +When the Head is compacted into a persistent block, entities must also be persisted. + +#### Entity Index in Blocks + +Each block includes an entity index alongside the existing series index: + +``` +Block Directory Structure: + block-ulid/ + ├── chunks/ # Chunk files (existing) + ├── index # Series index (existing) + ├── entities # Entity index (new) + ├── meta.json # Block metadata (extended) + └── tombstones # Deletion markers (existing) +``` + +The entity index file structure: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Entity Index File │ +├─────────────────────────────────────────────────────────────────────┤ +│ Magic Number (4 bytes) │ +│ Version (1 byte) │ +├─────────────────────────────────────────────────────────────────────┤ +│ Symbol Table │ +│ - All unique strings (entity types, attr names, attr values) │ +├─────────────────────────────────────────────────────────────────────┤ +│ Entity Table │ +│ For each entity: │ +│ - EntityRef │ +│ - EntityType (symbol ref) │ +│ - IdentifyingLabels (symbol ref pairs) │ +│ - StartTime, EndTime │ +│ - DescriptiveSnapshots offset (pointer to snapshots section) │ +├─────────────────────────────────────────────────────────────────────┤ +│ Descriptive Snapshots Section │ +│ For each entity's snapshots: │ +│ - Number of snapshots │ +│ - For each snapshot: timestamp, labels (symbol ref pairs) │ +├─────────────────────────────────────────────────────────────────────┤ +│ Entity Postings │ +│ - Inverted index: (label_name, label_value) -> [EntityRefs] │ +├─────────────────────────────────────────────────────────────────────┤ +│ Table of Contents │ +│ CRC32 │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +#### Compaction Behavior + +During compaction: + +1. **Entity Selection**: Include entities whose lifecycle overlaps with the block's time range + ``` + Include entity if: entity.startTime < block.maxTime AND + (entity.endTime == 0 OR entity.endTime > block.minTime) + ``` + +2. **Snapshot Filtering**: Only include descriptive snapshots within the block's time range + +3. **Deduplication**: If compacting multiple blocks, entities with the same EntityRef are merged, keeping all unique snapshots + +#### Entity Retention + +Entities follow the same retention policy as series data. Prometheus deletes blocks based on `RetentionDuration` (time-based) or `MaxBytes` (size-based). When blocks are deleted, entities are handled as follows: + +**Retention Rule**: An entity persists as long as **any block overlapping its lifecycle** exists. + +``` +Block Timeline: + Block 1 Block 2 Block 3 Block 4 + [t0, t1] [t1, t2] [t2, t3] [t3, t4] + +Entity A: ████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + startTime=t0 endTime=t1.5 + (lifecycle spans Block 1 and Block 2) + +Entity B: ░░░░░░░░░░░░████████████████████████████████ + startTime=t1.2 endTime=0 (still alive) + (lifecycle spans Block 2, Block 3, Block 4, Head) + +When Block 1 and Block 2 are deleted due to retention: +- Entity A is deleted (no remaining blocks contain its lifecycle) +- Entity B persists (Block 3, Block 4, Head still overlap its lifecycle) +``` + +This ensures historical queries can always resolve entity correlations for the data that remains. + +#### Head Entity Garbage Collection + +The Head block periodically runs garbage collection to remove entities that are no longer needed in memory. This mirrors how series GC works in `Head.gc()`. + +**GC Eligibility**: An entity in the Head is eligible for garbage collection when: +1. The entity is dead (`endTime != 0`), AND +2. The entity's entire lifecycle is before `Head.MinTime()` (fully compacted to blocks) + +```go +func (h *Head) gcEntities() map[EntityRef]struct{} { + mint := h.MinTime() + deleted := make(map[EntityRef]struct{}) + + h.entities.iter(func(entity *memEntity) { + // Only consider dead entities + if entity.endTime == 0 { + return // Still alive, keep in Head + } + + // If the entity's entire lifecycle is before Head's minTime, + // it has been fully compacted to blocks and can be removed + if entity.endTime < mint { + deleted[entity.ref] = struct{}{} + } + }) + + // Remove from entity storage + for ref := range deleted { + entity := h.entities.getByRef(ref) + h.entities.delete(ref) + h.entityPostings.Delete(ref, entity.identifyingLabels) + } + + // Clean up correlation index + h.correlationMtx.Lock() + for ref := range deleted { + // Remove entity from all series correlations + for _, seriesRef := range h.entitiesToSeries[ref] { + h.seriesToEntities[seriesRef] = removeEntityRef( + h.seriesToEntities[seriesRef], ref) + } + delete(h.entitiesToSeries, ref) + } + h.correlationMtx.Unlock() + + return deleted +} +``` + +**Integration with Head.gc()**: Entity GC runs alongside series GC during `truncateMemory()`: + +```go +func (h *Head) truncateSeriesAndChunkDiskMapper(caller string) error { + // ... existing series GC ... + actualInOrderMint, minOOOTime, minMmapFile := h.gc() + + // Entity GC + deletedEntities := h.gcEntities() + h.metrics.entitiesRemoved.Add(float64(len(deletedEntities))) + + // ... rest of truncation ... +} +``` + +## Ingestion Flow + +### Extended Appender Interface + +The Appender interface is extended to support entity ingestion: + +```go +type Appender interface { + // ... existing methods ... + + // AppendEntity adds or updates an entity. + // Returns the EntityRef (existing or newly assigned). + AppendEntity( + entityType string, + identifyingAttrs labels.Labels, + descriptiveAttrs labels.Labels, + timestamp int64, + ) (EntityRef, error) +} +``` + +### headAppender Implementation + +```go +func (a *headAppender) AppendEntity( + entityType string, + identifyingAttrs labels.Labels, + descriptiveAttrs labels.Labels, + timestamp int64, +) (EntityRef, error) { + + // Validate inputs + if entityType == "" { + return 0, fmt.Errorf("entity type cannot be empty") + } + if len(identifyingLabels) == 0 { + return 0, fmt.Errorf("identifying labels cannot be empty") + } + + // Sort labels for consistent hashing + sort.Sort(identifyingLabels) + sort.Sort(descriptiveLabels) + + hash := identifyingLabels.Hash() + + // Check for existing alive entity + entity := a.head.entities.getAliveByIdentifyingLabels(hash, identifyingLabels) + + if entity == nil { + // Create new entity + ref := EntityRef(a.head.lastEntityID.Inc()) + entity = &memEntity{ + ref: ref, + entityType: entityType, + identifyingLabels: identifyingLabels, + startTime: timestamp, + endTime: 0, + lastSeen: timestamp, + } + + if len(descriptiveLabels) > 0 { + entity.descriptiveSnapshots = []labelSnapshot{ + {timestamp: timestamp, labels: descriptiveLabels}, + } + } + + // Stage for commit + a.pendingEntities = append(a.pendingEntities, entity) + a.pendingEntityRecords = append(a.pendingEntityRecords, RefEntity{ + Ref: ref, + EntityType: entityType, + IdentifyingLabels: identifyingLabels, + DescriptiveLabels: descriptiveLabels, + StartTime: timestamp, + EndTime: 0, + Timestamp: timestamp, + }) + + return ref, nil + } + + // Update existing entity + entity.Lock() + entity.lastSeen = timestamp + + // Check if descriptive labels changed + needsSnapshot := false + if len(entity.descriptiveSnapshots) == 0 { + needsSnapshot = len(descriptiveLabels) > 0 + } else { + lastSnapshot := entity.descriptiveSnapshots[len(entity.descriptiveSnapshots)-1] + needsSnapshot = !labels.Equal(lastSnapshot.labels, descriptiveLabels) + } + + if needsSnapshot { + entity.descriptiveSnapshots = append(entity.descriptiveSnapshots, labelSnapshot{ + timestamp: timestamp, + labels: descriptiveLabels, + }) + + // Stage WAL record for changed labels + a.pendingEntityRecords = append(a.pendingEntityRecords, RefEntity{ + Ref: entity.ref, + EntityType: entity.entityType, + IdentifyingLabels: entity.identifyingLabels, + DescriptiveLabels: descriptiveLabels, + StartTime: entity.startTime, + EndTime: 0, + Timestamp: timestamp, + }) + } + + entity.Unlock() + return entity.ref, nil +} +``` + +### Commit and Rollback + +**Commit** persists all pending entities to WAL and updates indexes: + +```go +func (a *headAppender) Commit() error { + // ... existing commit logic for samples ... + + // Write entity records to WAL + if len(a.pendingEntityRecords) > 0 { + if err := a.logEntities(); err != nil { + return err + } + } + + // Add new entities to Head + for _, entity := range a.pendingEntities { + a.head.entities.set(entity) + a.head.entityPostings.Add(entity.ref, entity.identifyingLabels) + + // Build correlations with existing series + a.head.buildEntityCorrelations(entity) + } + + // Clear pending state + a.pendingEntities = a.pendingEntities[:0] + a.pendingEntityRecords = a.pendingEntityRecords[:0] + + return nil +} +``` + +**Rollback** discards all pending changes: + +```go +func (a *headAppender) Rollback() error { + // ... existing rollback logic ... + + // Simply discard pending entities - they were never added to Head + a.pendingEntities = a.pendingEntities[:0] + a.pendingEntityRecords = a.pendingEntityRecords[:0] + + return nil +} +``` + +### Correlation Index Updates + +When building correlations for a new entity: + +```go +func (h *Head) buildEntityCorrelations(entity *memEntity) { + // Find all series that have ALL of the entity's identifying labels + var postingsLists []Postings + + entity.identifyingLabels.Range(func(l labels.Label) { + postingsLists = append(postingsLists, h.postings.Get(l.Name, l.Value)) + }) + + // Intersect all postings lists + intersection := Intersect(postingsLists...) + + h.correlationMtx.Lock() + defer h.correlationMtx.Unlock() + + for intersection.Next() { + seriesRef := intersection.At() + + // Add bidirectional correlation + h.seriesToEntities[seriesRef] = append(h.seriesToEntities[seriesRef], entity.ref) + h.entitiesToSeries[entity.ref] = append(h.entitiesToSeries[entity.ref], seriesRef) + } +} +``` + +When a new series is created, correlations are built similarly by finding all alive entities whose identifying labels are a subset of the series labels. + +## Query Support + +This section provides an overview of how storage exposes entities for queries. Detailed query semantics are covered in the Querying document. + +### Storage Query Interface + +```go +type EntityQuerier interface { + // Get entity by ref + Entity(ref EntityRef) (*Entity, error) + + // Find entities by type and/or labels + Entities(ctx context.Context, entityType string, matchers ...*labels.Matcher) (EntitySet, error) + + // Get entities correlated with a series at a specific time + EntitiesForSeries(seriesRef SeriesRef, timestamp int64) ([]EntityRef, error) + + // Get series correlated with an entity + SeriesForEntity(entityRef EntityRef) ([]SeriesRef, error) + + // Get descriptive labels at a point in time + DescriptiveLabelsAt(entityRef EntityRef, timestamp int64) (labels.Labels, error) +} +``` + +### Time-Range Filtering + +Queries specify a time range `[mint, maxt]`. Entity results are filtered by lifecycle: + +```go +func (e *memEntity) isAliveAt(t int64) bool { + return e.startTime <= t && (e.endTime == 0 || e.endTime > t) +} + +func (e *memEntity) overlapsRange(mint, maxt int64) bool { + return e.startTime < maxt && (e.endTime == 0 || e.endTime > mint) +} +``` + +### Descriptive Label Lookup + +To get descriptive labels at a specific timestamp: + +```go +func (e *memEntity) descriptiveLabelsAt(t int64) labels.Labels { + if !e.isAliveAt(t) { + return labels.EmptyLabels() + } + + snapshots := e.descriptiveSnapshots + if len(snapshots) == 0 { + return labels.EmptyLabels() + } + + // Binary search: find the first snapshot where timestamp > t + // Then the snapshot we want is at index i-1 + i := sort.Search(len(snapshots), func(i int) bool { + return snapshots[i].timestamp > t + }) + + if i == 0 { + // All snapshots are after time t + return labels.EmptyLabels() + } + + return snapshots[i-1].labels +} +``` + +## Remote Write Considerations + +Entities need to be transmitted over Prometheus remote write protocol. This requires extending the protobuf definitions: + +```protobuf +message EntityWriteRequest { + repeated Entity entities = 1; +} + +message Entity { + string entity_type = 1; + repeated Label identifying_labels = 2; + repeated Label descriptive_labels = 3; + int64 start_time_ms = 4; + int64 end_time_ms = 5; // 0 if alive + int64 timestamp_ms = 6; // When this state was observed +} +``` + +Key considerations for remote write: + +1. **Incremental Updates**: Only send entity records when state changes (new entity, attrs changed, entity died) +2. **Receiver Reconciliation**: Receivers must handle out-of-order entity records and merge appropriately +3. **Correlation Rebuild**: Receivers rebuild correlation indexes locally based on their series data + +Detailed remote write protocol changes are specified in a separate document. + +## Trade-offs and Design Decisions + +### Separate Entity Storage vs Embedding in Series + +**Decision**: Separate storage structure for entities + +**Rationale**: +- Entities have different access patterns (lookup by identifying labels vs. time-range queries) +- Many-to-many relationship with series doesn't fit the one-to-one series model +- Entity lifecycle (explicit start/end) differs from series staleness +- Descriptive labels are string-valued, not numeric samples + +**Trade-off**: Additional complexity in storage layer, but cleaner semantics and better query performance. + +### Snapshots vs Event Log for Descriptive Labels + +**Decision**: Store complete snapshots at each change point + +**Rationale**: +- Point-in-time queries are common ("what was this pod's node at time T?") +- Snapshots enable O(log n) lookup via binary search +- Event log would require O(n) replay to reconstruct state +- Descriptive labels change infrequently, limiting snapshot count + +**Trade-off**: Higher storage per change, but faster queries and simpler implementation. + +### Correlation at Ingestion Time vs Query Time + +**Decision**: Build correlation index at ingestion time + +**Rationale**: +- Queries should be fast; correlation lookup is O(1) with pre-built index +- Ingestion can afford extra work; it's already doing label processing +- Correlation relationships are stable (based on immutable identifying labels) + +**Trade-off**: Ingestion overhead for maintaining correlation index, but significantly faster queries. + +### Single WAL Record Type vs Multiple + +**Decision**: Single comprehensive entity record type + +**Rationale**: +- Simplifies WAL encoding/decoding logic +- Any single record fully describes entity state (no partial records) +- Replay is straightforward—each record is self-contained +- Matches pattern used for series (full labels in each Series record) + +**Trade-off**: Slightly larger WAL records, but simpler and more robust. + +## Open Questions / Future Work + +### Retention Alignment + +How exactly should entity retention align with block retention? +- Current proposal: entities persist while any block containing their lifecycle exists +- May need refinement based on operational experience + +### Memory Management + +Long-running Prometheus instances may accumulate many historical entities: +- Consider memory-mapped entity storage for historical entities +- Investigate entity compaction/summarization for very old data + +### Federation and Multi-Prometheus + +When multiple Prometheus instances scrape the same entities: +- Entity deduplication across instances +- Consistent EntityRef assignment (or ref translation) +- Correlation index consistency + +### Entity Type Registry + +Should Prometheus maintain a registry of known entity types with their identifying label schemas? +- Would enable validation at ingestion time +- Could optimize correlation index building +- Trade-off: flexibility vs. consistency + +--- + +## TODO: Memory and WAL Replay Performance + +This section requires further investigation and benchmarking: + +### Memory Concerns + +- **Entity memory footprint estimation**: We need to quantify the memory cost per entity, including the `memEntity` struct, descriptive snapshots, and correlation index entries. This will help users estimate memory requirements based on expected entity counts. + +- **Impact on existing memory settings**: How do entity storage requirements interact with `--storage.tsdb.head-chunks-*` and other memory-related flags? Should there be dedicated entity memory limits? + +- **Memory-mapped entity storage**: For Prometheus instances with very long uptimes and high entity churn, historical entities may accumulate. Investigate whether memory-mapping historical entities (similar to mmapped chunks) could reduce memory pressure. + +- **Correlation index memory scaling**: The bidirectional correlation maps (`seriesToEntities` and `entitiesToSeries`) could become large with high series and entity counts. Consider more memory-efficient data structures (e.g., roaring bitmaps) if benchmarks show this is a bottleneck. + +### WAL Replay Performance + +- **Correlation index rebuild time**: The current proposal rebuilds the correlation index after WAL replay by iterating all entities and series. For large Prometheus instances (millions of series, thousands of entities), this could significantly increase startup time. + +- **Incremental correlation during replay**: Instead of rebuilding correlations after replay, could we store correlation state in the WAL or maintain it incrementally during replay? This would trade WAL size for faster startup. + +- **Checkpointing correlation state**: Consider extending WAL checkpointing to include entity and correlation state, reducing the amount of replay needed on restart. + +- **Benchmark targets**: We should establish performance targets (e.g., "WAL replay should not increase by more than 10% with 10,000 entities") and validate them through benchmarks. + +These topics need benchmarking with realistic workloads before finalizing the implementation approach. + +--- + +## TODO: Columnar Storage Strategies + +This section outlines potential optimizations for entity label storage that warrant further exploration: + +### Background + +Descriptive labels are fundamentally different from time series samples: +- They are **string-valued**, not numeric +- They change **infrequently** (entity metadata doesn't update every scrape) +- They are often **queried together** (users typically want all labels of an entity, not just one) +- They benefit from **compression** due to repetitive patterns (many pods have similar labels) + +These characteristics suggest that columnar storage techniques, commonly used in analytical databases, might offer significant benefits. + +### Areas to Explore + +- **Column-oriented label storage**: Instead of storing all labels for a snapshot together (row-oriented), store each label name as a column with its values across entities. This could improve compression and enable efficient filtering by specific labels. + +- **Dictionary encoding**: Entity labels often have low cardinality (e.g., `k8s.pod.status.phase` has only a few possible values). Dictionary encoding could dramatically reduce storage for descriptive labels. + +- **Run-length encoding for temporal data**: When descriptive labels don't change across many snapshots, run-length encoding could eliminate redundant storage. + +- **Label projection pushdown**: When queries only need specific entity labels (e.g., `sum by (k8s.node.name)`), the storage layer could avoid reading unnecessary labels. + +- **Separate label storage files**: Similar to how chunks are stored separately from the index, entity labels could have dedicated storage with format optimized for their access patterns. + +### Trade-offs to Consider + +- Implementation complexity vs. storage/query benefits +- Read vs. write optimization (columnar is typically better for reads) +- Memory overhead of maintaining multiple storage formats +- Compatibility with existing TSDB compaction and retention logic + +This is a potential future optimization and not required for the initial implementation. + +--- + +## What's Next + +- [Querying](05-querying.md): How PromQL is extended to query entities and correlations +- [Web UI and APIs](06-web-ui-and-apis.md): HTTP API endpoints and UI for entity exploration + diff --git a/proposals/0071-Entity/04b-storage-entity-native.md b/proposals/0071-Entity/04b-storage-entity-native.md new file mode 100644 index 00000000..0eeaa790 --- /dev/null +++ b/proposals/0071-Entity/04b-storage-entity-native.md @@ -0,0 +1,934 @@ +# Storage Design: Entity-Native Model + +> **Alternative Approach**: This document describes an alternative storage design where series identity is based only on metric labels, with samples grouped into "streams" by entity. While this approach offers stronger alignment with OpenTelemetry's data model and addresses cardinality at a fundamental level, it requires significant changes to Prometheus's core architecture. We recommend the correlation-based approach described in [04-storage.md](04-storage.md) for initial implementation, as it can be built incrementally on the existing TSDB without breaking backward compatibility. This entity-native design remains valuable as a potential future evolution once entities prove their value in production. + +## Executive Summary + +This document proposes a fundamental redesign of Prometheus's storage model to natively support Entities as first-class concepts, separate from metric identity. The key insight is that **metric identity** (what is being measured) should be separate from **entity identity** (what is being measured about). + +### Core Idea + +``` +Series: + labels: {__name__="http_requests_total", method="GET", status="200"} # metric labels only + data: [ + { + entityRefs: [podRef1, nodeRef1, serviceRef1], + samples: [{t: 1000, v: 100}, {t: 1015, v: 120}] + }, + { + entityRefs: [podRef2, nodeRef3], + samples: [{t: 1000, v: 1020}, {t: 1015, v: 1203}] + } + ] +``` + +This model separates: +- **What** is being measured → Series labels (metric name + metric-specific labels) +- **About what** it's being measured → Entity references (linking to entity storage) + +--- + +## Part 1: Current TSDB Model (Reference) + +Before diving into the new model, let's understand the current Prometheus TSDB architecture. + +### Current Series Identity + +In the current model, a series is uniquely identified by its **complete label set**: + +```go +type memSeries struct { + ref chunks.HeadSeriesRef // Unique identifier (auto-incrementing) + lset labels.Labels // Complete label set (includes ALL labels) + headChunks *memChunk // In-memory samples + mmappedChunks []*mmappedChunk // Memory-mapped chunks on disk + // ... +} +``` + +**Example:** These are THREE different series in current Prometheus: +``` +http_requests_total{method="GET", status="200", pod="nginx-abc"} # Series 1 +http_requests_total{method="GET", status="200", pod="nginx-def"} # Series 2 +http_requests_total{method="GET", status="200", pod="nginx-xyz"} # Series 3 +``` + +### Current Flow + +``` +Scrape → Labels → Hash(Labels) → getOrCreate(hash, labels) → memSeries → Append Sample +``` + +The hash of ALL labels determines series identity: + +```go +func (a *appender) getOrCreate(l labels.Labels) (series *memSeries, created bool) { + hash := l.Hash() // Hash of ALL labels + + series = a.series.GetByHash(hash, l) + if series != nil { + return series, false + } + + ref := chunks.HeadSeriesRef(a.nextRef.Inc()) + series = &memSeries{ref: ref, lset: l} + a.series.Set(hash, series) + return series, true +} +``` + +### Current Index Structure + +The postings index maps `label_name=label_value` → list of series refs: + +``` +Postings Index: + method="GET" → [1, 2, 3, 5, 8, ...] + status="200" → [1, 3, 5, 7, 9, ...] + pod="nginx-abc" → [1, 4, 7, ...] + pod="nginx-def" → [2, 5, 8, ...] +``` + +Query `http_requests_total{method="GET", status="200"}` intersects posting lists. + +--- + +## Part 2: Entity-Native Storage Model + +### Core Concepts + +#### 1. Metric Labels vs Entity Labels + +**Metric Labels** describe the measurement itself: +- `method="GET"` - HTTP method being measured +- `status="200"` - Response status being counted +- `le="0.5"` - Histogram bucket boundary +- `quantile="0.99"` - Summary quantile + +**Entity Labels** describe what the measurement is about: +- `k8s.pod.uid="abc-123"` - Which pod +- `k8s.node.name="worker-1"` - Which node +- `service.name="api-gateway"` - Which service + +#### 2. New Series Definition + +```go +// New: Series identity = metric name + metric labels only +type memSeries struct { + ref SeriesRef // Unique identifier + metricName string // e.g., "http_requests_total" + labels labels.Labels // Metric-specific labels only (method, status, etc.) + + // Multiple data streams, one per entity combination + streams []*dataStream // Samples grouped by entity +} + +// A stream of samples from a specific entity combination +type dataStream struct { + entityRefs []EntityRef // Which entities this stream is from + headChunk *memChunk // Current in-memory chunk + mmappedChunks []*mmappedChunk // Historical chunks + + // Staleness tracking per stream + lastSeen int64 // Last sample timestamp +} +``` + +#### 3. Entity Storage (Separate) + +```go +type memEntity struct { + ref EntityRef // Unique identifier (auto-incrementing) + entityType string // e.g., "k8s.pod", "k8s.node", "service" + identifyingLabels labels.Labels // Immutable: what makes this entity unique + + // Mutable descriptive labels with history + sync.Mutex + descriptiveSnapshots []labelSnapshot + + // Lifecycle + startTime int64 // When this entity incarnation started + endTime int64 // 0 if still alive +} + +type labelSnapshot struct { + timestamp int64 + labels labels.Labels +} +``` + +### Visual Representation + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ SERIES STORAGE │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ Series 1: http_requests_total{method="GET", status="200"} │ +│ ┌─────────────────────────────────────────────────────────────────────────┐│ +│ │ Stream A: entityRefs=[pod:abc, node:worker-1, svc:api] ││ +│ │ Chunks: [(t=1000,v=100), (t=1015,v=120), ...] ││ +│ ├─────────────────────────────────────────────────────────────────────────┤│ +│ │ Stream B: entityRefs=[pod:def, node:worker-2] ││ +│ │ Chunks: [(t=1000,v=1020), (t=1015,v=1203), ...] ││ +│ ├─────────────────────────────────────────────────────────────────────────┤│ +│ │ Stream C: entityRefs=[pod:xyz, node:worker-1, svc:api] ││ +│ │ Chunks: [(t=1020,v=5), (t=1035,v=15), ...] ← Pod rescheduled here ││ +│ └─────────────────────────────────────────────────────────────────────────┘│ +├─────────────────────────────────────────────────────────────────────────────┤ +│ Series 2: http_requests_total{method="POST", status="201"} │ +│ ┌─────────────────────────────────────────────────────────────────────────┐│ +│ │ Stream A: entityRefs=[pod:abc, node:worker-1, svc:api] ││ +│ │ Chunks: [(t=1000,v=50), (t=1015,v=55), ...] ││ +│ └─────────────────────────────────────────────────────────────────────────┘│ +└─────────────────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────────────────┐ +│ ENTITY STORAGE │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ Entity: k8s.pod (ref=pod:abc) │ +│ Identifying: {k8s.pod.uid="abc-123"} │ +│ Descriptive @ t=1000: {k8s.pod.name="nginx-abc", version="1.0"} │ +│ Descriptive @ t=2000: {k8s.pod.name="nginx-abc", version="1.1"} │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ Entity: k8s.node (ref=node:worker-1) │ +│ Identifying: {k8s.node.uid="node-uid-001"} │ +│ Descriptive @ t=0: {k8s.node.name="worker-1", region="us-east-1"} │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ Entity: service (ref=svc:api) │ +│ Identifying: {service.name="api-gateway", service.namespace="prod"} │ +│ Descriptive @ t=1000: {service.version="2.0", deployment="blue"} │ +│ Descriptive @ t=3000: {service.version="2.1", deployment="green"} │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Part 3: In-Memory Structures + +### 3.1 Series Storage + +```go +type Head struct { + // Series storage - sharded for concurrent access + series *stripeSeries + + // Entity storage - separate sharded structure + entities *stripeEntities + + // Index structures + metricPostings *MetricPostings // metric labels → series refs + entityPostings *EntityPostings // entity refs → (series ref, stream index) + + // ... existing fields (WAL, chunks, etc.) +} + +// stripeSeries holds series by SeriesRef and by metric label hash +type stripeSeries struct { + size int + series []map[SeriesRef]*memSeries // Sharded by ref + hashes []seriesHashmap // Sharded by metric label hash + locks []stripeLock +} + +// seriesHashmap - only uses metric labels for lookup +type seriesHashmap struct { + unique map[uint64]*memSeries + conflicts map[uint64][]*memSeries +} +``` + +### 3.2 Series Lookup + +```go +// Key change: Series lookup only uses metric labels +func (a *appender) getOrCreateSeries(metricLabels labels.Labels) (*memSeries, bool) { + hash := metricLabels.Hash() // Hash of METRIC labels only + + series := a.series.GetByHash(hash, metricLabels) + if series != nil { + return series, false + } + + ref := SeriesRef(a.nextSeriesRef.Inc()) + series = &memSeries{ + ref: ref, + metricName: metricLabels.Get(labels.MetricName), + labels: metricLabels.WithoutEmpty(), + streams: make([]*dataStream, 0), + } + a.series.Set(hash, series) + return series, true +} +``` + +### 3.3 Stream Management + +```go +// Find or create a data stream for the given entity combination +func (s *memSeries) getOrCreateStream(entityRefs []EntityRef) (*dataStream, bool) { + s.Lock() + defer s.Unlock() + + // Look for existing stream with same entity combination + for _, stream := range s.streams { + if entityRefsEqual(stream.entityRefs, entityRefs) { + return stream, false + } + } + + // Create new stream + stream := &dataStream{ + entityRefs: entityRefs, + headChunk: nil, + lastSeen: 0, + } + s.streams = append(s.streams, stream) + return stream, true +} + +func entityRefsEqual(a, b []EntityRef) bool { + if len(a) != len(b) { + return false + } + // Sort and compare - entity refs are unordered + sortedA := sortEntityRefs(a) + sortedB := sortEntityRefs(b) + for i := range sortedA { + if sortedA[i] != sortedB[i] { + return false + } + } + return true +} +``` + +### 3.4 Entity Storage + +```go +// stripeEntities holds entities by EntityRef and by identifying label hash +type stripeEntities struct { + size int + entities []map[EntityRef]*memEntity // Sharded by ref + hashes []entityHashmap // Sharded by (type + identifying labels) hash + locks []stripeLock +} + +func (a *appender) getOrCreateEntity( + entityType string, + identifyingLabels labels.Labels, + descriptiveLabels labels.Labels, + timestamp int64, +) (*memEntity, bool) { + // Hash of type + identifying labels + hash := hashEntityIdentity(entityType, identifyingLabels) + + entity := a.entities.GetByHash(hash, entityType, identifyingLabels) + if entity != nil { + // Update descriptive labels if changed + entity.updateDescriptive(descriptiveLabels, timestamp) + return entity, false + } + + ref := EntityRef(a.nextEntityRef.Inc()) + entity = &memEntity{ + ref: ref, + entityType: entityType, + identifyingLabels: identifyingLabels, + startTime: timestamp, + descriptiveSnapshots: []labelSnapshot{{ + timestamp: timestamp, + labels: descriptiveLabels, + }}, + } + a.entities.Set(hash, entity) + return entity, true +} +``` + +--- + +## Part 4: Index Structures + +### 4.1 Metric Postings Index + +Maps metric labels to series refs (similar to current postings, but only for metric labels): + +```go +type MetricPostings struct { + mtx sync.RWMutex + // label name → label value → series refs + m map[string]map[string][]SeriesRef +} + +// Add a series to the postings index +func (p *MetricPostings) Add(ref SeriesRef, lset labels.Labels) { + p.mtx.Lock() + defer p.mtx.Unlock() + + lset.Range(func(l labels.Label) { + if p.m[l.Name] == nil { + p.m[l.Name] = make(map[string][]SeriesRef) + } + p.m[l.Name][l.Value] = append(p.m[l.Name][l.Value], ref) + }) +} + +// Get series refs for a label pair +func (p *MetricPostings) Get(name, value string) []SeriesRef { + p.mtx.RLock() + defer p.mtx.RUnlock() + + if p.m[name] == nil { + return nil + } + return p.m[name][value] +} +``` + +### 4.2 Entity Postings Index + +Maps entity labels to (series, stream) pairs: + +```go +type EntityPostings struct { + mtx sync.RWMutex + + // entityRef → list of (seriesRef, streamIndex) + byEntity map[EntityRef][]streamLocation + + // For reverse lookup: entity label → entity refs + byLabel map[string]map[string][]EntityRef +} + +type streamLocation struct { + seriesRef SeriesRef + streamIndex int +} + +// Register that a stream uses an entity +func (p *EntityPostings) AddStreamEntity( + seriesRef SeriesRef, + streamIndex int, + entityRef EntityRef, +) { + p.mtx.Lock() + defer p.mtx.Unlock() + + loc := streamLocation{seriesRef: seriesRef, streamIndex: streamIndex} + p.byEntity[entityRef] = append(p.byEntity[entityRef], loc) +} + +// Find all streams that use a specific entity +func (p *EntityPostings) GetStreamsByEntity(entityRef EntityRef) []streamLocation { + p.mtx.RLock() + defer p.mtx.RUnlock() + + return p.byEntity[entityRef] +} +``` + +### 4.3 Combined Query Flow + +```go +// Query: http_requests_total{method="GET", k8s.pod.name="nginx-abc"} +func (q *querier) Select(matchers ...*labels.Matcher) SeriesSet { + var metricMatchers, entityMatchers []*labels.Matcher + + for _, m := range matchers { + if isEntityLabel(m.Name) { + entityMatchers = append(entityMatchers, m) + } else { + metricMatchers = append(metricMatchers, m) + } + } + + // Step 1: Find series by metric labels + seriesRefs := q.metricPostings.PostingsForMatchers(metricMatchers...) + + // Step 2: If entity matchers, filter streams + if len(entityMatchers) > 0 { + // Find entities that match + entityRefs := q.findMatchingEntities(entityMatchers) + + // Find streams that use these entities + return q.filterStreamsByEntities(seriesRefs, entityRefs) + } + + // Return all streams from matching series + return q.allStreamsFromSeries(seriesRefs) +} +``` + +--- + +## Part 5: Ingestion Flow + +### 5.1 Scrape Processing + +```go +func (a *appender) Append( + metricLabels labels.Labels, // Only metric-specific labels + entityRefs []EntityRef, // Pre-resolved entity references + timestamp int64, + value float64, +) error { + // Step 1: Get or create series (by metric labels only) + series, seriesCreated := a.getOrCreateSeries(metricLabels) + + // Step 2: Get or create stream (by entity combination) + stream, streamCreated := series.getOrCreateStream(entityRefs) + + // Step 3: Append sample to stream + if err := stream.append(timestamp, value); err != nil { + return err + } + + // Step 4: Update entity postings if new stream + if streamCreated { + streamIdx := len(series.streams) - 1 + for _, entityRef := range entityRefs { + a.entityPostings.AddStreamEntity(series.ref, streamIdx, entityRef) + } + } + + // Record for WAL + a.pendingSamples = append(a.pendingSamples, pendingSample{ + seriesRef: series.ref, + streamIndex: len(series.streams) - 1, + timestamp: timestamp, + value: value, + }) + + return nil +} +``` + +### 5.2 Entity Resolution During Scrape + +```go +// During scrape, labels are split into metric vs entity +func (sl *scrapeLoop) processMetrics( + metrics []parsedMetric, + entities []parsedEntity, +) error { + app := sl.appender() + + // First, resolve all entities from this scrape + entityRefMap := make(map[string]EntityRef) + for _, e := range entities { + entity, _ := app.getOrCreateEntity( + e.Type, + e.IdentifyingLabels, + e.DescriptiveLabels, + sl.timestamp, + ) + entityRefMap[entityKey(e.Type, e.IdentifyingLabels)] = entity.ref + } + + // Then, process metrics with entity references + for _, m := range metrics { + metricLabels, entityTypes := splitLabels(m.Labels) + + // Resolve entity refs for this metric + var entityRefs []EntityRef + for _, et := range entityTypes { + key := entityKeyFromMetric(et, m.Labels) + if ref, ok := entityRefMap[key]; ok { + entityRefs = append(entityRefs, ref) + } + } + + if err := app.Append(metricLabels, entityRefs, m.Timestamp, m.Value); err != nil { + return err + } + } + + return app.Commit() +} +``` + +--- + +## Part 6: WAL Format + +### 6.1 New Record Types + +```go +const ( + // Existing types + RecordSeries Type = 1 + RecordSamples Type = 2 + RecordTombstones Type = 3 + RecordExemplars Type = 4 + RecordMetadata Type = 6 + + // New types for entity-native model + RecordEntity Type = 20 // Entity definition + RecordEntityUpdate Type = 21 // Descriptive label update + RecordStream Type = 22 // New stream in a series + RecordStreamSamples Type = 23 // Samples for a specific stream +) +``` + +### 6.2 Entity Record + +``` +┌────────────────────────────────────────────────────────────────┐ +│ type = 20 <1b> │ +├────────────────────────────────────────────────────────────────┤ +│ ┌────────────────────────────────────────────────────────────┐ │ +│ │ entityRef <8b> │ │ +│ ├────────────────────────────────────────────────────────────┤ │ +│ │ len(entityType) │ │ +│ │ entityType │ │ +│ ├────────────────────────────────────────────────────────────┤ │ +│ │ n = len(identifyingLabels) │ │ +│ │ identifyingLabels │ │ +│ ├────────────────────────────────────────────────────────────┤ │ +│ │ m = len(descriptiveLabels) │ │ +│ │ descriptiveLabels │ │ +│ ├────────────────────────────────────────────────────────────┤ │ +│ │ startTime <8b> │ │ +│ └────────────────────────────────────────────────────────────┘ │ +│ . . . │ +└────────────────────────────────────────────────────────────────┘ +``` + +### 6.3 Series Record + +``` +┌────────────────────────────────────────────────────────────────┐ +│ type = 1 <1b> │ +├────────────────────────────────────────────────────────────────┤ +│ ┌────────────────────────────────────────────────────────────┐ │ +│ │ seriesRef <8b> │ │ +│ ├────────────────────────────────────────────────────────────┤ │ +│ │ n = len(metricLabels) │ │ +│ │ metricLabels │ │ +│ └────────────────────────────────────────────────────────────┘ │ +│ . . . │ +└────────────────────────────────────────────────────────────────┘ +``` + +### 6.4 Stream Record + +``` +┌────────────────────────────────────────────────────────────────┐ +│ type = 22 <1b> │ +├────────────────────────────────────────────────────────────────┤ +│ ┌────────────────────────────────────────────────────────────┐ │ +│ │ seriesRef <8b> │ │ +│ │ streamIndex │ │ +│ ├────────────────────────────────────────────────────────────┤ │ +│ │ n = len(entityRefs) │ │ +│ │ entityRef_0 <8b> │ │ +│ │ ... │ │ +│ │ entityRef_n <8b> │ │ +│ └────────────────────────────────────────────────────────────┘ │ +│ . . . │ +└────────────────────────────────────────────────────────────────┘ +``` + +### 6.5 Stream Samples Record + +``` +┌────────────────────────────────────────────────────────────────┐ +│ type = 23 <1b> │ +├────────────────────────────────────────────────────────────────┤ +│ ┌────────────────────────────────────────────────────────────┐ │ +│ │ seriesRef <8b> │ │ +│ │ streamIndex │ │ +│ │ baseTimestamp <8b> │ │ +│ ├────────────────────────────────────────────────────────────┤ │ +│ │ timestamp_delta │ │ +│ │ value <8b> │ │ +│ │ ... │ │ +│ └────────────────────────────────────────────────────────────┘ │ +└────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Part 7: Block Format + +### 7.1 Block Directory Structure + +``` +/ +├── meta.json +├── index +├── chunks/ +│ ├── 000001 +│ ├── 000002 +│ └── ... +├── entities/ # NEW: Entity storage +│ ├── index # Entity index +│ └── snapshots/ # Descriptive label snapshots +│ ├── 000001 +│ └── ... +└── tombstones +``` + +### 7.2 Modified Series Index Format + +``` +Series Entry: +┌──────────────────────────────────────────────────────────────────────────┐ +│ len │ +├──────────────────────────────────────────────────────────────────────────┤ +│ labels count │ +│ ┌──────────────────────────────────────────────────────────────────────┐ │ +│ │ ref(metric_label_name) │ │ +│ │ ref(metric_label_value) │ │ +│ │ ... │ │ +│ └──────────────────────────────────────────────────────────────────────┘ │ +├──────────────────────────────────────────────────────────────────────────┤ +│ streams count │ +│ ┌──────────────────────────────────────────────────────────────────────┐ │ +│ │ Stream 0: │ │ +│ │ entity_refs count │ │ +│ │ entityRef_0 <8b> │ │ +│ │ ... │ │ +│ │ chunks count │ │ +│ │ chunk entries... │ │ +│ ├──────────────────────────────────────────────────────────────────────┤ │ +│ │ Stream 1: │ │ +│ │ ... │ │ +│ └──────────────────────────────────────────────────────────────────────┘ │ +├──────────────────────────────────────────────────────────────────────────┤ +│ CRC32 <4b> │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +### 7.3 Entity Index Format + +``` +┌────────────────────────────┬─────────────────────┐ +│ magic(0xENT1D700) <4b> │ version(1) <1 byte> │ +├────────────────────────────┴─────────────────────┤ +│ ┌──────────────────────────────────────────────┐ │ +│ │ Symbol Table │ │ +│ ├──────────────────────────────────────────────┤ │ +│ │ Entity Types │ │ +│ ├──────────────────────────────────────────────┤ │ +│ │ Entities │ │ +│ ├──────────────────────────────────────────────┤ │ +│ │ Entity Label Postings │ │ +│ ├──────────────────────────────────────────────┤ │ +│ │ Postings Offset Table │ │ +│ ├──────────────────────────────────────────────┤ │ +│ │ TOC │ │ +│ └──────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────┘ + +Entity Entry: +┌──────────────────────────────────────────────────────────────────────────┐ +│ entityRef <8b> │ +├──────────────────────────────────────────────────────────────────────────┤ +│ ref(entityType) │ +├──────────────────────────────────────────────────────────────────────────┤ +│ identifyingLabels count │ +│ ┌──────────────────────────────────────────────────────────────────────┐ │ +│ │ ref(label_name) │ │ +│ │ ref(label_value) │ │ +│ │ ... │ │ +│ └──────────────────────────────────────────────────────────────────────┘ │ +├──────────────────────────────────────────────────────────────────────────┤ +│ startTime │ +│ endTime (0 if still alive at block max time) │ +├──────────────────────────────────────────────────────────────────────────┤ +│ snapshot_file_ref (reference to descriptive snapshots) │ +├──────────────────────────────────────────────────────────────────────────┤ +│ CRC32 <4b> │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Part 8: Query Execution + +### 8.1 Query Result Model + +```go +// A query result is now a series with potentially multiple streams +type SeriesResult struct { + Labels labels.Labels // Metric labels + Streams []StreamResult // One per entity combination +} + +type StreamResult struct { + EntityRefs []EntityRef // Which entities this stream is from + Samples []Sample // The actual samples + + // Resolved entity labels (computed at query time) + entityLabels labels.Labels +} +``` + +### 8.2 Entity Label Resolution + +```go +func (q *querier) resolveEntityLabels( + entityRefs []EntityRef, + timestamp int64, +) labels.Labels { + builder := labels.NewBuilder(nil) + + for _, ref := range entityRefs { + entity := q.entities.GetByRef(ref) + if entity == nil { + continue + } + + // Add identifying labels + entity.identifyingLabels.Range(func(l labels.Label) { + builder.Set(l.Name, l.Value) + }) + + // Add descriptive labels at the given timestamp + descriptive := entity.DescriptiveLabelsAt(timestamp) + descriptive.Range(func(l labels.Label) { + builder.Set(l.Name, l.Value) + }) + } + + return builder.Labels() +} +``` + +### 8.3 PromQL Integration + +```go +// When PromQL asks for a vector at time T: +func (q *querier) Select(ctx context.Context, matchers ...*labels.Matcher) storage.SeriesSet { + metricMatchers, entityMatchers := splitMatchers(matchers) + + // Find matching series by metric labels + seriesRefs := q.metricPostings.PostingsForMatchers(ctx, metricMatchers...) + + // Build result set + var results []storage.Series + + for seriesRefs.Next() { + series := q.series.GetByRef(seriesRefs.At()) + + for streamIdx, stream := range series.streams { + // Check if stream's entities match entity matchers + if len(entityMatchers) > 0 { + entityLabels := q.resolveEntityLabels(stream.entityRefs, q.maxTime) + if !matchAll(entityLabels, entityMatchers) { + continue + } + } + + // Create a "virtual series" for this stream + results = append(results, &virtualSeries{ + metricLabels: series.labels, + entityRefs: stream.entityRefs, + chunks: stream.chunks, + querier: q, + }) + } + } + + return newSeriesSet(results) +} + +// virtualSeries represents a single stream as a series +type virtualSeries struct { + metricLabels labels.Labels + entityRefs []EntityRef + chunks []chunks.Meta + querier *querier +} + +func (s *virtualSeries) Labels() labels.Labels { + // Merge metric labels with entity labels + builder := labels.NewBuilder(s.metricLabels) + + entityLabels := s.querier.resolveEntityLabels(s.entityRefs, s.querier.maxTime) + entityLabels.Range(func(l labels.Label) { + builder.Set(l.Name, l.Value) + }) + + return builder.Labels() +} +``` + +--- + +## Part 9: Migration and Compatibility + +### 9.1 Feature Flag + +```yaml +# prometheus.yml +storage: + tsdb: + entity_native_storage: true # Enable new storage model +``` + +### 9.2 Backward Compatibility Mode + +When `entity_native_storage: false` (default): +- Behave exactly like current Prometheus +- All labels treated as metric labels +- Single stream per series + +When `entity_native_storage: true`: +- Entity labels are separated based on configuration/conventions +- Multiple streams per series possible +- Entity storage enabled + +### 9.3 Migration Strategy + +1. **Phase 1: Dual Write** + - New data written in new format + - Old blocks remain readable + - Query merges old and new formats + +2. **Phase 2: Background Conversion** + - Old blocks gradually converted during compaction + - No service interruption + +3. **Phase 3: Full Migration** + - All data in new format + - Old format support can be deprecated + +--- + +## Part 10: Trade-offs and Considerations + +### Benefits + +| Aspect | Improvement | +|--------|-------------| +| **Cardinality** | Series count = metric × metric_label_values (not × entities) | +| **Entity Changes** | Pod restart = new stream, not new series | +| **Storage Efficiency** | Entity labels stored once, not per-series | +| **Query Flexibility** | Natural entity-aware queries | +| **OTel Alignment** | Matches OTLP's resource/metric model | + +### Challenges + +| Aspect | Challenge | Mitigation | +|--------|-----------|------------| +| **Complexity** | Significant codebase changes | Phased rollout, feature flags | +| **Query Performance** | Entity label resolution overhead | Caching, pre-computation | +| **Index Size** | Additional entity postings | Efficient encoding, memory mapping | +| **Compatibility** | Breaking change for remote write | Version negotiation, adapters | + +### Open Questions + +1. **Stream Identity**: Should stream identity be based on sorted entity refs or preserve order? + +2. **Staleness**: Per-stream staleness vs per-series staleness? + +3. **Remote Write**: How to encode streams in the remote write protocol? + +4. **Recording Rules**: How do recording rule results handle entity association? + +5. **Exemplars**: Should exemplars be per-stream or per-series? + +--- diff --git a/proposals/0071-Entity/05-querying.md b/proposals/0071-Entity/05-querying.md new file mode 100644 index 00000000..f6c2ec2d --- /dev/null +++ b/proposals/0071-Entity/05-querying.md @@ -0,0 +1,555 @@ +# Querying: Entity-Aware PromQL + +## Abstract + +This document specifies how Prometheus's query engine extends to support native entity awareness. The core principle is **automatic enrichment**: when querying metrics, correlated entity labels (both identifying and descriptive) are automatically included in results without requiring explicit join operations. A new **pipe operator** (`|`) enables filtering metrics by entity correlation using familiar syntax consistent with the exposition format. + +## Background + +### Current PromQL Value Types + +PromQL expressions evaluate to one of four value types: + +| Type | Description | Example | +|------|-------------|---------| +| **Scalar** | Single floating-point number | `42`, `3.14` | +| **String** | Simple string literal | `"hello"` | +| **Instant Vector** | Set of time series, each with one sample at a single timestamp | `http_requests_total{job="api"}` | +| **Range Vector (Matrix)** | Set of time series, each with multiple samples over a time range | `http_requests_total{job="api"}[5m]` | + +Functions have specific type signatures: + +``` +rate(Matrix) → Vector +sum(Vector) → Vector +scalar(Vector) → Scalar (single-element vector only) +``` + +### Current Query Execution Model + +When Prometheus executes a PromQL query: + +1. **Parsing**: Query string → Abstract Syntax Tree (AST) +2. **Preparation**: For each VectorSelector, call `querier.Select()` with label matchers +3. **Evaluation**: Traverse AST, evaluate functions and operators +4. **Result**: Return typed value (Scalar, Vector, or Matrix) + +The query engine interacts with storage through the `Querier` interface: + +```go +type Querier interface { + Select(ctx context.Context, sortSeries bool, hints *SelectHints, + matchers ...*labels.Matcher) SeriesSet + LabelValues(ctx context.Context, name string, ...) ([]string, error) + LabelNames(ctx context.Context, ...) ([]string, error) + Close() error +} +``` + +--- + +## Automatic Enrichment + +### How It Works + +When the query engine evaluates a VectorSelector or MatrixSelector, it automatically enriches each series with labels from correlated entities. + +**Query:** +```promql +container_cpu_usage_seconds_total{k8s.namespace.name="production"} +``` + +**Before enrichment (raw series from storage):** +``` +container_cpu_usage_seconds_total{ + container="nginx", + k8s.namespace.name="production", + k8s.pod.uid="abc-123", + k8s.node.uid="node-001" +} 1234.5 +``` + +**After enrichment (returned to user):** +``` +container_cpu_usage_seconds_total{ + # Original metric labels + container="nginx", + + # Identifying labels (correlation keys, already on series) + k8s.namespace.name="production", + k8s.pod.uid="abc-123", + k8s.node.uid="node-001", + + # Descriptive labels from k8s.pod entity + k8s.pod.name="nginx-7b9f5", + k8s.pod.status.phase="Running", + k8s.pod.start_time="2024-01-15T10:30:00Z", + + # Descriptive labels from k8s.node entity + k8s.node.name="worker-1", + k8s.node.os="linux", + k8s.node.kernel.version="5.15.0" +} 1234.5 +``` + +### Enrichment Algorithm + +```go +func (ev *evaluator) enrichSeries( + ctx context.Context, + series storage.Series, + timestamp int64, +) labels.Labels { + originalLabels := series.Labels() + + // 1. Find correlated entities via storage index + entityRefs := ev.entityQuerier.EntitiesForSeries(series.Ref()) + + if len(entityRefs) == 0 { + return originalLabels // No entities, return as-is + } + + // 2. Build enriched label set + builder := labels.NewBuilder(originalLabels) + + for _, entityRef := range entityRefs { + entity := ev.entityQuerier.GetEntity(entityRef) + + // Get descriptive labels at the sample timestamp + descriptiveLabels := entity.DescriptiveLabelsAt(timestamp) + + // Merge into result + descriptiveLabels.Range(func(l labels.Label) { + builder.Set(l.Name, l.Value) + }) + } + + return builder.Labels() +} +``` + +--- + +## Filtering by Entity Labels + +Since entity labels appear as labels in query results, standard PromQL label matchers work: + +### By Identifying Labels + +```promql +# Filter by pod UID (identifying) +container_cpu_usage_seconds_total{k8s.pod.uid="abc-123"} +``` + +This is efficient because identifying labels are stored on the series and indexed. + +### By Descriptive Labels + +```promql +# Filter by pod name (descriptive) +container_cpu_usage_seconds_total{k8s.pod.name="nginx-7b9f5"} + +# Filter by node OS (descriptive) +container_memory_usage_bytes{k8s.node.os="linux"} + +# Regex matching on descriptive labels +http_requests_total{service.version=~"2\\..*"} +``` + +**Query Execution for Descriptive Label Filters:** + +1. Select all series that might match (based on metric name and any indexed labels) +2. For each series, look up correlated entities +3. Get descriptive labels at evaluation timestamp +4. Apply the filter: keep series where enriched labels match + +## Aggregation by Entity Labels + +Standard PromQL aggregation works with entity labels: + +```promql +# Sum CPU by node name (descriptive label) +sum by (k8s.node.name) (container_cpu_usage_seconds_total) + +# Average memory by service version +avg by (service.version) (process_resident_memory_bytes) + +# Count requests by pod status +count by (k8s.pod.status.phase) (rate(http_requests_total[5m])) +``` + +### Aggregation Semantics + +Aggregation happens **after** enrichment: + +``` +1. Select series matching the selector +2. Enrich each series with entity labels +3. Group by the specified labels (which may include entity labels) +4. Apply aggregation function +``` + +**Example:** + +```promql +sum by (k8s.node.name) (container_cpu_usage_seconds_total) +``` + +``` +Step 1 - Select series: + container_cpu{pod_uid="a", node_uid="n1"} 10 + container_cpu{pod_uid="b", node_uid="n1"} 20 + container_cpu{pod_uid="c", node_uid="n2"} 30 + +Step 2 - Enrich with entity labels: + container_cpu{..., k8s.node.name="worker-1"} 10 + container_cpu{..., k8s.node.name="worker-1"} 20 + container_cpu{..., k8s.node.name="worker-2"} 30 + +Step 3 - Group by k8s.node.name: + Group "worker-1": [10, 20] + Group "worker-2": [30] + +Step 4 - Sum: + {k8s.node.name="worker-1"} 30 + {k8s.node.name="worker-2"} 30 +``` + +--- + +## Range Queries and Temporal Semantics + +### The Challenge + +Descriptive labels can change over time. When querying a range, which label values should be used? + +**Example scenario:** +- Pod `abc-123` runs on `worker-1` from T0 to T5 +- Pod migrates to `worker-2` at T5 +- Query: `container_cpu_usage_seconds_total{k8s.pod.uid="abc-123"}[10m]` + +### Solution: Point-in-Time Label Resolution + +Each sample is enriched with the descriptive labels **that were valid at that sample's timestamp**. + +```promql +container_cpu_usage_seconds_total{k8s.pod.uid="abc-123"}[10m] +``` + +**Returns:** +``` +# Samples before migration (T0-T4) have worker-1 +container_cpu{k8s.pod.uid="abc-123", k8s.node.name="worker-1"} 100 @T0 +container_cpu{k8s.pod.uid="abc-123", k8s.node.name="worker-1"} 110 @T1 +container_cpu{k8s.pod.uid="abc-123", k8s.node.name="worker-1"} 120 @T2 +container_cpu{k8s.pod.uid="abc-123", k8s.node.name="worker-1"} 130 @T3 +container_cpu{k8s.pod.uid="abc-123", k8s.node.name="worker-1"} 140 @T4 + +# Samples after migration (T5+) have worker-2 +container_cpu{k8s.pod.uid="abc-123", k8s.node.name="worker-2"} 150 @T5 +container_cpu{k8s.pod.uid="abc-123", k8s.node.name="worker-2"} 160 @T6 +... +``` + +### Implications for Range Functions + +Functions like `rate()` operate on the raw sample values, but the returned instant vector has enriched labels: + +```promql +rate(container_cpu_usage_seconds_total{k8s.pod.uid="abc-123"}[5m]) +``` + +For rate calculation: +- Uses sample values regardless of label changes +- The result is enriched with labels **at the evaluation timestamp** + +### Series Identity Across Label Changes + +**Important:** Descriptive label changes do NOT create new series. The series identity is defined by: +- Metric name +- Original metric labels +- Entity identifying labels (correlation keys) + +Descriptive labels are metadata that "rides along" with samples, not part of series identity. + +--- + +## The Entity Type Filter Operator + +Automatic enrichment means entity labels appear as labels in query results, so standard label matchers handle most filtering needs: + +```promql +# Filter by entity label - just use label matchers +container_cpu_usage_seconds_total{k8s.pod.name="nginx"} +container_cpu_usage_seconds_total{k8s.pod.status.phase="Running"} +``` + +However, there's one thing label matchers **cannot** do: filter by entity type existence. The pipe operator (`|`) fills this gap. + +### Syntax + +```promql +vector_expr | entity_type_expr +``` + +Where `entity_type_expr` can be: +- A single entity type: `k8s.pod` +- Negated: `!k8s.pod` +- Combined with `and`: `k8s.pod and k8s.node` +- Combined with `or`: `k8s.pod or service` +- Grouped: `(k8s.pod and k8s.node) or service` + +### When to Use + +The pipe operator answers the question: **"Is this metric correlated with an entity of this type?"** + +```promql +# Metrics that ARE correlated with any pod entity +container_cpu_usage_seconds_total | k8s.pod + +# Metrics that ARE correlated with any node entity +container_memory_usage_bytes | k8s.node + +# Metrics that ARE correlated with any service entity +http_requests_total | service +``` + +### Negation with `!` + +Use `!` before an entity type to negate it: + +```promql +# Metrics NOT correlated with any pod +container_cpu_usage_seconds_total | !k8s.pod + +# Metrics NOT correlated with any service +http_requests_total | !service +``` + +### Combining Entity Type Filters + +Use `and`/`or` keywords to combine entity type filters: + +```promql +# Metrics correlated with BOTH a pod AND a node +container_cpu_usage_seconds_total | k8s.pod and k8s.node + +# Metrics correlated with a pod OR a service +container_cpu_usage_seconds_total | k8s.pod or service + +# Metrics correlated with a pod but NOT a node +container_cpu_usage_seconds_total | k8s.pod and !k8s.node +``` + +Operator precedence follows standard rules: `!` (not) binds tightest, then `and`, then `or`. Use parentheses for clarity: + +```promql +# Explicit grouping +container_cpu | (k8s.pod and k8s.node) or service +``` + +### All Metrics for an Entity Type + +To get all metrics correlated with a specific entity type, omit the metric selector: + +```promql +# All metrics correlated with any pod + | k8s.pod + +# Equivalent to: +{__name__=~".+"} | k8s.pod +``` + +This is useful for exploring what metrics are available for a given entity type. + +### Combining with Label Matchers + +For label filtering, use label matchers (simpler and familiar). Use the pipe operator only when you need entity type filtering: + +```promql +# Filter by label: use label matcher +container_cpu_usage_seconds_total{k8s.pod.name="nginx"} + +# Filter by entity type existence: use pipe +container_cpu_usage_seconds_total | k8s.pod + +# Both: label matcher for label, pipe for type +container_cpu_usage_seconds_total{k8s.namespace.name="production"} | k8s.pod | k8s.node +``` + +--- + +## Query Engine Implementation + +### Extended Querier Interface + +```go +// EntityQuerier provides entity lookup capabilities +type EntityQuerier interface { + // Get entities correlated with a series + EntitiesForSeries(ref storage.SeriesRef) []EntityRef + + // Get entity by reference + GetEntity(ref EntityRef) Entity + + Close() error +} + +// Entity represents a single entity +type Entity interface { + Ref() EntityRef + Type() string + IdentifyingLabels() labels.Labels + DescriptiveLabelsAt(timestamp int64) labels.Labels + StartTime() int64 + EndTime() int64 +} +``` + +### Parser Changes + +New AST nodes for entity type filtering: + +```go +// EntityTypeFilter represents a pipe expression: vector | entity_type_expr +type EntityTypeFilter struct { + Expr Expr // Left side (vector expression) + EntityTypeExpr EntityTypeExpr // Right side (entity type boolean expression) + PosRange posrange.PositionRange +} + +func (*EntityTypeFilter) Type() ValueType { return ValueTypeVector } + +// EntityTypeExpr is an interface for entity type expressions +type EntityTypeExpr interface { + // Matches returns true if the given set of entity types satisfies this expression + Matches(entityTypes map[string]bool) bool +} + +// EntityTypeName represents a single entity type: k8s.pod +type EntityTypeName struct { + Name string // e.g., "k8s.pod", "service" + Negated bool // true for !k8s.pod +} + +// EntityTypeAnd represents: k8s.pod and k8s.node +type EntityTypeAnd struct { + Left, Right EntityTypeExpr +} + +// EntityTypeOr represents: k8s.pod or service +type EntityTypeOr struct { + Left, Right EntityTypeExpr +} +``` + +### Pipe Operator Evaluation + +```go +func (ev *evaluator) evalEntityTypeFilter( + ctx context.Context, + vector Vector, + typeExpr EntityTypeExpr, +) Vector { + var result Vector + + for _, sample := range vector { + // Get all entity types correlated with this series + seriesEntityRefs := ev.entityQuerier.EntitiesForSeries(sample.SeriesRef) + entityTypes := make(map[string]bool) + for _, ref := range seriesEntityRefs { + entity := ev.entityQuerier.GetEntity(ref) + entityTypes[entity.Type()] = true + } + + // Evaluate the entity type expression + if typeExpr.Matches(entityTypes) { + result = append(result, sample) + } + } + + return result +} + +// Example Matches implementations: + +func (e *EntityTypeName) Matches(types map[string]bool) bool { + has := types[e.Name] + if e.Negated { + return !has + } + return has +} + +func (e *EntityTypeAnd) Matches(types map[string]bool) bool { + return e.Left.Matches(types) && e.Right.Matches(types) +} + +func (e *EntityTypeOr) Matches(types map[string]bool) bool { + return e.Left.Matches(types) || e.Right.Matches(types) +} +``` + +### Query Execution Flow + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Query Execution Flow │ +└─────────────────────────────────────────────────────────────────────────┘ + + ┌─────────────────────────────┐ + │ PromQL String │ + │ │ + │ cpu | k8s.pod and k8s.node │ + └─────────────┬───────────────┘ + │ + ▼ + ┌─────────────────────────────┐ + │ Parser │ + │ │ + │ - VectorSelector │ + │ - EntityTypeFilter │◄── NEW + │ - EntityTypeExpr (and/or/!) │ + └─────────────┬───────────────┘ + │ + ▼ + ┌─────────────────────────────┐ + │ AST │ + │ │ + │ EntityTypeFilter { │ + │ Expr: cpu │ + │ TypeExpr: And { │ + │ Left: "k8s.pod" │ + │ Right: "k8s.node" │ + │ } │ + │ } │ + └─────────────┬───────────────┘ + │ + ▼ +┌────────────────────────────────────────────────────────────────────────┐ +│ Evaluator │ +│ │ +│ 1. Evaluate left side (VectorSelector) │ +│ - querier.Select() → SeriesSet │ +│ - Enrich with entity labels │ +│ - Result: enriched Vector │ +│ │ +│ 2. Evaluate EntityTypeFilter │ +│ - For each series, get correlated entity types │ +│ - Evaluate boolean expression against those types │ +│ - Keep series where expression evaluates to true │ +│ - Result: filtered Vector │ +│ │ +└────────────────────────────────────────────────────────────────────────┘ + │ + ▼ + ┌─────────────────────────────┐ + │ Result │ + │ │ + │ Vector/Matrix │ + └─────────────────────────────┘ +``` + +--- + +The next document will cover [Web UI and APIs](./06-web-ui-and-apis.md), detailing how these capabilities are exposed in Prometheus's user interface and HTTP APIs. diff --git a/proposals/0071-Entity/06-web-ui-and-apis.md b/proposals/0071-Entity/06-web-ui-and-apis.md new file mode 100644 index 00000000..ca05e962 --- /dev/null +++ b/proposals/0071-Entity/06-web-ui-and-apis.md @@ -0,0 +1,566 @@ +# Web UI and APIs + +## Abstract + +This document specifies how Prometheus's HTTP API and Web UI should be extended to support entity-aware querying. The key principle is **progressive disclosure**: query results display entity context prominently while keeping the interface familiar for users who don't need entity details. + +The wireframe below illustrates the concept—entity labels are displayed separately from metric labels, making it easy to understand the context of each time series. + +![Wireframe showing query results with entity labels separated from metric labels](./wireframes/Wireframe%20-%20Simple%20idea%20-%20Complete%20flow.png) + +--- + +## Background + +### Current API Response Structure + +Today, the `/api/v1/query` endpoint returns results like: + +```json +{ + "status": "success", + "data": { + "resultType": "vector", + "result": [ + { + "metric": { + "__name__": "container_cpu_usage_seconds_total", + "container": "nginx", + "namespace": "production", + "pod": "nginx-7b9f5" + }, + "value": [1234567890, "1234.5"] + } + ] + } +} +``` + +All labels are in a flat `metric` object. There's no distinction between: +- Labels that identify the metric itself (e.g., `container`, `method`) +- Labels that identify the entity producing the metric (e.g., `k8s.pod.uid`, `k8s.node.uid`) +- Labels that describe the entity (e.g., `k8s.pod.name`, `k8s.node.os`) + +### Current UI Display + +The Prometheus UI displays all labels together: + +``` +container_cpu_usage_seconds_total{container="nginx", namespace="production", pod="nginx-7b9f5", ...} +``` + +This becomes unwieldy when entity labels are added through enrichment—users see a long list of labels without understanding which provide entity context. + +--- + +## API Changes + +### Query Response Enhancement + +The query endpoints (`/api/v1/query`, `/api/v1/query_range`) should return entity context alongside the metric: + +```json +{ + "status": "success", + "data": { + "resultType": "vector", + "result": [ + { + "metric": { + "__name__": "container_cpu_usage_seconds_total", + "container": "nginx" + }, + "entities": [ + { + "type": "k8s.pod", + "identifyingLabels": { + "k8s.namespace.name": "production", + "k8s.pod.uid": "abc-123" + }, + "descriptiveLabels": { + "k8s.pod.name": "nginx-7b9f5", + "k8s.pod.status.phase": "Running" + } + }, + { + "type": "k8s.node", + "identifyingLabels": { + "k8s.node.uid": "node-001" + }, + "descriptiveLabels": { + "k8s.node.name": "worker-1", + "k8s.node.os": "linux" + } + } + ], + "value": [1234567890, "1234.5"] + } + ] + } +} +``` + +**Key changes:** + +| Field | Description | +|-------|-------------| +| `metric` | Only the original metric labels (not entity labels) | +| `entities` | Array of correlated entities with their labels | +| `entities[].type` | Entity type (e.g., "k8s.pod", "service") | +| `entities[].identifyingLabels` | Immutable labels that identify the entity | +| `entities[].descriptiveLabels` | Mutable labels describing the entity | + +### Backward Compatibility + +For backward compatibility, a query parameter controls the response format: + +``` +GET /api/v1/query?query=...&entity_info=true +``` + +| Parameter | Behavior | +|-----------|----------| +| `entity_info=true` | Returns structured entity information | +| `entity_info=false` (default) | Returns flat labels (current behavior, entity labels merged in) | + +When `entity_info=false` (default), all entity labels appear in the `metric` object as they do today with automatic enrichment. This ensures existing tooling continues to work. + +### Response Type Definitions + +```typescript +// Enhanced query result with entity context +interface EnhancedInstantSample { + metric: Record; // Original metric labels only + entities?: EntityContext[]; // Correlated entities (if entity_info=true) + value?: [number, string]; + histogram?: [number, Histogram]; +} + +interface EntityContext { + type: string; // e.g., "k8s.pod" + identifyingLabels: Record; + descriptiveLabels: Record; +} + +// When entity_info=false (default), use existing format +interface LegacyInstantSample { + metric: Record; // All labels merged (metric + entity labels) + value?: [number, string]; + histogram?: [number, Histogram]; +} +``` + +--- + +## New Entity Endpoints + +### List Entity Types + +``` +GET /api/v1/entities/types +``` + +Returns all known entity types in the system: + +```json +{ + "status": "success", + "data": [ + { + "type": "k8s.pod", + "identifyingLabels": ["k8s.namespace.name", "k8s.pod.uid"], + "count": 1523 + }, + { + "type": "k8s.node", + "identifyingLabels": ["k8s.node.uid"], + "count": 12 + }, + { + "type": "service", + "identifyingLabels": ["service.namespace", "service.name", "service.instance.id"], + "count": 89 + } + ] +} +``` + +### Get Entity Type Schema + +``` +GET /api/v1/entities/types/{type} +``` + +Returns detailed schema for an entity type: + +```json +{ + "status": "success", + "data": { + "type": "k8s.pod", + "identifyingLabels": ["k8s.namespace.name", "k8s.pod.uid"], + "knownDescriptiveLabels": [ + "k8s.pod.name", + "k8s.pod.status.phase", + "k8s.pod.start_time", + "k8s.pod.ip", + "k8s.pod.owner.kind", + "k8s.pod.owner.name" + ], + "activeEntityCount": 1523, + "correlatedSeriesCount": 45230 + } +} +``` + +### List Entities + +``` +GET /api/v1/entities?type=k8s.pod&match[]={k8s.namespace.name="production"} +``` + +Returns entities matching the criteria: + +```json +{ + "status": "success", + "data": [ + { + "type": "k8s.pod", + "identifyingLabels": { + "k8s.namespace.name": "production", + "k8s.pod.uid": "abc-123" + }, + "descriptiveLabels": { + "k8s.pod.name": "nginx-7b9f5", + "k8s.pod.status.phase": "Running" + }, + "startTime": 1700000000, + "endTime": 0, + "correlatedSeriesCount": 42 + } + ] +} +``` + +**Query parameters:** + +| Parameter | Description | +|-----------|-------------| +| `type` | Entity type to query (required) | +| `match[]` | Label matchers for filtering entity labels (can specify multiple) | +| `start` | Start of time range (for historical queries) | +| `end` | End of time range | +| `limit` | Maximum entities to return | + +### Get Entity Details + +``` +GET /api/v1/entities/{type}/{encoded_identifying_attrs} +``` + +The identifying labels are URL-encoded as a label set: + +``` +GET /api/v1/entities/k8s.pod/k8s.namespace.name%3D%22production%22%2Ck8s.pod.uid%3D%22abc-123%22 +``` + +Returns detailed information about a specific entity: + +```json +{ + "status": "success", + "data": { + "type": "k8s.pod", + "identifyingLabels": { + "k8s.namespace.name": "production", + "k8s.pod.uid": "abc-123" + }, + "descriptiveLabels": { + "k8s.pod.name": "nginx-7b9f5", + "k8s.pod.status.phase": "Running" + }, + "startTime": 1700000000, + "endTime": 0, + "descriptiveHistory": [ + { + "timestamp": 1700000000, + "labels": { + "k8s.pod.name": "nginx-7b9f5", + "k8s.pod.status.phase": "Pending" + } + }, + { + "timestamp": 1700000030, + "labels": { + "k8s.pod.name": "nginx-7b9f5", + "k8s.pod.status.phase": "Running" + } + } + ], + "correlatedSeries": [ + "container_cpu_usage_seconds_total", + "container_memory_usage_bytes", + "container_network_receive_bytes_total" + ] + } +} +``` + +### Get Correlated Metrics for Entity + +``` +GET /api/v1/entities/{type}/{encoded_identifying_attrs}/metrics +``` + +Returns all metric names correlated with a specific entity: + +```json +{ + "status": "success", + "data": [ + { + "name": "container_cpu_usage_seconds_total", + "seriesCount": 3, + "labels": ["container"] + }, + { + "name": "container_memory_usage_bytes", + "seriesCount": 3, + "labels": ["container"] + } + ] +} +``` + +--- + +## Web UI Changes + +### Query Results Display + +Based on the wireframe concept, query results should display entity context prominently but separately from metric labels. + +**Current display:** +``` +container_cpu_usage_seconds_total{container="nginx", k8s.namespace.name="production", k8s.pod.uid="abc-123", k8s.pod.name="nginx-7b9f5", k8s.node.uid="node-001", k8s.node.name="worker-1", ...} 1234.5 +``` + +**Enhanced display:** + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ container_cpu_usage_seconds_total{container="nginx"} 1234.5 │ +│ │ +│ Entities: │ +│ k8s.pod │ +│ k8s.namespace.name="production", k8s.pod.uid="abc-123" │ +│ k8s.pod.name="nginx-7b9f5", k8s.pod.status.phase="Running" │ +│ │ +│ k8s.node │ +│ k8s.node.uid="node-001" │ +│ k8s.node.name="worker-1", k8s.node.os="linux" │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### UI Components + +**1. SeriesName Enhancement** + +The `SeriesName` component should accept entity context: + +```typescript +interface SeriesNameProps { + labels: Record; + entities?: EntityContext[]; + format: boolean; + showEntities?: boolean; // Toggle entity display +} +``` + +**2. EntityBadge Component** + +A new component for displaying entity information: + +```typescript +interface EntityBadgeProps { + entity: EntityContext; + expanded?: boolean; + onToggle?: () => void; +} +``` + +Displays entity type with expandable labels: + +``` +┌─────────────────────────────────────────────┐ +│ 📦 k8s.pod [▼] │ +│ k8s.namespace.name="production" │ +│ k8s.pod.uid="abc-123" │ +│ ───────────────────────────── │ +│ k8s.pod.name="nginx-7b9f5" │ +│ k8s.pod.status.phase="Running" │ +└─────────────────────────────────────────────┘ +``` + +**3. Collapsible Entity Section** + +For tables with many results, entities can be collapsed by default: + +```typescript +interface DataTableProps { + data: InstantQueryResult; + showEntities: boolean; + entityDisplayMode: 'collapsed' | 'expanded' | 'inline'; +} +``` + +### New Pages + +**1. Entity Explorer Page** + +A dedicated page for browsing entities: + +``` +/entities + ├── List all entity types + ├── Filter by type + ├── Search by labels + └── Click to see entity details + +/entities/{type} + ├── List all entities of type + ├── Filter by identifying/descriptive labels + └── Click to see entity details + +/entities/{type}/{id} + ├── Entity details + ├── Label history timeline + ├── Correlated metrics list + └── Quick query links +``` + +**2. Entity Type Schema Page** + +Shows the schema for an entity type: + +``` +/entities/types/{type} + ├── Identifying labels list + ├── Known descriptive labels + ├── Entity count statistics + └── Related entity types +``` + +### Graph View Integration + +When viewing graphs, entity context can be shown on hover: + +``` +┌───────────────────────────────────────────────────────────────┐ +│ Graph │ +│ ╱╲ ╱╲ │ +│ ╱ ╲ ╱ ╲ ╱╲ │ +│ ╱ ╲╱ ╲ ╱ ╲ │ +│ ╱ ╲╱ ╲ │ +│ ╱ ╲ │ +├───────────────────────────────────────────────────────────────┤ +│ Hovering: container_cpu_usage_seconds_total{container="nginx"}│ +│ │ +│ 📦 k8s.pod: nginx-7b9f5 (production) │ +│ 🖥️ k8s.node: worker-1 │ +│ │ +│ Value: 1234.5 @ 2024-01-15 10:30:00 │ +└───────────────────────────────────────────────────────────────┘ +``` + +### Settings + +New user preferences for entity display: + +```typescript +interface EntityDisplaySettings { + // Show entity information in query results + showEntitiesInResults: boolean; + + // Default display mode + entityDisplayMode: 'collapsed' | 'expanded' | 'inline'; + + // Show identifying vs descriptive separation + separateIdentifyingLabels: boolean; + + // Entity types to always show + pinnedEntityTypes: string[]; +} +``` + +--- + +## Implementation Considerations + +### API Response Size + +Adding entity context increases response size. Mitigations: + +1. **Optional via query parameter**: `entity_info=true` to opt-in +2. **Compression**: gzip reduces impact significantly +3. **Pagination**: Limit results and paginate large responses +4. **Streaming**: Consider streaming for very large result sets + +### Frontend Performance + +With potentially many entities per series: + +- Lazy load entity details on expand +- Virtualize long lists +- Use `entity_info=false` for performance-critical views +- Progressive loading for entity explorer + +--- + +## API Summary + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/v1/query` | GET/POST | Query with optional `entity_info=true` | +| `/api/v1/query_range` | GET/POST | Range query with optional `entity_info=true` | +| `/api/v1/entities/types` | GET | List all entity types | +| `/api/v1/entities/types/{type}` | GET | Get entity type schema | +| `/api/v1/entities` | GET | List entities with filters | +| `/api/v1/entities/{type}/{id}` | GET | Get specific entity details | +| `/api/v1/entities/{type}/{id}/metrics` | GET | Get metrics for entity | + +--- + +## UI Summary + +| Feature | Description | +|---------|-------------| +| Enhanced SeriesName | Shows entities separately from labels | +| EntityBadge | Compact entity display with expand | +| Entity Explorer | Browse and search entities | +| Graph hover | Shows entity context on hover | +| Settings | Control entity display preferences | + +--- + +## Migration Path + +**Phase 1: API additions** +- Add `entity_info` parameter (default false) +- Add new `/api/v1/entities/*` endpoints +- Existing behavior unchanged + +**Phase 2: UI enhancements** +- Add EntityBadge component +- Enhance SeriesName with entity support +- Add Entity Explorer page + +**Phase 3: Default behavior** +- Consider making `entity_info=true` the default +- Deprecation warnings for flat-label-only usage + +--- + +*This proposal is a work in progress. Feedback on API design and UI mockups is welcome.* + diff --git a/proposals/0071-Entity/wireframes/Wireframe - Simple idea - Complete flow.png b/proposals/0071-Entity/wireframes/Wireframe - Simple idea - Complete flow.png new file mode 100644 index 0000000000000000000000000000000000000000..2496463e0134e1d1245f0931f4dcada8c91b0c7e GIT binary patch literal 218678 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

!hjF|Ut@ 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LvasT` z(aZbU{mRK=Hs+LK;rCDXrfd$Dc`h9rStO&7Jy`SM{WHhjKmg{`@*-wt-umkiv!`z* za;SoEdD^}bpdDiCHLBMU)A_XcmDih-(li5Hbx+8l`dM2lPhYE*F_CRPLPr&zJnW9Q z_4c*y_Xor-_GJydJbNs^jl-B) z$T^LuAIDBxvow#Nhh9mAOy0Wn?RDhIh(nL*GHViw3lcyu)(%Q>fETqVk^Xe=ljgmc zvro?UD`O%PWt3&c53Fi7??bGgefKW#)>Nw7Gv#!#aFv1`aZuV~Ts8=pH$y$oSYwO5 z1cRGLFh{)~3`1fz0S&#eVx;)>?FBv;PPvs8nY(FM$eP=?9*Z?%Y327@ucSMo2EA@A zef|)KlC7Qs2w|U1UO-E-w6RBU%W8j!dYM^2C*;DI__nV`zFp5DdOYcqJfBA)6DrI4 z_BewTd6<^GIqBvf9Z17E+ZTK8-H|*9$v5O8-`ESkdAcKRGrIGTrEAJA`>;~PF{2Wr zHzqU3C3J3;n0Nx)V0xniq;w6E2wIuiL0Cq4-CV8b?CxzbZZc={=n#n&6?Y5hLKxTK zk19mv6m7wefX6qzVcJ@JgqDVqI`(=@40-I_pr@}+**dCjfOW}?SY1hu0CmXE3|qMa z7WQ7t_hv(fcNG*`%ox2l-{iBU#ru9yTXv@4&qbmd>(ICfFe(ao|Nr}k_Wix$uCn7& zh@V8a%^pXymQdemSDI|n5x%_$MRHH7w`{U=J-AQKUG;@rr9QM|r2e4r&HzY=2F^$CUO+rs$c^)hYKYZIwo89Y;Z7YOmPEBtCsI=j;Co!7`We&*3 zMz)<9(*|;vpfKQT$dJ+9iypg;P=q5@$L8gc&dPh-#N4F;n(?hn&-A>4%wAbqCbNK+ zgxd}-qK(_cvE%Lz>Yd!tHcMte$=oYG47?8!HZntOQq+<2gZ;6aN#r2-|I?OZ?-lA%<{HmY*=4Yj2sN4puSE9ewgKOB)aJrKo zA<*4t1zVE39FjY>9AYpUL25p2#a+{>UJvee^BR8DmrQ!FbP$!x%Yr3k0w;B&j_qH= zI;*_NE9H;KESV>=`y{|r3W8|*E`zt=B1lQ#BnK}NWY(lzwoB>^ZN6VYI1_$;_d8%c zsw68^en$A$0BPuFA$@_H%si?I67c`H;jo+zIgW_}qCz5Tlj1fXOgRNV}bJwRn3sX0&~Z_n24dk)O% zAT-;h9VLQq1_O;#4Asv?_{T*EX#&*@ly_b-(?!|t=$0|}0YIw0L>TJlhl?Q}UB9uf^xdI~B$iuWg>XkMV(7*|!nG^Ayxc#%ejk}FtRi#l_t%>b z-WfV8BLg%AbEXXvPxU1}Io+#x=u>vx7R`NDze?9VIp+OD;_1*iRsAvQctB=AtlM0A zD606T8Y86H(Y1RuW1buEhwIV#2a+B@{=)|dn|dmn;WYl$KfGTY9HR`f=59wHGq!xE za8mM$+#R((fEasKG~oK+A=34sX)0=4c>+0a-FvX!yWdhHG)=rl4*I6ft=0*@d%@q_ zX6=lU8geQCH@4@wb3+}Q+f<8jJebFSZ-M$-P>Vwbo*N0o8pWWa3}as%hDRtI+56Y& zJMT*ozaO$eln?W-?9do(khO(z!}?n%8-&~R2+hL#P31@5+CEt-Pb^f!Cp10z)(QN( ze{+wrUo^H&5I#X}0~heG*8c2zOpO1?yT7@m{n=GVl0PEfJ^{X*=icKP!QWhLcXr-5 z<^i9i{Oxf#dlv~t3GSUiu=xYBX8bn)tEuoW-`d3RPuuao@8eGV|LpNRRU~7@J3-O! zH}1y!Q^}UB4|&Zx-|ik~9ZB{bIGt8)yxJ%+^NL{TJY-_Ss* z32?I@W(Ud-Xd2gmj0z5HuIh8VByFG!cpZv%H58aI^5}7ZZLE4wMx6g6bX1bST5*}en^d%|t@!m&JOeOa%+46q>YXn31a?=iPkp}J34G!<|NN(_>9dCGQC@nWh){IdmnRoetvci_v`X zIx;}g{O5F|+opFf2hNQQ{~`7kSo;AqHhgxhp>DJm1tGS*c69??J7h-LIP8#_NdDq#AY$9aqCkRQ8TQ|y710po~e*B9kH(! zlHk=e)s73OQKDU2nPh?#2puSL*rv|d4=POd_5fNm@2SSgjM+baQaMNF_&%OFy?o9& zJrm^K74r@_8qgA1_XD&qdlA*=bZEGv(MUVuVuJsqMgQ%(lZ%JDQ0TWy|@92NM}8j0jfhW+*4^UP2P{3UUCj6 z*U6prnj>*OlP*(i{OE&KgeM*W3M>}wT$nhW$+}52qd(IkfOgwlR**BjfmheOYvn3m zhIwW_nc0mx_`Ma0;={VqWm*Lx#6IxEmeiollIVk$knpQM!{$P(LRt5IVAoY^q53&o{i)Jr~%5OXm z_r3^~-IK#kBhQm@gj_Qmu(hZR2riDi2PXLguv`ren$QlGdk44l-?8L=OC&V_6rpn0 zyN^m4pLYF6T0+kl)aggm7rcHd2$&7@9;XF%{d7?>Kyf@1;09yE&kpiFP-Tz<*xD$coU&@w#O56O zYAux@6b3TiU7hzuy5j1JD`;7X#dSsm3(q%beA#*?kg02KTHUkSdg*bH)ae{&>!d=j z*K>wcS%-Y~xN~gO($u)29z3DHk!GMf!0)#wQRKO$++>2-Fea_L-varfex*@(K4vrl zWr2(jA;L|@kRf>%7^VzsntOZf373?9<5-nWm}MRTOl6;Gq*s6fO{Kjqza=z45wqI| z+iz!i&1TUgEfL2##i{7MJeCrs;(YLZLX$bm^7{cu@d(vhl}3|zXIC|DKTlOkKqd1$ zmG;qenVZ{5_f``sQ1g5jw(v!Iyz&!B{dwYq)B_QiIheZ010ti!H@2NC)X+zK|Jx1a zt6kC?;;vF45&PV-PbA{S%E6AaiIubZU6;FZmE@vvlq|KkZfX#@V&tWEaJ>X~=&5ps z!G)g?*%FgojqVD4tmjhO4uxQqUv=0?{&dQ%+Vcm(IC|&2X)YRqaMC6@7X68JTeK(a)iDyDkjEf@#mBS|G4A`6sHoL;i&uKwvt$R)%vLo=Zyx)VBBWtCXJCKu!nye&i@Ypy5e+i# z?yf7+=9~;e_n|x3mf2Uc;u13?{j6RXV|uvz^Sr|pnKS;zGxZmJ*ehwv2Nf+m%$r-X z%^|j~?b;sW^p8SW-~+vI1R(oaKvN8oSnlzm;)B^_RN_o4))6X%Wc#2G7=+*?>))|f7D(bsDGx+|p;7*aZUm(g= z69C7ICSWl6(+77Yh({(}bQN)UA^t>20-zjvJ7H=D?|(u_o-~(!C0?k_keiam9{BmJ zVb_iIf@`sc0dPN>8cMW=;N%ZTihZ0D+V`rX`-HFmva7@(`?7nDL3iMuQdHH}Wd zwj16yCR(&e%iecS-}Nr7|H&)Y3zu6|RY>0XkER#=VH$#6S`-3N6{6T%LNo@>;Ap0|>P+uQ)Nw#;=L>Eqnh`-Tgk zlpID>9mb9vb&J-dvFEP`nT9n>8=e42^OuB$_5rr`@i4()_GAqD@C`(V6nh(jduPf4 zQ7*-tIfA{rI+7_FcE|h}%)Jn0=XRpZG15==vlO=zemL1sOeG7Ac@mh;3D`s0yBFIK zE((|9k_K))Qf9LBW}4d63d`z)zh>r??cy1xE#P%%t!&VVjM}6ZSk*TDzjYFU-4c3- zLAvB;348kh#mRsNN?_W5kE+i0e;TH&!0J+BY6LV*IJMTHR+a z(%ICmAwK(+A$fjOd5GpzfO}cAAaPx9Z0TL)&EJVteITOZTcE?4;qa%zdhF|m|{~>$#w>e`0 z{TvZ+Gj1n&OYcBPiof=^`wCiW*{=@3egMJ0 zlOlP(V8$&SE#7+-Wn;MZR&t=W5zbktqJxMvhBlyzRL9+sit@6%Z!CCw(Te_xM{8b{ zd3AhN|B-rb_*$QjPmC%7=2^sD>J4tLBA!}tLd}zd0J2{gWljLZ=;(LS-5_!Wz0lOC&iufy$jZ*GiffI0KX+kl7Z!x>8K^rqnrCDkij` zJ%@M7MK=txofcG~Eqkboif$0C+4recS$>HQ1vcfE!Bd@S;jW^cy*P^K!svVxHx_lrJteFy2 zpjP05b-i!)XrENIgvc%P!1OR6PyGoS6;m<=U^@m|c@NHp-dqr5(tx-2ab)W$%py@` z&6c;#_gqS3yE)+I>}PWQ&r=W9Bxb){b))byCG}*Fn624kMichXsv@(BuvPs7)Sa_P z!TWuT#9}oA{>t-JvV=ZHSG~oqt=S4M)<>Oao+~EUCd3S|<5Adv){vuXZj>tP2`3P- z9oK5B`a{ijB@FupR|%U-pM*1mYo?k(?~LMz*YNg?CK5C}KNn-D`{uVip$)0T9M|k? zk`|Z&{UUVGQbUIli#nk4fA}49o=gqsZwevO4A#*vVcDJYb%C_{h=={QbEvKyx{zef z0v<`O4;6X;4?r%QTQo8F>7t*x`g9+;-buD2Pw>a(x)|5Py;g}8xqMhJ0PgN;%%tb{ z=n0yc=SA8E=wc68S-2jl!_BybvTfWd;&{5Cq`E5`aRj3@> zpFz^R%1#dY%u-;;J(WSG)w5S#pyG5ZoCrR-$#E*sf2YPI!*B>uZr<;4D!z&cPM zU#ybGD~7zRp~~)&WRj2P_NG5vb;H+JW@%bK&?%Igx70g@7Y$hiV)RxRo<@s2c#wJM z&y4kj?jF}p9^vk6-|rG+D{~I|jnc<9K>TVT*-~{7t-6V%gUw0vmU&e2s;Xbl(eG+L zEVCpy;v6kb&RP)P|MzqFCv)SJOL%e-ug79-*lr#QnKrxi)O)JvTOC?X+pp`diC96K zl%iy|`0_e9u^7ENhfE5S*tYYa=ZhClpd{QjyEUh#*mtg=@=U_DUw6fSW~u7!;-am> z4)*s26IvualinshKl4C0j+IumtMHfb#hv>GP---;ilv#mDM#-j47NUJO`z;BowA#$ zoza{)m^RttdhtB8>G-1Z3gN5UZ=17#)M*NwhALxml0gtF7UrrHzP_k7SUi)%wxX}n zDrQbk%bq@i<(#|_w{D7o>Up=!q&m*HeFgpn6*rve#Hdi?^!!ZRQcg^d5uMbZ6p@%T zclAOyF+(HeQQIDx7Hi7ijGZP?nWC~79sZ-|EF*M4%OjJEznak=a{_tHwJ)n!-84(5 zZdEgIgnB8i={-X~X}F}hOn=QMYkEVs4CRQiw;I_Q=DqkW;-|b8MS%v zUh!RpTyjVMGbTVkMbxoAVtLhLb%FU!pbmRXWa0g$T{jLmKi?cVeNd<>Zkte{>N}XG zRlIW4@#3V?fMa7Mj>5JhN6ZAJeV)rj`zNob zcpUn#|NM%0qypmJLl;%S?>$Qc2j4#_{c&H#5zix&SCY@bemf6r0JTA(wXa_4g@m38 zXN``mO3xj$UHhXXj0T$HAXiO4JLf@4tpnDhF=4K=F6vXy&-f*xIk|)cmYwNKE&LBr zsz&PrB_0d&ca*ZYVe1QH!oe#VXbG$>xRMCwVE{J$4VIvcc8}eu*P^kuI5x8H`1$vb z>Lfo)Zqv}j0FuGwrJ*9D%PdAHOunPxq* ziSPaA$bS1tIbrGksEgB9X9{pgJR1GiFYej;1psAg_zMAiyd_pAcqIDDf5+;6{udON zy5+seKmBmCfAd-+8e}f~4IcOHUOW@O+~2m`vo#gtLfd48*f$vn0hd!-0b}JC0P67< zZ^i`$jDdBwoU(79g%p4P_;0YmrvNM6e31)KAN%&|fB#;7;mYgl-=;@kp4M>h?-tj; z5YqqhB60Lz{a}mZ0FYGPnr*T9;ct(AAM3T{Gz&yDeA!qb)$Tr|SRFP2?Us)YIoVY7 zK)75Iu;L{jP4Kv-qOU~QSnlHx({W%At^wdtZS-wGhPVg%lQ{J?VCN5qYNi8RsG~y} z2k!810O|;ite!I9NZb-G%Fysvd=%F6Ck2mm6z>dms0+vi7!dLg6_}QxrmOnLf#Xs= z((RtT)-6uzvR^!aaf}}+OSik%LrLoGXQxh^N}2=*jo|QBNhWxnFPO zvFCTIjhCK&AP+qu5J-MVD1oI}QxCw#YH)0>1t(s`m)%bF*6%KR%LUegvhX4*(sy~2 z!jmetB}!`p{O(lgKr);TI{W>h8v}TuuZ(GC>fyKXF3&(~Ney6C-}MNj+I8iOgZlnm z*W-1Ncug)+Glj(E+yV*YfF}_dgvsuw1MB-5$-KGPS6!>5Y3?c=h2D9x)$803lHW#mP{6S zQg3!3ILv-w!G_{9ilA*r|C*SVz}yLCZhs+|dD-VHqppuF9UK-ES+ zjNC9jaE~!Qwm1lFm2AHD)ih z29hA&RC46cKogeAllL>>O_=8Zbu`l{*G|pqc zP=VdB#r0$Bj7GRiR_~1;#rKc8H_qzss9$Zj4>0SpywH1%=yB1$XfY~9@>=d-pp`Av zu6q^nKm~TG@Lm;Pgl+yxGp;ynvLf#wgC;<8hA85J=} z4#pNt7V9b>)e_owj}RTj%8Bj?#9-XVW!UVjR3|yHx{Ysk~ zk>8wY=6v9OLKqv>ecmceT_UU^>t&*02J9%CO(rzor2^_wU+EosLcR5-N?ZBozpbK) z+WZHglKIQ71_R}*lHoamHf-5!scrIcS|aB0*-449LLsZIy3&RFGr{m(O+%Mo5xgeS z7k6*jPQS&V?@43q>PeK*HGKkqptfIi;Sv+`8k{ZM3v2q?s}Y^I_XU`-HA}_(BUbC=RfhA3LJbzFndfgjYK}? z5}6ZhXn}z$*gAeoSi}t^FEibL2VG3aY(f{2%BQfw06hQUV!R7r#R0ZSy#!PG3#45@ zzr|5D!J|Nj!7pUFREY`cBkdnv>o<^K9D~C~Ooa*QgqEKM4=AfVgO;jWae16K-FV(2 zI_xtLv~+x#Fhjr9uHwM&8J_)^pK{SR0M!S#Ew-z&D^4=rrDJDwEMaX70v!uiMhFN_ zV1cvqK%AFrm3z}9Q2LN_+QfTOe@Zp^D}jNc+h1Q1e<*|U*@}$b$&7!LshoK;Iaq4n z4Y&T6?L>Py_u%V2r(LFuAbSZBu8XjF&r|UVM0ZU zr$*yg-LQLSvb~yoh@cR4?hb*)vZrKt=4uvbRWhuQZs`l8s8dWPWJlQ68c!H$LLt`mxv%$5}SJ?>hPtAQb$wgHt8NU&cD zucePBd`T55mTdHpf=CqPC$6`>P=ZTn36m}*LUF%MH?d+b?NobCd3Hk_JK)AOBjO4KbT3-5GuU7YJbULj zHzK)&RPC0muISu6`gpRd37?CCPoUt1|jYVauH|GRdU#l~=fT0N~1z zpSV!fl}1LjzOd_^7=Vu+pHv|Bo*h@pmU+ zf1C$$BrHt^7RX=ZzDVz&0d0%B4^+7va2{eNgM+nXN8ZIdrzS+5xpUSH%CQbcpA9J8 z$cqS({qX`ZLZhS2sIWYNWB~6A$OK^#81!u5I4u3;4sVOA=gg;tAV9of$1Nq|9qa)Q7ILjFw#7`7|w>SQIokyk5NzbRho;$Wy+7_o- z6rdJ7e?)&Rv?bb{3X+Qkuj(}nJ)d{J??-45f@Sp7*F|A7A3j@P*@*?x|6^dlk2V4b z?yT7eqyezjcFg~->w#mlfZs)T5<{(iXnUwMp#U2&cyr~VMnK6AmDfE);?F9ab{kZw zFdw>dQCnmxULe)>M#EXZJuvaYd--@r4|9DAB*qgqo0}rN=5P1jWRcDa>$@MQ=$E_T z^GbpXC;If+Aa$x%L#&hhn`EZ_dk{}cXTkYb`rW? z8%gAPmbPn)u2eV%C^)DrhW zp5N4Y>4Bhcmn86G?A91B#4oT)5r~1R`A`b{?lkCeerwVTSj&w7N&jJZAv-C0P5hgI zNd)oaR^<2}JMQZ&?z8Fq|8_p!WV~!iVgWVnZw!yOV@oLQ|3=>^c?Vr&s>YUC*n_XG z--r}LPzB*#e@WjC_kzp5Geg!4}|g#UOS z`BZ>xsJD)cuhpytB3OZRpeFp|&CTn7o1DU^^dGAq00OuJ(11h?Zh4@21&x@kD0uo8 z)gGva5P)&?HyFmA2!xRbn>k~Qd%%KWOkI4-#oG&xN*RS0eVbQaGO8hULEY8&m&U$L zGz+>X3M62#hT)O;-u@WQ$Ov?;HKG|@N9|Fit;RHBZ!M4l1cuqML5N*np%As1#0ZS~ zgBJGFVa@H$$Nmee#|6wJ=Op60)JaItXmhNTl@`gY#q!~;7-BAq5XEcrWur~uw8gOC zzdDzu z!?zFrdx#`r$K=knZ!ZD~;}%f&=33i)K?HNf@qdXH{|izhu;pU<_UVHVT9&H+_XFs% z7Xp{wZXIU6y$A9fL=6L$Oy7R|_wVHwHp(}_4grCW|1?JU_y3RB0l?V$ZzC!2cYt4P z`(I1E1k0do9g6;X&n+kW89+pXzAfu=bTjgAFssdXh)@%`xpkTYs%3!R(sY;B`*Q8I z7`Gs7vyCo-LkDgBb09grSKScD7zzBo+I4*E`wUbP^h1Ab6{~@~3%r4lO6&;GB@8Dc zc72m9IW~bOJIyyY@Y1mBX`8XAneceqR%811F(eW`0?8Y!tswX-bRc()+`Rnq0m=Ru zaI)|NFa#Y6b#M7wma=~A04pZr49H%#y$uGj#1B*yzOBKU-#`$l!E%SObV0wLOCP9b za8V$1Y=!ogD+4;>h&<@gnvL`o4O=*?_ig@tL)IaN#26h?RN(+OobxLvSmkgJV`Kuo zH91fcQJ&(ax+y#XY77N`xfF;VUN_|cr$E|BF|Ju_MHjqB34<~Ll<)sqF8{ucc1t5F zYd3eH6WYOx-!_jg;+?@Gkd_i8nc3&^VQav-;>byLDa0}hQp(wU$NT$rK@SieI8wVl zy!=nGoqzu}FO_9uIAwO+YrC4h-9TeNDiaEyRFC?Jn*ucTzie zCT^>U3td3O4i+G~R|CZM9!}>xWY+|G#>SfjS%ZME2Qrqe7s_8T;1aAtGyH40c*f3a zgYw7}HKD~5RJTWt9ib2)#F=j|*(i%j_*yIhexIrDlyYpjhA4VrHk<$So z^4_d}OW62IRvGc~#xbx@mB{mhU7Bqy-e~-79bl70%)>@GN%l)TP!=wNCTVxYecn24 zL?@%#3P8+5mE_XTx7KU`1I#}L9}0lKdejn_5P5BEdBQ>x^y~tadi+jHB68;DO#m{| z(cQ<%s+!&>AjDMNPVjl3RZf!+Vm^~_0M@tkx(z4@ck(S{xY%EJ+7AYO#awflT+ezL zQnU5y5ngDr6hthymcl|^kl>17v-iPCgkp8CHq7@m#^w$B{Tjd;?6rh$s;S{n5HmN} zcL#t#ZxPlr2zCAKyY@l-NWs33tMNcuq_zVVr{H&0^v2-#LPLYg^_&Tn}2A zA@0bfL9oqTr+YS*j1H8xlF60E9*tcchSCP>5-m=ge&UhIMcMuQQz zE_^+JObRz$uMPrK)7)k-r8V=;Wy}#pO=mX)=lp0jX3iMs`jV*^lQ5y0ThI73_1$Vy z_S>i0o|aOb0G~#&GfAy=F?Lm>8s73iV|f;Btj6-xIg_z&isCY%1Ghb;WBfDDvdf|V zZlV2%VPIUSIkmdEAEikE?BBA5>WaPd6yVxd+dJFplgDi67fq|?&AQE$I9m?_I{~Jx zcdwQC{FugZuQfZi`Q!qLS;9HVK~l!y;D{Kkz_Myc%Sd$e_HFgjVCERFnKLU8W^vr) zX5$fIu2%@y6R}9&Ve6_A=Iw>+Zbliv!_9fnig`zYWQ&P@yb>Q+m-Q(f;H`89eqm+* zDA?~3*_eYX?R>7ln`2w$3oZ{Nb6n+&_Ft0hIr(l+P#NHpoIU`{Gf2G*{^8YL%d!^b z0L2Nh5R)_^#8mTfu8I_sER33ALNp5IXbQR7nk5-^s%R_`$Fq7_M-V%I-B0G`Zx_#r zw8RBkO0(dAr_|k+UT0unn#0|7pm1#vnXB0d#8%1qtFI%iWR3IN(n)8m^^Ucy zXA&xIe6tI%d}=sgjkMe*S}5UqoU5K!3Na!ptNpJsbK9&?ojQ5V6R3kT3F_uGVuJDL z8#Y%lSt`lsYaQo4vQMP(?SNOnigVx^P?b`W!>(vrGxCYUfbT&*-W(s1xU0AD&P*WD zcCJ`6qgss3k@lhPV_m}dV<5|Q^!YkL)YWn8a?3{u58r_+^fX>$KfDCKw%sL<{^a?q zmSP{r6+5O0McBKc4Q|L&zhPraDH1QySsBRpDTcGKRnMzJuht&qr{Gylr*Y(sb^<&U)J3~#0J9JWYdam=o>M`B7NFZe{y zJCwcy&O#03sI2~@k_GO-4uDvVw*F#Fv}K;;oaExTABB55nF%sxip)EdOiQdx;r<-- zkjY{ps%DUTEpS9Br8&1bw9N<6KlGS$5?h6<6bmrR8mo3hbzVnx4m~E_($!8_WnE&{ zu6gb-HnFRk3y#hgyPI_TwRd~;F^%N8LdXe#VIAM>iM8Z#uVoZ?oy55L5Le)V7J&Mm zM@1K^5k|!2q0NeiWA~nz+48JyvmaK`89T50JxFu1pYL*lB+OXwlOizJg6z0VZ6G6G z8FuRA$qMk$FRHO51tf5=Py?Ia0U6KAm(X&-920r^)VuxM?5E99F{`)%?m?C7C{stZ zn1VY{SA<-`{H*guo!=tQvYGkDn9=zU3^PL6u6;$C@*le__ZvjlM&2*t?fO`-KPG-@ zpwV-m$i4)ZnCj~+GTae)+iWqq#~J}V09J7+t94z|ctLa7g1oisk#@3>1%Rf{p84y6 zWU5<;3FbGkUsXCoXXN#+f|}&wOe23!PN^M^r$y#Wpg*S=VOO57bq|Dk$#c2RCk+zT zE+HIbNC6n%v|@B>kKHP#4@;Zlmn$8yPcp(F5jPrUz1pEBuL& zaoO8)ex6FEoRM=Sh{;|XK!Pa$`SU-Z5iSnfA35o&yXCRkRp&IKOk)qMlNR-}WYh%6 zOUCMUiJv(*bM4iE^~_I?0SsCLl>oICJUSv=lV`H$)_w%&0rTvEq9-hre$ z+YV6w2KYSqa$MikjmehRulL6l;bV2hT>?`ju6#m{^-PoRD{L2^ET@{*uloDJP z86w+*R2!~3xc}U7ZD>R;Y<ckGl(l|UwjL9GtA&=A}XnKBLf*ohkuhviRR zL7lRD-jD2ggkzk6w{JLQJjF*_8S>B)z?j=&v{1jZC*HV`q8c#L_Ju zzf-w143z#^plHbhbd<4`3af#9ZS(p|{dUxX#{d6*+4q2) zBY9lrd`{$P5uZ2-M0UFJuR_lffwRXB!znvKWs!{+7owvyrk)N&%0sHti)!!02JS5y z9`2Pd60VbB%y-a)e(T3SR}IR;w8x71gKkI1`eZjiHu6tvxEH%q!4?SEKZ zC3C*Vh~O|_{Z2lits#EbsZbEI>+1||wu6051;3%3nuBY3_&5-f=k~V6dL^lK!Mji} zYaj@{VSeHW13ZitVI_p*x-7IlarPYYWt9OzMt3{j`3dJ0TRpSNds_DQye^pwH+4C$ z=1iTDzR11tvImFa;cGNLBzwowIuq_KRVWc;qCHqIGkA*WrQ`{(JcLE94J+=KlYSJk zH`kbr#dmO#d+!11haasYeU>rtQH4`{FIe&V`zP>zpcJSi|L7?sB**AY0?u*wXV}1C z_(i5I3b@S9FWR01!_KK!6R3%aYDW}oTP}~^iVo=Vtnkw>pcan@WSO>$@pMBRDR|nP4B~5jrMDc);A<-o)opnH zq>(i5qj!FQJnHOvzD~(iSyT4uZ|szLP3)KWUC#~Ecya0m*Oa>Z+C4OCGQR2pQ z2OJ6m8sy>pR!t+_gc;w0tXIv|x_-b;fS*>RWv;jqhc80ePxle}2ZUP-xi4x0s5$7> zdD4B3TgDHPmL%XNKw5mC!+nqW(aW)w=KyF5O{M)TVtid$2r<;M+_YpSu*m)moBM&^OsY}g{H4vkpRbn6UfwX2l)-#jl2^DfNJY=-iO z(t+VBuYBHZzcoEJ76qevV91_SJQ=RikS z4G{M;tD@?S)2qB62d1c3;(U55(MM=LAP=u+r>+dpctdQCYjQ}jy7jSbu-HcwQx+$r1-NT>xy99~tNKSiw}8z1xu@k!*lmRO&8wNE~2v z^PM5!S_#E#MQpU*n{10#EKnuGS6>O6AS)X-rTBOEq*?iQ<&?=LkO`ay!hG*Euxo~& zUE;;_3DAROKlc$5>C9b*b$8aG`4-MG=?Ov`H^c(MXH>DagX86h5Go-n5PJ5KiH4My zgM`MNHCYgwPc$0NEHoP)`@a$IpMNVCKm)7I8UP?vL*{$XfyyrjLZ-t2#;rdxb|1)z z4mUXgM$DOZ>0tJ#*_H`&ka@6E>j3Ukylpf+i$kf23WWEBq4g?T5=X zMohT@ivp2UbG<_ekd8|?+$^It3JrBw7QV!<2AZRa(>xT4M}0Vd-r}er9r0c=h5|;B zH@d3PBo*ApMQl(n8$E4}&P6J5-UwzqrW+RpzG1M#0@7{U)jK`uPnfG7a<-)AvbR`} zA2fS7gJAUxiDwA^2>`yEEKDr0&u*1VHu_>DXb%xGQKsj+AqC_$2A{`p{A$!48tNw87&2kM;Jf99Q;7VuxfTD7HvhXUS~%Mh)kPcSuahunpRn0U;4)2I44v!N zf%Cpj8j%lNOQ^`_eue5MWldD>7?lq9Y8JU?;g`n~67+bUMjnv-LF-k)TT01v*rL!L z1gH@BfdZW$sO;^X>D;>QmMGF+e#B#WdjX7Y@aghJF za%;u<(ts`T=+lZ1TnB%IdL^MM-HLn_*mb2edvmk!*n~xAGXpbpkNN{EaNhL>E`ER7 zN17}=DfQEx{K}>Bx%cJ=OBdk?&piMRxy%z=g2LlibQB?ctqMzTu|M@d9ui3$cfxR) z9@Cz%$}}rJh%MNSDH*^Yi0$M=tx8|;9O6fjrm~lPs7CM$>}p zFQf@|=J2`jTt))LDBGlK@1?scYyAT$TE$$<*C&{pm{omD^<9N`YTOV5zR11p0<6fb zpe`HnbyqX16fsp314zeo1S;zH(snukz7@VqATI0-m-KGt*mv)AzK41O=&?|@E^zJ8 zn4#{3e|Z2c=38U5lkWlO{H9jqeSpR|=zgn(#J0P8QP6T&)0|KstQryep>Y z3(GLnC47BG_?oPoQJpXkIjtwZH0v;lspl*4{#2p>-`rWnaG7lys8+4wreFeRYz)a` zSh{sB0fh~MR3nYRnw)cyvA0uTlUV$l8^ zVD(6_Q&Z(SFIQo?zK4_()X7Ijmnle`i4J1Vi^4!ysbSb9ZbVwtd&$FE+tg+;glM71 zrt#?f#ZBVkIiMNM?~j%VRNF(cC6=2Ays_STA9>zBS(79sFVSul_ck^^E41hYsQHGC z$(|IQwVpZSs+w_Tvgr{0i_05p&OVUxl}C@L+rf=O2vj3j0IiU zP=Hm^PPvdtLM;Y5e(i=v_8{Tmk?$HM&&{Af^s9tAR{;6@5-hV&E5QH6^$CqS)je9%b@{!A_Vwu(Vc7K>ZwcCPTB61Jy3+kWzB6BG${2%lX53;|~wolfdssG4@=} zJsaFs=}T%D^L6N7MXa~-B8&0Yl1U&EJ4P5?bQZYYB5cP=`p520DX7?y&^ftv~Qur9-f1jz*{x{Rc>>)Bb0i%7^oz8X@2VqEkL zzz7DI-|I{5Gwq_`=epyTBn_m&sXds9z{(w$36i`^0~5GlPwD1riU=ewNsmfTIzB`5 z`>fbAVY)-1Iw?KHd)ZoHK294)|t&S&*6AJoD2X` zmKn2OkdIoF$qdO%s9~06Fns{QWmSW$qXvS!gUE-`3>F$!%!$?*x0y5Q1y_`USx;HC z3jv#5$_$Q5=*O;`9-B4VF<0VFZAL-e0()&T@ZlsbbiDXy9KPt6s{PswHD@uo)nGOW z4TFe>m_1W7XIe}k3W7rI1P+zWs!p=XSC}f zrpOT^C1}B}41Ms}13THp-Zqj2R)FkQ#m~+6Pu#xme0F9)*BPRlKEUzo1Az92I?3tn4Nu8{^t<_PCTa`2tm%pm!>adE<)t~<;kZSGprXUfph0$W|m+ma>F<&Xk-DChue+N1UH-bd?xo$*p0fQu>r>_-LJ zDicr{McX~yE3iT)VU?!SoRcdVWrBVJ5yN0!du1WX>P>wn7OcXy#@BY605}G3QQhp> z1*=>KZto6G8-E^3;jWF~9kYp~tbYJMzIDa$jYGK$ zO~`EmsrX*N3k?87cy%0reQX6hyFM3S#DR*!)}I5>)w)43_e0e1g_h@)heUi<0Opgp zU0A2Q2gvRN<-sPhwnLoM7OVg+muB8<77)J@UJ% zrQXkYAvoYpJ@faz-XY*JXkQlq-u1D>R(zM4H1MV>_6j!T*%$2Bet*387^JrP_U?(> zZ@29bI2^;b*kZ6&e*gUWbClQSLU9S*u-6XUQnw{l=E{{T8*fAf-X0H{_JUh_+RcT3 zl@UHD;4%l!I%)h{rZ0tcry7n34ApP_)FoUy=Hkia!P=AL_&zh3g4FS5CM!4zgZf=> zIu5IIZg!M(;%NyyO?nO$qgHMcS`XMF%nW?s8W%OfL6G;7`*y*4V zIOl{-GB)7PeMfEvj^1haOWMCJv;hcyb?NOq(Jv%#tj;1k29MnzPP+$GOOxDU$9PoY z5$fO&rjSKJ)-r+v9jQ8fSiQ=@`EU(V>Zqz}#ZFW3VKYv{R*#3}0zW`~I}q1sRTl$^ zs<8}|){RS9wobJtfcw`pusF>;^gvy>lw(;pCzhqipI~IkVgmW343lnLRJHTLN)k?THO3o~_+QL?pnk(pQdux z{xO|b5hxta(w;6NIoS1TuJjWxdW%FRynPkMs4EVcVkTkNg9O@t@rUPzYz(XKk)&Lg zN^}W(^4!fK_tq;eUTnxHn;0`^i3=TwYPSb&GnK#;5afm% zIX%``0M>M!2p&CqcO8T;FDDY&VTm|$>z3fInPUI(HT?3j^>I63IQ*&-thaza?UN7I z#3Bi|J?dBta$@NOhsy2!%9bvfRKtb24S#T0Pc!P?&flQ7^#65#=VqS=;|>dl+wuGB zE8J4lPo7J04s)yfTxmI7DOcv*lEqslDIF&$zEdZoCw^TXN7xN#k|uj9LIdb*tFOPO zF*IxI#Aty}Daw$mm2{&^Gn+zgw%P|6Y|_~$C#1TQ*KdtGl{GGRReBN!*Ke7AZ|q22 zU}-epY3JN(hK~QW1bRb=R%Pw7ZTpl6yz1vaercoO`_Tw2kYvJn|9L#l5J65q_cb>7J?>?J)i@HhvXtB;09$;OgIhTn` zvEz!$dj)D9J^Ch)y%|6L`7KrB-V`wXcyVdp8PK>Cx}x=cMeGY#o1xXz&F84Ou!BRzSB8Ak}ZOZpNW6)+k`O0RV9yfyu%Vg+|pPNN{}q4QE# zz@Hf!Jn`_zoww?Fl;sN-;pyd(X3+z8E*@A=NvqDte)zDx%Zc?!u8YkyOO|j(t==CS zuyA9J-)(JE?`vN??;|$8n(Sfn{--W_6=NHbgJNlChr5+fsg2y38Hxq(Q_0LwHajfo z$d#5O&3qEo`{Lm>Z$~T}$N6)5I<5lk8SaJ+*TtDBx0;ZhHQ>Vfyuq`&oI7+IM?8)@ zSZohp8OU}5age7SkX}=nl0Za679)C<_Sj<0k<;*1O=f#Ide_kPu_F=4%%zCK;CSV}bH3R?~sY zYVCxIBW6iq$}Do_pq2+#Dr)vv?U;_}4};-(FT<}M_-zm=>eT>5K#PI%a~q={ShCc6 zeZ2y-t9TW_-cgNaaqF5cE__<9z12=c=Y{fvqswsu4-c50e+9djW=t`8uh%8r_^DF{ z>q}Yu?p@9}a1SxA!B{J&KIGnTiN{i7{O{;5y z`;DPRke!x^)e6jD@dsr_lpG+!`H=)a~3r+M6y zS0A>XXkmJUa7Y!W_PaFhqm`f@Ws-zCT-PqFy-@Bc>XW8LPKzyJ0aH9`kN*Jg_-v5y zz&bIIz%v_sGTWt-PpABATBe!VO=_Q2b2zr|kuMuq7jUEpu3go%u+%VbX%D5GvFc$) z_>AmI;*0c}RZXrh&SFMudagjixI2C)|N;W*+h>CrT+jnc?@N9!W1 z4>-mKafw`nQn2o-gKhnNJx%u8m)*zqjeE4LubyNw2qUJ`2sDQ_-GjmBGzQ?J5iD%^ zO9w3N63gMn_?KnJHjZ$QBP{kw-Oax|nf@6|HvgPytlR&yMbqcYO0ocB3t0J@fLKph z2C4(~FnkEe*4nzKxFAm&o{1AksY&Djz0dwkqy46w`o(pyS)y9%8vVtL#?*}SYwW4D zBa1b|%rOQ!cq4r7aE_yOux}Hk$Ya@o!-FuIi`tqR1sDq^0NdPLbRmzUE(lRnAxmES zbV;sv{D;MgJcrx8!v`*I|FguxR35Xwv^#?wyD{}Hpw~7(Sq`yM2=fpbeKA=m;{oR` zA-u<}V|6yJ9f(ljg>vgvo(m~Bs9CXoRn-`1T6-0+^kkeGg}VX6*uV$di{XLZ2Sno5 zRAcU?IizGiOhgKb4wlMkls1=Ts#-BXUW9|iJ{79U3~i}6FeA>)cbJRa+03C6yWB;U zc#|vYVd0@48~*_N`R{EmkI7AUE`EPWP^u3%-+cd%ytDo##q(_ybq00SVzXxKr1_Cv z%DF?tH=Gjdk+vo04??)=k**-nbvsZ!U`LWpy!o*rDmmw+Uf&hbLOEB_*S8cXXQTD3KPNCpyB5z2(-^aEVq?#$dn^tgHb|@^ zHH|N}-t8yhO8nW$b&I|Bbmv79Tl1snr9qGI#X)~~aFAbsACB}w)^Cp;ZK*j1kFB4h zUEqisVCz|iwLM=QZq2p2v-*sU8eqddPiRwIOcSHsw0^|4WGygjMQ{PfU`11#Y(vLDwpl9u`t zRMwB3V-!dISTb@>gHBE`ys6i)2v!}7K!Al6m|L?`#UDofl*4`g+EJQ39o{AN=_J{G zp^yc1*N-V>h(`gX@p~=Db9}o7E;H)A4|xWi5=~NnYVn|)+ykVlEw^j= zI5y3kn)VqoU?-rb*)>{+KKM6L`m89XLzf#$9a3skcoeHJ)47UO4f-B)ngm%Y`!`2& zcF5-#oG6l{S2kOV4e(oQ0d3(Kb!K&T&YU3JT9sL4uUd9t^6cX4L^G_9*c@Bh;C0!0 zatF$mksr#K68+rkI)fjh_E@shCjG{^ajYP|uqr~WTq?-jWJFKGyLjycaS{de@mpdl(d36`5^Q&sFkRFt3>ym%b>$vM?z6@r48SZM!d%ar z2fh^EEj~a2+u3(*`yWvLxc&qHs{!CJmE?B@@ibFR&8hn4^^o;$=kb*%4{L9r8L_MA0x?T-^UctL@4p|$o1-^{n2{!whEkw(~}?by5ZqMF=V2oSo!! zSJb@$$v=@3RVFE)*GxjQ!GQR+1ox%XJeSxe3hLtp##0oXW+pX8uKfhR&GWhC#q0HS zuM?I2&Pic8YQc2W4bE+PilTpvzXA=jnlSz8lKOX{k|mN{Vqn|HRX)l~18G5%R%t(z z!YyB_&&c38omFo;dN>|m#_HNUu#JNA^|XmhY!kY>a;d(OC*D zrPs1ND8$j`8O9=V+?n;LJA*y%RL1%_GEZeKnw+WOl(%-0Ir?UeqT)C#Ge*MoT#t05 zKzVotqak`ysdT;R*2k=#QCeJ6&{T13`WMZV3Qz}G)T&v-(>Z5U21856W#~b@T*@MH zZGxfkN=qXaM-I9)Dl@Ef#)4K86M|$+&Vhr~y^w+8*sN-YF>QJX_E&4Jf5yymRzn-@ zwzMCSF{{mJ4AeO+j=@NFNS@ptSN^m*Lq#p|_2W{>jhtko{VhQ3Z6r$grY zzXD`<*dZbNL1WghQTwMDuPNv`69NGK;Nw=1k=mrka;+Ep1~4IT+Z$hq>iHVr=O=ga z0PN2Kw{B{u=6D(5yWex1?TfyK#(g%7E2dl2W#2X{hx>w~epo_Jcs?(1i6wloV)|0#|<$9Kh+6(K|I#|aJaB`Y@L&m@qx^jDp=GA&o@pz*&3Q23Gv?;b&h<>--$wRji zp_;YuQsF~Zx7HJTpFBeBfvL*1&S+Ofq{gHAsi869TYDlxrn7c;_c^Ky=Nq~Z`~El( zi()g>zxxMvDWf5|Gg{sC{7(6?LT|bM!`^#`HI?rBqo}BpQAA;86cn*gMv*QcHG^dU zX(}orU3wQ{XdwZGv5Zuyp#=p&q=b%?5EKWbiW z6RdeHj9yL9SVNA)@d7f^lroC4M; z<9D{#41|&kC)5k&X2Mw-;gyAmTj0pc4PL9tQip0ZHLyyfj9CkAmde`V?6S1a(8kdI zEZ-}=sZGl-^GgN0rLM^0Hz`D3(BzD2Y*j+}Ox>>O0ic!N;idpdxvG)!;9@9Xg9??> zqCV#>i2rc*29c#rwAh&0T*q~DFY=X{;?AmrYnd=ADAaXAZkakula1CVfrH7c5ls6u30SFMwkvqMB0$(PKQ0S4WEzSi<5bH&>b@Q?c1dHhtu-5gqdZI&9w+i7!OW*N=jSuXknpVwMa8%^vL&-0!e4Vp&^g%_?= zPm|}XIU9YCWarxka7s^4JD!LuM!&L}L2EFc+VSQS9+A}b)nR&tjuMf1O;O^~%lQ<{ zJAIMU148fInYn0n2FGtau=A$SHV=GXtJ?URqo(J2hmUCJU(G7dRvy9y{?PaAAYajMu}re8fKo`Z3iUZu0E>A28< z$_fT57fl`HTVAlu7SD)d&s^srw431SmOy2`5QQ7yFXd|}W+>4>(3IvHTl+^9)vnDh zJ?D9OZ($d&)85}58%u8GBF$4u9KF!yvSlwMK9z3{2O-W2y^fO1GZD&pMVj&}4E$_7 zcdOF1J9y;C=3<;1twD38deof4h=>%wB2vvfg;RW;y4_IevT( zY{;11dL!>O6L5Tol^< z+EG4X!=<;H)OZvR5V`8F>xHk`+Xd0RI;&?<8$G_9@`fSJRJESm1<+yMK%AnRN;(BM z3BEShiB8^JaIeK7&0by6_#s!<9+|1;?Cj-Hiv@PjALz??%PZHp{rirSeQIN<-6M$V zrs%qd{N=*`$%6;$%@}8dQ+-i##7^S zY)!sLZ;-PBojcTv?Q$bv;>DxM1a`Zu#%9ykaeD?bHt^d`9^y5>P6At`16l)pjbp4` z{Hu$|l_9xIcP~R8a9wuib^+}zHM5%c0S^jTSfR+rYmr_7rMC&8NLJ==X@Z}tTCA2L z8(^76k2|3dvU&KRJ5-ol5C?k#Xk-!&4xS?eoC;;h@m<;Hxv}^9M!TpBY#RJ@$Bad`Pb6ygE!Tqs;8L*1>I@^XxTb=`J-7ik} zP9yG5VTuWc|;Uj(a*4r_Ow}m-PSWI&;*n!GBZs-fRM{7{q)Pu7`Ow-s>A9pY)dd znd3l>w+rcnQ-u=2Fq~?G0yZpsnoK;B!SovoWJP%i>p3q)q_DNxIBg3B#C6u5jNVKP z4qs=ku<swfNa*>hnTv4E-_Bv`fb8X>gm->fwo3-!R-0U9V zmD&qBuLY&5E(nW!NF~A{h7`@_C=BEN$kN*s)@i6@Ja8r25xL{%dZuW7{BMam^`5_( z2tE3GBM84nAA*nQ4Aw2A6g}4{Jc-EOpX!TUetx*(*%B6?Nsr~c-K~KUmhgX1qjpEq zs&{>=;(>NvMprBjhKhD28o*&*g}ma2AC}2(Bf^EM+q4MVRqN--$P1@DbBwYQG2B1u z!7ca~vTjCd)w8Zp3pTMuTJ~1FF2uO(`)ZT}^4uv?J4Pco~~D_~TazQ+h_jL*R%$%+f35 z5lG>Pz{vcX6g!;stmXL{*QExhv=!`Y?1rA+T=QSkXd+<|x!r!r@N=gehrG&KN=U5o9kkbT9&fj?DN2KoI6yjKB1sCAl-K2xjI82)h01Ot&s*CQxza|Q~DCD zFl3WQrB;@Rdsa$+`n@5W9QO)s67EIy3P%~O>7zT=x{PQipDVW*-s**tVfo#Tc(I!r zA6-#9N8SU*`FPYeNPTu1SiOXd92IH(q}ZG%Ardc;;pJOj3A=50?)?qi+I7*3cmJx^ zP)B;3YK-5t741s+Eld3U-N_YF^3%=LMimQ@U5Ov#O2s3fYROTQ_eSI_Z$7*^&r!W) zSRAoBE?n&WPIF`a!i*Y1<2j+cny_AfSfhbxKr z7=07wZ7zL88YGy)ANY{HsLKfNCSV>GpDQS5`peL@nc4;to!7oydDkE7pvE+~p~znm zcE_z{|J;3&6=boH6}M@`8uMb**Ena&#!}#J_s%E3SSajT1WB-q{k6;Zqtx^@oU#OMwQqS zx%nqK<{H;e&5hACH~H8m7vlFu0vz56nXj%g+>f1QoSQU{?sT>8a5)ko1trTmH(!mN z6n9AULhW=gl|ekyf(-2zn}RMpm?PY}5T%kzSV(N7-n{PwQct=ZFK*_QV)5u3ByE1L zn}=R4x5P>a={YLSp@BV8c5nW!ck7RcC{K&D?k`KIN5s3Y=tR~qYZ^r6BT-%JgVuI7 zDGGL1YEoV{L?<=Xta@^tEhRq0zg7H;(;@H z<`Kh7`<5)JfFfRI!Q+(%5xhRt@6()$UsPWHvD%0L(j*!#s_@7$439B+9G>8iVWVNO zbb+ZD{B}=z8*{yfnZ%HWJ2w(AU2iTMnrb|rnlRNr0g?j38cgB}h`ddU@N!H(M=oP2 zM5=ij2>u_K26Yk}v#`#J3N?!M5ayi(`M40_yimRzvLHs4N_gRi6>OPOmYC%bID0qx z7?Hx7#UVY7wQbn_&(ItYqkE6sB3;!b$xWPhYvPS9e#_EPL;;-qhw^$*^uDvUjI%#C zz8Pune@j$g@dNphOqH7L?5Kus!%HZ1_lvm1O+Cy9q-Sq6R!Qs^}pcL;jRCl@T`aqNP>ZxtAbqcKHwzu|SE~*BR11s%1=CYZm za4?VBhFviE!nabhEpPF`E}}X~>`y%>;UQ(PJ4R{UDeUJs4Cf|^)5dO1!0Uy6ndyqB zmK18EeVGXuQVlb)7_t3b3V~6)7^)IiXUW@D?_F;`epWpWFHZN%>WrDVMDn%anBKoC zyw0kXjh3Y^zBuSZiAX%npF2oPJeHA=5aXy#XvL~Eu%B9*q!o2pj7yHySxSx%wCeFB zZ`C|xtmtNajPl)YBqU<=;i6RDamPb2F~c<-5v{xL4;Y+1u9&#Nn^6haO4P_!D2+pfa(X`KRLX{sUJHR&4N5d!!9Pl}QJO9I`UCg}}O^QC2)$ zjfg-3eb>&;y>e({Fz4qFo=@1A8jBRx8&*C0YXF*ybx0h*281}{T+n26_iY#I^rL=J zsemr!w!Mwvo;=7zLN-cxFye*QCg0h1PTYIX%F$w!%H&hQh^iej(G0ntxQRKNM!n`)E!?70ID;d#u#v!#Vj2 z%XI_;uI*@cqWJHLsOq6ar^xNLbLeLwCiZk28hJjBeP}K%w3<-;(w?G(12sIyN>mBE zfxnil0&38IMQyldFEydiK9kKm+!h)_)YpR$$;UEk?ODO1&aA&`7y5MkOz~rgtD-fD z211|LxE$O_urKp1d}i}c!oe8g2BF1X30O=ZiKmT&j-CQ}Ru#6SO-Qp%)yj#ff6cW< zShnksUntTl)#azw`Ddn4?b2itsyHC2fUOs=|+Dx^5W1|Ng zUAKf+@BvcA4#rta>}gTj=R#A*12dUOPb>_U^=gF_|E$rs=(M_mc_1|@p{V*ZO!kzM!%ZipY*sbJ&n3>v#@}t2bw14l#NY*o`IUSOYgvRNo=u zLb(pSk^LNo_TxNH5vvjAPc?ZmIm?P@`oan*^Os>q^X;8nR8pdlE1$-#yQ@Kk)n>F5 zPAX+CY7RGn^2P`inI-x#@mq>ttSunP7;_N6qK+T`J$%jWpxK*n(Y^Pk@;|3{d9+aRIq2sKe)&|SyxN^O@ z%qH53pEvC}KP$1$vnf@JGZCEAe|%Ko;~i_mh~M4`iaflw!wh&MH_{%hi|Xu$2^)UC z_`UBH0Mcj1OXIg=_jjn>bI8wAEL8Q$=PyrxGS*l~#=6Vui!(G|IUayXW&R)Cz3nF~ zPb(Z2{2^E3mn4!V6*x(Z$b}uB5MM*-z1^>NMfUB%Q&3lH11%$2Aq)2o$1J*0Hm zN%~EWnS2m?tA!+iIyt2G3WYDviebZqepUUQEIOK@iBrejBw1$P$BvV_YFE2(8s9a*)6bE}p zPxDJx*oo|hx7M0BFT9@1W!LewIJa9zHhPRQ)ZP@79R7NCRNl5R2bR~)au(Un%BF|k z9MaXk9m};3Qh;z;o#Cqu1+9`0@~G1UMFod|y&KIj=HIL1w8g$rjfF>?4ffMrcBrq} zPtyf51t%I&#Z>T4#dJD#Be|_}w{Lo#fR+Q>=#9fsrGxU{nb7~VOliLoBxPc_vy&4F zu!mOFo;O5qSwy;vTw_6H!Vqwx5x<*g&@;`A8or283IxH5XlZwOl?9$S8LQoT!P!h} zHqVHc<|XnQYor%b6Ed)ohp+Aq{A|`k1h}+$D@$Nnqm!tVrQ;cF-cVx4H|8Om0!$wu zRO|tXEqt^Jx7HEoP{0tJcaHsJ*5F%Xvt77n^Q&yew^;H0iB1P?Ydm$FPfWm4ZT(Hx zK5Dw9T9}`!(M%CY0NJ>XeDCC(WWOZpYy*g8RqCe z&=o0*k9Zg;nSavp7?iaeyVp_;irL4NRG$yw;uC;^g7(6ysQRAd4_ZK1RdMc!A}_MC zwR50)tuxO4+;hAY4x?hp+2E(kT>MfYhd;%OE%EA@K{u3GjMs$SMqv5=;F~LEElT`A zc_@y7d*b*bEZb<#)V7VxiEaR4vv2cu-4{*~XIYsq7THlPUhlCGDxUnKkF+cR=fwQ9sSM6olf)XF>SXo{C*4COFG zvnSi5GgBsB>bL=vO7+uGSBJyw3lWJxX7Y4g)p={~?26_y`Y3~jjT9J_D33Te}~)kYf*f$xf3IsmcTXgXp0YW(>Nm zNh1_JbB|-u&^znp<5X%G+$;-UYnS;oXV))Nw>GkiL7n91HvkQtPD~aFz7{v_S1Yc! zALeY>{1!T3ugeeZa~u(YWmsv~XoyzC8*pz#UKMfv2rBea7mweE!9v`7_4%Xsj0d^& zBW?>KKMe@|cb~fz->}VChbl!w1vnd`Yd7falhZ2UAw9f6YwXNymrJQBwZGuD{oLFx z3F&WAZ(krKyr>AD8yu?!`?^x9-D{NUbU((nGFlV#X~0ZYkh)VTV}^KPF8nZZ)Q>bu zp{rKoct#Ee4c*H{63f@xhW0JA>)>xK(wVe+QiTQ?k*V9CQuhI4a;^trt1LRrUdYO* z%8G~&bT@_*^b7*f4U|SR9C_jlg;zXIN5-fKpHelE+|_>6r~zsJvd4F|9Xd_{6LtHJ zJh<8+_u2g3jr#xHiP*%b$ZAdyDvNH{sWY@6rzOkK{9wbb4cRG_B)W z-32K*4gHHqoQpz*-!<1O%@$;%I`)`Q5h=(l2wu(0Y*L`;H)Zs$cH3sODGlkZ6#*pA z#w4FOJRf%6->#0-wecSF&RuUKpOR6QDSjt0P>+>D$(U&mqsc2O9E`C|-M}#JyS36A zKO+%zKUoI^LSEjYzkx{F&`%y#WKtnU3QX1m8^c$;H3SHV!sEYEh>y!v4f0w{uk~>TVU`gdTG@lhFT@N5yY)&zjgKCTBds2$2=}Wb;+S^G2SA?PZf}|n-m@IhL-4ZzUH+HLG_?)|7PiPB!QSg*V;E) z>)5%R(cs8zcceL_5i}BcFKwHwOHtm#5$OPPTdAjS)UW1l2EwSyiELy+hS_r4bu4);;nbuO+>lmE1HfgdBR{G6Th zx^_oCu#(y1gPmVjy05BApH3;2?C@czPqEqkj=ZzU=(Dzwb7_T`o+`&yT?<~WwfT9cj$upx2L=H; z`p2nn7%5H1kE;$Y-0QraYP*))G*gdLRb3Q2$meRzW5is;tUtv8)Jh6&ez!a^W^0X4uv&EmdXq<42t zrXvsTU5EWjH|d=%L#KD>rI+d>yRO6C$bXNg64~d<37->=m|yuIUpuBU9UqUqHCkA_ zX$e@Y_g73_`n9CYl8BC+PNm7wXBd-eliGFXwudE(_gw(YKai!XL_v9cM8Y&L--Zv&?-T_EfwGn@DGn@PZwoO@lxn@7eiKV$l-8~FRP?Iv`wJU8X%~L}``!%T z%9lj9Cn{xF@`w7SyEvKFu9fJZh`KC%MR*DU9kTY3*P{UK=Gy}mA0j0x@>pwqIpEwkfeJb&flFZcS2I62Zj#2w*{%8+NZECYDR&!&&NpNZ`=H@b3rVNl}uuRji}KwKEn z*n}CZiq&ujpxJaNw|Q?ev!gZNY6nq}mLM0-QPmXgSF@%)nzbsw^Vdz7x-q)W@(;0X z0@nQ>q*HCaY|8vixB5RvbhA#GW6Q%OKv~gG_{7~T!1e$@7{K|qTZ;k2 z!tJ(|{(kGhw%Y{MHQimq3nT$es3EO`h``&_Rh+SFd~?R;P1za1$Y{2?WALx^i{|fa zB+!$c-bs?{#-I09jX=CLfoAWs)|({~HA!2MivU5SG1z|%1b8=d7yt-+1{6(BRdzo} z0E}#KA!4_F(=Zrn=}H+o>fXaw(x?@?Az=*~@a9CNjJPJ)vvx$Z?tu_W5g{JV7r&?> z5;q6_R)PHfq5t(C*FP>j{UteapVq5&AadYyw99YLFK2#jn6EIdl&#Qk%?Mw7aP26j zJ_%y0&`Nfme7UaJ@YDAXy*B{gr=qbHzd^t?CMspVmWtWw+VIDo%OgRgYa26_FW0}U zZ5Lo&KJWbt<9Y{i*kv;41ZbGBvw*(kps*z&N1#7ml1ZiSbW*gt^8JP#%;K%Tv#|c_ z_W%ArtLq;$D{x~9TV)WH;-X_?)xS$e0k8Efzl-(Ze?G%*D+0nlzH&=3(cu7y?*wh0 z-v?fVL|N2Px!o3@mI(|Wt)J^Oq=hv zL#;!rei{M-TojP@@5n|iSUEIpxoCKoLwyGb+NntTP%>$4*>h*1(bdUm6%@Wv8}-l>IU7TQe<}c z###krA>FCakOcA58X<)ji)3Q&5|taWY8_MOx)lmK%@G4lAbO-daqGNE4}5byU~M`z75fd)xpNxlT@>ZC(|f}=Sktd^V^0H0hl1SA z;$J~UgC507wF~voWK{Kc956U?HQzIT<_!*z9aHg`l_D1{uC4V2U*J`a2hcH5ED72} z3Yoy3e0+YL5=5nRF^7@@*XkYFJV?L)tlby~#64UL zlHwBtv!uElQ4h1a)M0h2fHU1zAw8El9z;d!UhvNbXKr`t#i@`IOY;JR!r>y z+SDyTp)CO2LE|ENGz*~aI81C}m)QEj3$>9inUF9L;>@;#9*Vsb`shr6Z5Ccj5ouDV zzz7<>4KfL;rOUOpO%&2oh5A>TIRE86r;L8Mq?@Yd-g)HZcok{EptOB`DtIouO?&)m zXo)H9xXZO(NYi5p^0zC@t-&G`(X+;#K=g4uT37!NhZ-Kv$_!~=fg%{yQkz7*JW7(} z%@S2oK~I$>J({hD_4dmL&W6R-lnh2)Z+TTBNZ*i+c?J^5>mjTR>R3=#X!RgvH>3X1 zjkXlJDiImJLPi04!Zju#^bGF|i$s8vS87RR)8>^FsD22=Z#!1ltoE^ z-ZtL&{ZXCgR?|{IpysLgPXQw?scO9%V|OngbS7Y+vfI5Fbz16&eQ#};|4^lnQGqBB-xAYeByNJ&y}ti z2#vkcS3iYgA&b?dWIj;^&6f96JgG{+45+!|-2hCs5FJbYw^8Wmw1?xx3{aA zgazI!4vJiYvt*ze2#>rPD7`Ni#y^Y=p-$6NP=&jKTreu;r!46uI8(;t3PPC^PGN>n zy-G)MKAPiA9J*R{EXJt_5Omb71ojIERGRaMI~_~6v=9&w7&Y3yS0wBJOzqYEpVS;) zq!@j?Boue6!r4eP9k?+?rCl~Ox-n`(DO42su=r4;(NRb0glG7*(ueX>z%w)9%AdCZ z>feT-2b8(4Myk|9enfaA$zST&34NJr^FmpX)xKys<3jzScS$n%y6vaIb+ z@_MxH982d;je+%7S1+6~Q|OKlX-&fz%=t3A=l>w5E{xCr{4oIm`=bysl>O*~onNop z+}CO|5P@V+b^~$-+D8LlaKh^*FGWj4=DLd=LdV7Pu9+e>BZ`n`^Ugse{+dfML$ic6 z)Fjg;GAGI)B-NGhPO=gfNhtZ5Ikm_EI?IMR8aOIu(U5%vkLdG-^9m(4k`OE0Wq9wY zQF>EcD6y8=CXN}{IP#>+aUnI70{jE?Fw7d#X*^xc9~8NwIn@h=fK+e;ORevqN5lY5vTN$b{1y`J9HMQaF?#oX5$$ z1w&LnTi3Ztyrm@B$9vX>a~LT7HG6NVbU?!jlEF~7erE}6i7A1jKBmK$w_kzE{W4Pk zM)31w+iQKt1Bpfi*{pllzOUm^Q6>ewFyQ6v8DU(ap?{2G&_MMOvnYKMU65j&W^!J4 z?hq@J=mqWnGG}Rk+9ruVxlN)vHq$)#Vz$=@!w%WccnZmG+t0ARS1wD260=mW{cDZK zMfDF{iOB?vln#Sf%H0+r(=^i|c|1foj@B=Q4|ir`KZdnf#lFR%%5G3+82L)t{1W}&Jt_8 zr&lWj3Z;bIyl!M`{6-ZM8znf{TVm$~254HzmIRUU55mej1S;(x$m^}X6;_}40Y(g_ z(CsviK8XJ|N7h+u-gdo_>22d8WEsJ{dFgIX=qY1}x%}{9=^qHWlnst=%xCLX_%Uy{uFrL4%c< z6Q`BR0BX$N9%`ILe6%gFAvFmHG*TY1*Uc{p#T0J4%uq@%e#--SMcDE438b~d+UTyv z89hMJvcdfW;RS%!oQ!-IfUulZJ&PPD5>I zwZ>oopZz=0>!DR-wAy_+g0oE|S4WgHpMvo>88 zJu*85(p+wq+KjAc)!dzUSK;vX!FELZ)<@DcWwd}p+egK7jupTCLEwxx%q&KoFmW~B zamw}d^*dMK+^h`GQEM_upTwDS>hSZ(poESh&5(=$TfA z++xxVG;f=S)Ik@euELG}rH=%ZguRE$p06*Z63;-!aCP6_?b#zx#*i}c1=!=A0uQUf zN$jltCCL8^>~K4Q;)v69M{+mS0~p_(fifIQsl$C+e60DD!Sdg^qa4XC6A9)}xEj1m zVAu{i7OPl5=L%XMb1Y2NT@QHANG8cEOU^(kAKHKUBmA##QR)SW2yjfN=`6k%oi>1T zuhxM=A6M^Mekv5kdmFi1mX5{yAO2yXhI5~L@5HAUeo%t;z98B4s5F}PBjrB7l0X~ zcb#DW`ilJ~QuG>vN#FpSeWFpshZmQgh90G+G;OTlHF_b+Kb9fV@$6&b&vG|^x~zH- znAlpXDniU%Y%Ij=b^-Sw-=ADP{ESY=^xlF_eo(rjOK{wrO<5Hk=WSzpaMNLX4ZvZ9-x54NTGYQyDKq(sru1s%fnm&X{3FdHd4||2NAw1Ut9cu;g z174nGW?4@c8mVCWask!aNv~wg6tdQyQ8!Pb+VRatmQV4HU*o_xCgCZ#&GngSu)TN; z6=_T2!359H6)n11;@KLHal=&FjldaeUBTcyCaI1p(r$`6KApaN5`iU+NNSRN;mD!T zehfE3&v~p)ozyrXcAR=wU(dRv_OZP2O#BMXuaca)tc)1L46OCmyXbj-1A39= zuH&j~kPPyvTr+GqWl54k6~@Xts$v0i1bNy1-<$Nw}{|&&}%bpo384B$*!hYYm?;HKl4aSUZ${DLqNz9** zJln{#FU%dP=8U7_aCo0fWaAO>p=St~d7+PVnN+MMEsbU~XBJ_^2&!F}?a}8Z)QG(J zl-(;xn7W0wnP}mQeXv?5dKtO8&Yz|8eKxq|P#`;#v!P64JXxD@U zuDa=rm%C!`wfK7m8%ae-y*&;zwGEB7Us@U#fwg)N^*~-JodnY~(uO&&0S!fJRbqT% zV0b-0BNj67n%t%x**Y#%KdX^qH*v<6&&@=~H>b=BF6a3%Lix@VW~4R+mvCu)_{Z0u;8E$Nb$IU6wu_vueIZ$UZQtJpv(a1HK%mW zsf+-aiBX+++A|W&$!NHnZokRRH<_E4Mnd_3LdE+kKt%Vo4*^VK_Z|t%>I5YJn^@Z^40{eGvisWE5eZ_Z`eCT55{RwcIQ?O`LDCeNv9rnZ68{`LG5URgEtFlQ6B$1;et#c4Et?T8fb>||#) zaQMdO2LSiH=GZ%+uGefMwPkwt{`#xz{f7n8##Tu7RKbz!F#MYN<}5s!1jH9zP?zr- zpVt@BdiL>|VEj}9-Gm{w&vuulxGqDS)~W*FNut4}u6ST~= z8C4bF+$IxI@UXv%m#RGEfMe@i7xQC;b4M07ujetAOL{!WMaPv|3`e4+Ni#^U%}lYL z@JUv@mdHzn3hYG3hCANc8RtB1;dee-0VuORr*~J6$XPW2ik{4Vs4g|QKUBYewtv>1rn*fNxgnDD6HIh_zH6#&pvSuoD3>jm1eD%T z#h{)w52OfO`d(aedHZ1JPTI4ra>*c&sS_MI0!mBP(jQ#oqw-DiA3oXpqulJlb1GK* zjmSi(yX!@50zMhZnu`Ca0>!O%GrSo1gb=}`){ozj*JOu2By-5s_`G-Mii2F(4e29Q1YN+2O>65;c$&hma|k3FOdI#sNW?@ z_U{(JFhB?+AzbkUQmIrGhtdsT_x*9ph){Sy0N{*4tG&Jx=ZiAiX#K&E~Dn*Cc&sX)EHGv9}>#5D29-Epi_qf)tU)@;(0+&O8 zhfl{1OIx)K42Y0jyxQZ&K>okfW#Zi*(_qrd160Vjti8XPuk{!{?gn_qF+uX`^Lrqm zmfbXEGSB`ynZhn(kXczzT_oXw;%}$1-O@8aw~BXI{PLG+jwuKWb8zcQp-qZQkhNWz z{8mMn2%wVoc&~u>J^l(Qj%b$FT@!!h!OlIuB!Pk&4c(N%s@JQHs~%*23&hs=u3TZYE76GPDU-8?#OA$(R!%t z9@bLqM{L?RH#v03qShOwlgJXUaW_bLpvhu8$htpW-l2smGJ$TOVi$R{KiRrkh!lephVRL zJ79M%8Jsb`msq=kwv67hff=+wR}kY^Mrz}fH1xp^r-7UMmO^)b-&w?Q>9b9G4qK;L zS%#Qfes8zKFZ?Y9+kfN2cN|2Q+J@p*3$Z8=K>f#4l(__gV*-sz_If4MB@K>$|8glQ z4jjvXoGBn7-f5_0i3kHKm7*PBneYv?)g98M7OeyFDSq5;#z}@uzAbaOCC8~{Kuz?a zEfIU0~*;!jZg(}7BU|4Zq~UL?yU%~ss;J1n9U#T@sjV0^OqtctHbb46s;O) zrKO86=f?0czgAj?%c@!tL%V03;X}iCNj~i*3CXAV4NIypI8HGw(SQWOt?tvKGR%qP z%SA-eiiP|r!S&;ajX?ftr>gabWH5tMZwcRBlFOs=;r7f;fFKEZrv?ty*>_{0 ze|pR#?j0mye5!hbc>R87_;XAo|9WR2-XFBUZvV~H=Ug&AhP~z+&W(9#&nJ}PJVzGP z7Q#83#}+nKt^?iUf4+fM&>oWS`SuUzyol=LnFc-x0l{>w$GmoFAel^W^ditdR5&VZ zSq*0y^4i!hsKST0H`}B-)ao;#B{pbU=p|K>Ws)uHh3*Z$}0wraEgH0Bnq z^8fLna@l%|XBH5MJ_pQ-oB$^1M+fivr4Bi$=l~{Y^RU!bdgI{r9q}>W&l1hUP)_pS zT&+6)H`De1W|seFaoD0k|1(>+Xq5k7&erBb{ey#Q+@{)Ejo_jFBGT_(20;Q&{j_fM z_bDsA<&!(m`?R0f@bMfmY7rm^#?%k`}t)xD^389M}=0&#LkR43s=H^Zx$f$Dd z7FW(VU?F4nSj<8eysf(W&k^vxXqT8RdPN{Y84xrsMao1t0h!9l_d{VT>9e|(x*DKmctkmkwT}RkRUZ)~a#|$LVKTnUU99sOG{yg|z z%avtIru75M2XjS#vRkh#3tgPoDU>D)`hX8|==wJ;)pfhZd&IrVb!};S8u7cY-Rw=g zodoc7iNV#jsqg*kB>mBgK9Rl=@x}*x_g*T#rTUYo)(O{`#N!}^7*T$Ca*K=^XhtuP zcT)BtS1mOa6v!&ne|-`B5-@XFU!Ww~@+oX;Xu1zQ zZ>!SQYj10nsJV{+G9NgjPXYu?oMnX=U#sUudi7P8wXT}VjicNFTRR)ct-yU*19nYzGV$*;Xe zwZ;2BsRaIcT3$ZJ-G|?cqGi^W_)PJhJpX>X>i_0;8&!MGovd$Y=;VQVYMYxL3v229 zk(QTe#x0CgkBp2Q)y;m3-PqU&E+>;m?+i}Hwe1L^Na!9`znEKsd_=0bIWtsZC)!&a zsdC8r3u8Wn1JxzZ@DH(|zNZJ_#@&5KnwOg+<2V-4zK+DCK8m-}R_874%&Aa42G7JUe@}{U!YI{NNa-Wo7X8P;2JSJzuOh z*}eYl8pL1vY4gAE=Jj-2)gCC%*Npl;1uOt4ALo=9z7-Q$Kp=T9Arlldb8)HytU`12 z>xcMfc6TSUv&@7Gc|olLe=iBy%)95G@PHmM?(uHPu(C|RDdyF_ z0ftPIf6A3UW@0!E%4x_X#&F}QJH676TK)aPP$pT*+Fahl*Ncbybej$&76=iwv|08E zWRa4R1H$lNKb^azlD<#wC8cp`XR8k|7%_*$LpPd@tx@k)&2^6kQNw(67QaRMle*zg zWF_qm`1l-{Y!&X+@I8Mr-vqy5=6x}@`atB4$i6|{FyHmO-%T>dUJZTxwbvfc99i4l zSpMq)ZNRW^S{%?4Ajpthr>9jHKhcyvJ=f$0cj+P?z)qqH+7nF${~v z5B#7@n<*PoFsRK?@tZiJlr=3El16VXp%zzGOnRt zJl@-!2f;qpyW>rF`7EshveTDj*U_2w98;~yXw_^^6XXFF4XH_yLaz>l}CREmC;^$Io#gQn_Q-_YZo`Sq(qv_JLy|T zX+9~OCR^l4X3-^{EMPtFZxv2 z>ZYBjOzmGjB#O;Y^<4QQr10=h^km@G6BCq_v5@fS}VFjiiFxRb9 zSRhW`AAW=JDSpF=b-%txlZ`BB%avP*DN^7E&Ntp=o|{TbbXw1Q(l_{X`O=2L&|f6SYPXNKU<@1Mp}JrjVG%3zFB{^ zI!Ik-mOGGU{TE5ZgQ@75&OTkoYsD(cZV~k%fB>fPd1;7ed?j=+BQw)aXgD-{5!1etzVqg*jRlwTv+e7;fwm6^TyvF z=n;tpQNm2SMnkUZEPjaepVin>PpGxsZ!PBQ{6e%bF~m-1Wwk{)Xyy-p%Jjh-sPV%E znH%+Q=&ciIveV+WLF54r6=__Io_jKk#_=a>=%r6H@ODT0-Flx64wlO)*5{Bb`<;d! ztd+A~5S*5h#L68wy17NDuw}m|U&S8J`Gr^Apd!4Wx$lJCx@P1(38*)J0+8PKJvkU+ ze3e5xG$3dlJZKl}sF{ifzzSVEn4RZG+G3kzTuMp`nD0wp%cDn~huxs<4Jb*#Z(CbC z-Mw4Zm~C!+_I0^<-}s1FQTFhmP9h4^m8J_H2TQvBc{TfW9x;}lVt)iBS!Q|mwX#OB zz3m<4M@JCXYy#efEKcqr9(XFOd76EzQL4Vx^iNnKDz~JX5KVYVRIG0^kA8dqewsSs z=E}x5cONgg`x$#XonN^)^J{Xp4@g3ERuWo?u%YPc;&wA8W|b?5N>H>$KWJO39C>#3|7!0&!Yy(Kz8LJJWggdQc3AORu- z2q7Wa*X`aP_I?)b_rv>bA1fa`Kp^G5uKT>s@;`s4@UFhHva-6r?#VB~$6pW#1f_1q z^Ruaw?FwDez<7{dRR3$KLq^aZyMmY;VzlVp@-kFsFPu`p#YB+8r`}TEC`7=%TSU>@3D)YnC%mouYIXs`um5;PHUA4GOTQ{GN8xvHRAHz zL+thB#nhs-OnYyn6PJt6;@^5mcOX2p=Nhz675Bq?#hzV5Pw8F@M1e%R*tqtETkF(k z-BRIt>Bf9ArZgY_a~N}vw{T8+>V#Sl|9ItOdB=KmMYwx@)h z>6v8mTD?Tj)K(v#+BKq5&mF91k@HXEFw|tNyo;+>R~#MMbW2sVL`KyS{4ZN~OnX(Q z2fk#Br~h`ukBu)Xd#E`aiba?@a9?-nQ^liDyMI z1`=^AX=lD<$a<0|UFg>iSxm6NyW9ZEbDac{!_XWpf?x&sSI1;=gw-aVfko zd20A&Cl)PEWcpgY>>D|}m^(N-RFovM_rp;YJdfF$4R4wnd(&&NL(pYMiKU$pa{-W!bsln+X zJH&=B%D@f;~tTZN5tzYw;|b-G{9PPmWE&j$$9> zytg=Mde!RMR%@GIcfGVr$U^Tu?QR_~XXF=Ymu0NBXRZ-3c0;I6k8X8%A6PAi^k0eT zFG{ME79_y`o+?Ih9nvXX-~jreS4c5rgVX;+!k%_ERSoN8;f-;3$F6Lc-$34U#x_iS zY_-`9<;fIs6rtPd-yWufV#k;1uc)e$PpV{y@Z}2PUAr88%^28C491KzbTv+S4}`Vw zh!IXt^HUwnVL*}j5GdKOaja#*^~9uo`hOq_F(OV~aD0Qw{2oshnWAZNS%p?Mn~X)e z?-YzhM)zcqZ0)ipex47RM@gU7waqiUe@5gfCPGKUzCl7f486Cl2$M5e%f%@%#4}jv zYqF1%nelboep&MkBq?bDoFE@K(JiBePhc%iM#E z;PIR}DtOriqS=UXt%%;XwrwXVUN7W&!z@w+Xf3i<3(> zEiEmJV8G&O2M31;X~F_f11EyRFFB_7SBKb$TD_}(-wy}fi#x$+-T)R9;kaLKP*2x% z-xH9FBHyA!&lJQ>+Ip{f^teUcoC_O3RYvrGS+pW5mZub_YvveGdLnkQW*B)Y8D3wI zi*>kVlfJ5JaJwvh-R)))muICWKJ&9L3v0?XuebO|c+`oXxkfObqTZ%Z$j@3mqc@#M9IC(SDYuL~l5c<<0hXvD8K|Dx-#AX}W{Q?LlRU|sgUv7O&lJ`87nI!H z*o8WWxi^9N_0zM{i%+DY&NccDaotjLPH3*?2Hooq7_xsIQ%TI`e)J>YMb}B%y`r8& zEZHg!B{jJ`@lVl-cu((|usPh!g~cQ-F+6G&j@#Wd-%x-0Hk)#%4y9em>pMl;!(d8}_ zS{m)o0V$J3BL>K8JR$KSXjq?->T4*_X4#%Iv0N0+XqxyrRl|N{VF-`|uw#+kny5MR zZktDb;koZ-=7QSZzrC{U#c5lW1jA(z4~!&URwsU#=z}HfFh9VaZ#+3jNHUyTW3(4g z@{}}_23=7!<;tlqaG%brl(zdvQV+xpMH5_}plaQCSK87wVRvB1FQkX8ge)YPt(qM6 zs#*K1o>RNehi9O)6RUb7aC!OkaihthplBKO!@5HjQ5yx6mfG(FRbsv@WSV2Wz-8;x z4jD{yZHj~+H<_l=M>uVy zYZlS6_kr9*_{v#i3AsVXnGk8ZYBuv=DD3l_Km;>XqXNXx?t@ zrfHb97f<%*fE3rQWj_9Q{m+TIy$`@aW?i^AR(6)`;8yFHD4emjP|Pu?NT`#n=93!- z<9Ev3(~j|h{I#DK3DPx2CGEF3O3s9+Nqaxs&~oQfPkKK2Muvu(UUbVnZP)RBvx4%r z8#;f4nx$W%moggSF#74PaK8?@6 zkSpF>;V~;lN0x z4DMdd{-U-sKeviRBF!PJV%$d6A`P|^Y6^7=OsE_>kH#bOmhKxk%}`j z>0O(*h_^80Q?>UibT3K0f$~xZTyBM&k#Ah-m5aaY*UbMJe6f4`!>=@yo=hF>B8Pkh z2ZCbaD*48*ephb%f?t19*Y;cYxd+>J9eQ%bKGsD)Buc;Jaens@J+~!iYYsbdRc!m? zaN-!Or9~69hVW8NEwW3Xm+F46YiR6UsAqZNp8XC3c}DTzP!(!a=w3p)M_;#EC?6al z`Mqv~&(e%ms}M*zYrpk8SjvkdP>}9r8!hJ~|6dQCw;{fA*3dHIoQoC%1du@{mjFkzRae zXfZFgDAo1~EaT*`{$$f`oO8Ff&Ry*dvqFHh5~39DUP7I{{ylj4lltEhY@~gKX82U5`)>D=7RBld&@&dD z9ouXj-)dz^6Cc?tG6v=6lOEhjxdjgEk2trEqSb`gv7a!eqOfrLR<5F=nSRu7*E$nD zs^&K=-RHRPnge>aGGdh zlRW9(PXZurdaFgKg|6?vJz~D*`=v+r=bQ@r-FEp$E7SU0hu7ED+!VF-pPcDAU+pB_ zo0Km9vQv8aQ)UNiXDc@C0R(B1iL^Myy=9Aq z?dGxG7hB)qH-4yb45R&mwekx3$Kiny#hq2;>847Bp^g9*MPZI~mhmoyl@wavc3lxO z;2XI1L7beJB9|mi8Z2Wb1~WT2pMHQJw9t*@omZ|t^3qCjsp_7ZptjoF#M+iG4pC#7 zg-PDrpN%4iSs5Q~{VlKPb=Q~O_X;UX(Lo5rF~)W|xwIi0;w+!a$mtPm;5?%}eST&^fN-D(kaV zwyVaSVwIPaS6yvtkb5!CCY!QPGJ2R3C!^t>O;!qStawo!G`q#N`-&9w$SjNb2Fx4^ zS6CMuaZ6%c`$6eaF^9ILyOxjK7p*N#$RTZK4e<4>Z-Q(3aeDJ=(U!F%z8=r07K>qO;$o||2qePyF;Jgo13QPn_t3@eG8lbamiGgyNc zaw5w(TN9w?Z{N9T-EK>G*6i&w=rhmpPoK*rpYqPhwp82L*eJXTp4!;;qPJu6h5XV& zjR<6Pvr3bB29ArN&uUYpT~A37c=jv%4N)nNKma-U+Zi^S7Vfru^eZXc9}+^7lzO?B z!dr5%pa^8JH7?dC@WzeOM`wu_w>@%V)CTK%<($MYcVZF!KNfRDQ};VzLTtz)#Tv_hr$}na(3i(KIsjcFwu6!bYIu!Ktw{oU`0au zVC_sWGTH89b?tTK!E4usjfeQZ1AiHk@RCn_b>ec&5#Q(Z57o7^sL8;>D7s+?UurA&Ac2t4rc$Yv9ghrUS4%jSgcfPc~p(@ zWHA(Wl9`TmV&c>rZ00(rjNQ9;jd@_wc^CiKgs9Q|sJygF=qcD2C){g9E}ptPnv!1R8mK5e(v!OG__ z_}+bold7sp@EV%0N6pWFQ&8i<)|GN5+nUW zeGlg)O-|L9hlI55&z*HeAP{oSI9b`PwM3FiQ8vND(bJ;7WvGi!C8@16gb;=&%(PaA z5^_o9EoQMLoEg>lEg|6CY4JCKbIfa9u-Oz*T|CW{XNOtFw2YB4@Gvt7+CGc`#gvoa)i8+b;EX<8fjD7gP zIh~mBnlylF)h-+3^(gpFCr)Vd3U}}i>wtipmk94tK2l$$7}_)t6$@J$0|G}8R&4IV z0v^h+Jmp7-GurB&?Is3KdcHZEy~BF)+J`qufTk(r@UX}msRy}X6{xG zmo7=Sa!Hthq8LcHphebVTNr^a{{6A`PbbQ67q|W%Yc+Wg*uLpYzaPLD`W`&`AsCn# zl)y~T1|~A>yGIwQ+orccssgC*m1V|@aIniX)Lv+kN`|hzyE}xFZm!q21!9e|^lgBH!J!f1+=v^3$i4FWjtYMfNLq;UmkR{luPO!|Ho@b>7>i zeIKYZ9v9?z2*^L~>x!oKrADRa5+Zu->GcuO0eZAr0Y~wWGZ%>nNlKqxm*Y&CHISPt zN*iCktTR|IfqT+3mFbaKY-B7)dE&kNcqb>YoIKN^hgbC`cN;BDXwsDgPK?C-ll%Rh zX*HE2sUD7b-5Y`B>`si|&>i)BG!K@FYe{U)Lr7}YNZO;M88vq z1dYh<^dToR7iPzV0m07`6E!kWj9UC-Z+p=!3_3J*BIJ(>?Mfn*p?xPrk2 z%)o`p>(uNuXzcv~El|X=R>Cg#O%wC-^7_`!_C|my7}xbU-^nWPb}Zql09FEL%p#yS zckNShjMKAY5D2ZzwekSoaPom6=;Zz?y0BJ_IOtWw$y0Am<*2;E&)13;w2>0ALf4t| zdI140F7b^wU2y!U3Q@`|QHP>UX&V5FfKWYYAswKnlBEjCmvG+bzqeJo=v|NX)-gqY z`g1Qi$zbfKA4*XCv9c^(+mC*wKiR3nwE-sCVX_z`#w=ExKvSnV-fW>>hpor(a3m&p zNE5I1PreIkp(2cg&r>Iha*%ji(AMqhIqzqMJy(LCp78LX;w) z-Jmp4{-@k>Cs8^Rtvoxd@0Oa+-O^u6gKptb7S3$5Kx9kv8QAy5g}=c5tGIB#jEqbn z$Er|&v@LkV&E0lt8Z@pvfNtCqCOnUcu$!26Ld(W{B>?Bi!#lCMk^HHWOQ^*iF+Y`- zkdCRw7@h!?N}C&ggJqW&>3mwUsz>NqnC?A&7)fY_k$|?bgl)StPIq@GqCH`*S*F)6 zLb+G}-rURNH30$F^1c79ioqMv7@FWj^GR^e8!GmFE+IG98I$I7yS18*LkA|a{^MQF zSU#7yGU(2-^%nbpVJ0VvT=>ykcjnP|nR3*rNM0kqH&YLL%L7Bdgre3k(*I?v>{DFh zj9|X;v-QBN&=>7$ydg$G79>+R{3+cO7oc~}D|Kf9M6W}Ml*7_)(Ei-(x!Ztw({Wp` zsd%Ok_4!3c`cDoE{k7!eeR~<+xacTu4)eIErM7XwTQco!6L=_JFqxo*&p{F!<~nN; z!V5J!#_1cWFYS)>Wy5s+)BbuNMihjL9#?G9`YrbNAk2FW=^#bd_;G&SMh40MzTZl=UfZ6%DTJ0T(p+KXT2DWW;Ony3tL zV;XXD(UW&%RTH6EJL+k=P9W#eQMton?)4C$i%i~@+5Ui`6y~w*ovFA(4K;c8P z#T^AWCloL_Zr2XeFTch&K9{TUh-m+rmW2k&og^-@SsNmia>j5t49*J9?#bGP3}@|Q zwW>;PKt=9~rqz*53#6A_jZTil zQNrBW-V|C@apc5!*r?{jB7(GsB*2nfRzDBsAWtmjt9oND!FX?Fb|fxTRLEyE6h=-| zEG%$hlC_mYoq8aT3|m}hv{dv|F+j7Ns`#C1ngVSDEE9fOF`E&}1xIzu5FhmT-pK*u zPWI~~CzQY(Ez3C|Fj|CUGK>4b;Q8ZbGk?i1b@BpN!F@pv@-|-{B`M3$6_U-@1LMDl zzgSxwz54WptSUgp!d`#HX7YkaK}21esqSjUU>ZF5#gX&6hD*^%2lN0W?)Q4Id9nEI z4}sfj4Fg}IeZgPrVrYhTzm z6qHvxi?%xFPs>y8FLFo*C62b7b0(VUE$einh_V=V*4kn+PKi56NH#)qtgr|~*0E_< zvzC@V7it#4R5Fn2KnAuDzL0BFd6h+$kA)6yJp?2DW8>R)S6~sAAgtKE}D0_s|COsJA6Da8!A8SwJOXGcy$fgon0EB zQnq@v-Ev3!&n_N24Loqs!DZ~ufO!I*6aGUf_E4g#YU&{eJd|ui>Max|o61MinTf=) z@P7Mg*mjRM2Q?hBofby(O!H_%m!oAZ>CLc>&VQY&xC*=zKWVKK!?YeCf)SlO^;Tn8 z@9@QWgd}=$kzyNuSj_n~5 zYCz}j#i;#w

8G|gZYkKZ9D7c2-q)>%aecl$ZeS@q_NQ4NR zU;$!-sE#G5*?*lgXH^$F=Ie6)*z4_aKtCBYhN5}-rbZ*9qpS(h0;Dr*Uqi7bA=MqX ztDg2wR@v!qy?jC*CJ4hUT^-Yn#75ReP7GX_|BVb^60&JIdtc_=a@*EBWj-bGf3889 zAAR%UG~C%-d}Eso^{Gi+8%jr!-^d{9L%{PPEU(4Z)Zdzkz-Mr~UA$7Y3q0V`j><8L ztZMi#CmN<6mk&N>=v%EGfrAAETd=VP(Je5J9;tp)p_L!zCCq*I?A`20WL`naNm>us zxIs0vhTi^jW_pnCkXd)-w{1gd2&=(rGvR1pF`LS(HY#$maN&Dj0KwH3ud>b4Xlo7^ zDb6QI;-xjft59_?TXBM#=NF-lZ-}ju>?NonL~BBXRr!BJ@UYB%Q2losW(r{~*p6>= zuV|btS>xa&oN$2VW&MZ@w`>-T^G=|iBod>*^z}pl?gBT^@9#TbXSRD zkqt$2Rkj{Q+vBE`Fvp)aHJQXLrRKNY-?nRV2G~DPAUb6c9K66yFmOf~0u5<*c+*0y z`{gjaXx4?)m{=Jz-Z;~}X17uXi@)6DZx04|vY?)X#m_)GmU#;0V+ur%sP)e;cgVVy zFz(1Ioo9;%UYcLZn_sRY>bZ@Mb~oyTzoC?RU79@EUu2)4mTaoC08ZbvE*GLaAPQWD z30}8uPZ-~;`H&bCeMG>n59%d2GfvSWMsg;0TRSl(B1QAG1{`Q6M**@7rJv4^Dp)s% ztf+(8#?5BH7fJ>n^2s9yml_N3_8oE~XN&zvx{nHeVb99BSx50Q6`f@k$lpnfW zqMiTy1YKo3lv}wM<%KXF(?;CQyemH20yXd=pm6I?fI(Z7 zH?t-lUK=XXnPkaf=)f9^04LrWqq!)GggO7xZ^|gYcvVA2-(dWvBLoCRNo97lIhH!N zr_dExl;mVRx*)GIFFoY$*-`P{z?(hm0x#>obk=c-R4ys#-dy69KL` z4dee-qxGPwu*2F1tn3lA@0EjUMkYEyqyYKb-7B#F>U1$j5_Nt_UE&Ey(v78Du=2ySvU*hXv@hdn3BN&-$ewEEb7;w*>6nJq z!61BLNr2In&c{053uj|JbUy0(b){u`wD`N^^Bd*rrx2@i!`c-ig`KGy?azXkp<)kQ zOdRa*&PToXg9zy8hKfY8?^{1-y>t9rkqh&;m_z8&4O&AEk7h^(0Qif8uLb6!v;ZUa ztHLhhxDsuC1F}KLUg|lNcjI?qvpOds<*i*!Km*aUbc4#F3ejfsz|{Y$NBND82(r3f zV%GVmHZ8flIB_Uw66tj;xAno^HO8Kof4pFA;@$3e9ouM&4x1Qg&@n zvpAP|RidxKMmzJ_=IqKP_g68;gvuwtq{qM8;?;ktcE>mnRh71VI#mik*AOT4oI-PS zr$*{pP9&3t9#KQ;#%nRYpn7*(`^9uwRj=YEY$)(Un^;4L4I(dHLrUjK?VuX#fhT}C zrq^6FC!YW@MO2YE(WvdOEHG2}oY39+3Y0%^ZMqergznMyD>KdiE-QZ5A@bKTh(Dci zYY6p=f9Cso3X%h}08J4Ok%axqtW_coPKenC6j}2xcf!GNQ&k9$9DiR-kgkxrT6mzC zZ!t?$8<_fDBSRh}>|Fh_zOYJ_RD8TzO%~7yGR@%r`zeWN3$$g^@(# zi*!Ov&HeL|S%M@e4vPlF=s%E1Tjw5%hGTT#H64!dH7DMM*!IY3c2U4 ze~XQMR#jSBc1T78cBb=VPf=c;eCFr5x#N^Ls|qeSiK2QH?5!^7L_KB*f=8VGp}wLA zYKt~o4)MAxpEYU_lzUKCe`~?aI|Diz(0R=k1YS9rM_5Q^e>{sZ8F z4YAZjMTO?cy{`^PQ9l{uLW>0(U-f_nZj!2vaGAVysZ_sS5Za7$aYryE1qG{m7n+GT1E(|f_M zTyi2*Ye^z^AjIIbV^V5LLDZ@oZwi+&d&3?bc%mZ~&-TXq!S;3R=_TWz~B@Pco2*4tYJ5`|P``VA}Ps)pqcwk%+w69MJ! z1jNmO4kRH^>D5uuneqMvy&dSW8C zA$@+Fgldg7{5+LfSiWvnRyq-2cc@jfp+cPGJ2l-l8}|59zNx;U{OGkjf)}ngg}E^! zESj<3dL=TO;Z7)B2Lk*3lt5zx<3atUwHb*sgTp)GJ&mrJNU|3-7S_n=(_EBkuW97H znWR2(F5T2*{lqMycHf)Mf}A`xO6NwI6nk=@n%vid&)8D~ia1w-zfm`_dI|!cN`@Eq zNqCRNM!!WnKs#K1rU-$I#Pdu^$Q*#`pd><|{h$&yMhH;4qVC~y-|B<+6oC$L5%P6Z z09OQ1YKiB2!3Fi3_8@x!6^B844Dj^v0Z#0>DjFUq72uK~_-b3Ijx)cuu0<2D(=YfK z1BYJaDZDq=2nEhaBNxk$?s`N4x<)JBQ$2+9^6DxXNVG1SlK_hEFzwNA z*~))d2B|AvY|*gG{0SPO>;t-l)Zg=a!-!RK&acchP%!V+TaRwgkq6a&1)ELi9E%+GDe%%4N7xy`5kE!{gtoPsDnDm~2mg;#qA&Lj?$*A0=pFYTkKld=M?& z5KzFFPX!?Pp3Zht?wjFqkELWl;QFtzvNlC&7ZkMa?SJW@#fYAB}9fN}Tj<=B$n)weocbFD7SnJ)pAm<7}70 zHzKf%0ZO(7k~g-!V&A$EHD5H+-(R&dWX~5kbC!NyiOfSbXJ|`tt9+>`hk$!2>|%y4 zY^=uA6_QAMcWnn*sBbkC)~d1g!jLe<__Ur(Z8ZoKDW8#Lfb@c#Y4vbEG{rNQKo^vG zHt9n;ul)6amy_T}31BFIF@(zn)A|-wDWLo3WMs5e5EPKmq+%Ki!ZoMl))80AQD8P$ zItpE(#Dx+WEt~?7VdWH^d+H{+&YSSC?&5iWCV&>i9KiX29Un7jh z=j8LViY_5?%yq2uatyCs3?6&EKM!!G-o#;^2Ly&s2095sX6H34Gy3#=%MJ=&kt`ZZ zxi1}Z_UzLSd8kL{`<@OA)nhjX^-$^24SOSav9oB~UIUcR&6^+^k5=Qvl`Muz&e9t^ zxa%0MkFDMphzQoXA3Ou zoe@5rin3H=oU$So&QrbP{FcJrUu;2^dl``(OmN+2+KMDk)0uWP4#GrF5Q8ScK-~>> z1UR(em7Dh?-{y4uWM!oba()VNN}*@Q!dXd+L_v$zp5v}Rfy&NTJHXXB2^{-6E9h7c zg2phbt3Y01Jz%?bui%yr;V$}|0(;!!xHqx6Ef@%9iBRLqwu~s&KIQ{imGEE8mkV}0 z>F5G^gbQS--q6jFtAuidivTiQ*Ap@Q#3lVv@7!<|3(&%x$3U0~oR5$9cAQ{9rfhmW zmy~x*PZkpd`&Z{%o7nTl#>QRYqa`d8$j!|XLG2meJ%!kgx$aQMV@uTPTont;l*TlF zwnrWntwDUZw%ZfJ-Y_!68>*j9cL&`f1Y@tqqOgt+FDfCAROoIsn99+$L1Sw`Zm-K_ zvcrXB9J9f|nJv6p6(RvCFO){4dqsV=^*)N$D$YB`kW9~xjP=)%KvLElBjHIusdlL} zvlua&CrXGJxx80}$@pviMeWcazf7}$bIl7igrq|*H(dYCi_d(L|d8z}xs@J%*S^6<6fUHJWQkAYzx^)4F@WLX`TjVz9 zINyn)sH%3Cw)AfF^7J|L*Iq=l{Ny zT|8r+atQ_TkRpfwK1gSqV+G9xo#^i?F+SxusU1PR_4gSqKZ!gS)^LInq*8|;@Qpew zV@V~^jlCSLG1+iKu>ZdN{;mBt1aiY=ZZ$`tpwVz+x!*RE9fHKV$3O6W4Yff&RKpt{ z=mq4q`l-jZlK{(iq%u1S_x?B8KR}TbGDniogW-=WQxp4hqn0`=ua7sb0l=XGGKQbS zf+{EgtgZ<}$CCX-Fqg9p@N*}@xWKE;(hZ&#>xFdPTZ-5=>8s!m%9-k2fJ)e0nA`oj z>!roUXU>=?8w8cP;DS$++Moyy8PV+yg<$c3a$27Fr1B}?#0Ho<-qk1ZO-TG)G)%Vh z3KdNEu`xZ=dQ_;Sx-nzT0v`QwTIgIQg$!+#`i~%Yy)zGT%Aj;Xnn<#iEUmw9UF1aJ z^nwUQS-BOJT2LVWui1)TKk*AQwL|uI(h?=UVosN;)bM(vkS*WWDFli$uitgJ3_>yv zKss%mI730SP=YRdX<7?s1ThprX38I|rP*RBX}z@4vBtZhwwXikPH!J;K(l_}Mo%#$#zweJ9vK?c{nCB%J3fs;G|q3_x8EM z&#n^luE?W8W~JE7ulbc^ED%5E1ZRAg!xlihFv_hIH^WZ>U;g{!mG7y8mg+FI&W;`s zeK|fm0uAb+=%j@LHFJ8V9oY>`$GlY0?P^P;1=?+O)_ME}Okiwli{I64hGzG~hqZ7$ zE15B(q^;Msn|^sKZ=Gd^y=6oMMnGP_^U+zaQ9Aw7U>XB*;1s zEveWXP#vtDs`BmNJhI$=1kowvW3hCQPWs0op|Wp>2J56S28e*CM@*T+!RiU)DY}|F$gfbb*H_OR4s^Q#~5kYZ1l9a!Jr|(!2x!&6Qvu7 zhRQw&o!4nrf~wE!6)7$;-kxfrT&#AaeZM3bVXnVq4rW?3o2J9l1XO-YkC2O~tt(o8 zBD!cD9qJIegGZUH!UP{=GPorcoMS9qsJ%IgW5SHL zSTD2ywX?)-U)MvF9>gKL%=356fLueT6W)Lq=d!K?miqnsI)SMiY-D6tWT@B_8qxZu zC=oV21nz&WluV^VG>+j)ZbOB9B+Q^fYz~p|7rHUi(MB;b6&xxJCMllAG)#CnEV%Th zgo@>=2jGL~sg;IWc7UDH_1+XXRJ%0?RaD5Khe=sDaWZHXuNh>byo;k$I7- zIXIIQ@ygnY2<#%7kHq-AV>gz6Nfm)-oT3ERdbTE@z~HiWfXW1@w%e7(n=%fzWC!mf z4taHGY*g>h*{jImX(^iQI%{}Vxu(-4n&>r(e`bP7J2>qMTA**lw*tqt@Ow|4^~VC=wHNrA zDKp>!6~cWFYj_vqpZO|*lwk4p@u~2Yk&DW=uSv$5D{^(G(Y~)VIJ6miyknf->_!=< zb5S?ckwnP1AW%t0=lG3WA%BF8rgBSKwJ-{YDMnx3%0NMKCE#^un3dp~RwRSpWo5jP zQc3sNa4Hb%4e;t#sxmF6H!#&j#EO{dhWUsi`=R(G@Y@pZze35dfHtU_0q?8*Ek#)w zDe;Iqn@ywRxR31l$661JPB^NT-aAOG%y9#9N9C?z{qq-P?PpQ9c3&!PJ)(t?tczC% zr3oi-(b43nrEIy+!MsM7bv|S9IZzaAj1H{|mO>#!U}flZ7kDdIf`c}JGJ+r6QrI%Q zx>nowh<)2W2%mV$+I}gDr~}FAnEk`UM(f1#@{m$&Xf9iuABA^M?pNw9=7nru7!a_9 z874TsE~7Ht`?-*d6|JvZ5lSgSzeow2>CVTDdW|YELDYLY<6Byzm#;%ieMcYI6OrI$ zOIuG-QK=!VM^+G5u3Rb2v`3dc$CnK71p+}bzF(=WSUSkX8i9)~JwVOBlUeyiEDXak zar~80>{rQ5`ZtPn4#h?uKnjEhqNaL#;!=3DXZiNZDGbO9s=Ga&b?vMb3{sZz*yqoo zkH0J|gfB<&bJ@6Q8dHo7$|FULv3~*Z*DcMvzftX#ey4N#gOXOUr5fy)VoUW=FK^1( zAk38SD1v+YEPCJ|q4T5-UvI^fy11K93gsZpnWp~Ho@QU4)8iY@X`x0fNC{EXpkB=h z;dQ#G5G)U^aM*!1L6WAB<>%X<51^i`H?Fq`y@f!s2{N9eD$os>(JFldC9(hQu?FjY zb;j!Kt>;EjACwG=;Ri7T&$myW!c|N!va#G7Ht3()#iVAw!LCY3XBzNg^ZR&hM`dBh z8D;fOKI~|5a)_4eV-6QpyVt3hha{54aCi)O-L=cRHrH*g83k?6Jw-WtYxj}i3qQRO zj@fR@&6H*Y{Leq)lYR%Uy!qq7=Jg+i#pT(WzL7rVw?owtXm9YhCvR;oCErpA2pZ2y ztJHvnDlgXqzp2pl4X5-%-FTE^#CPz1FzY}O{9>Z#<_q-p6=)IsN_~Sns%`$Zp_k!r z{xV(8L2RGkl(C`0}wJF@;>r-`p8^QOVkM{Kn{cr#L zXVw0 Date: Thu, 11 Dec 2025 20:41:43 -0300 Subject: [PATCH 2/5] Add SDKs proposal Signed-off-by: Arthur Silva Sens --- proposals/0071-Entity/01-context.md | 12 +- .../0071-Entity/02-exposition-formats.md | 15 +- proposals/0071-Entity/03-sdk.md | 417 ++++++++++++++++++ ...e-discovery.md => 04-service-discovery.md} | 8 +- .../{04-storage.md => 05-storage.md} | 2 +- ...native.md => 05b-storage-entity-native.md} | 2 +- .../{05-querying.md => 06-querying.md} | 2 +- ...b-ui-and-apis.md => 07-web-ui-and-apis.md} | 0 8 files changed, 438 insertions(+), 20 deletions(-) create mode 100644 proposals/0071-Entity/03-sdk.md rename proposals/0071-Entity/{03-service-discovery.md => 04-service-discovery.md} (99%) rename proposals/0071-Entity/{04-storage.md => 05-storage.md} (99%) rename proposals/0071-Entity/{04b-storage-entity-native.md => 05b-storage-entity-native.md} (99%) rename proposals/0071-Entity/{05-querying.md => 06-querying.md} (99%) rename proposals/0071-Entity/{06-web-ui-and-apis.md => 07-web-ui-and-apis.md} (100%) diff --git a/proposals/0071-Entity/01-context.md b/proposals/0071-Entity/01-context.md index f0c0a6ad..0e3863ae 100644 --- a/proposals/0071-Entity/01-context.md +++ b/proposals/0071-Entity/01-context.md @@ -331,7 +331,7 @@ This proposal must support both architectures: 1. **Direct scraping**: Entity information can be derived from Service Discovery metadata, since SD accurately describes each target. 2. **Gateway/federation**: Entity information must be embedded in the exposition format to travel with the metrics through intermediaries. -Users choose the appropriate approach for their architecture. See [Service Discovery](./03-service-discovery.md) for configuration details. +Users choose the appropriate approach for their architecture. See [Service Discovery](./04-service-discovery.md) for configuration details. --- @@ -442,13 +442,13 @@ As of late 2024, most of this work has been implemented: OTLP ingestion is gener This document establishes the context and motivation for native Entity support in Prometheus. The following documents detail the implementation: - **[Exposition Formats](./02-exposition-formats.md)**: How entities are represented in text and protobuf formats -- **[Service Discovery](./03-service-discovery.md)**: How entities relate to Prometheus targets and discovered metadata -- **[Storage](./04-storage.md)**: How entities are stored efficiently in the TSDB -- **[Querying](./05-querying.md)**: PromQL extensions for working with entities -- **[Web UI and APIs](./06-web-ui-and-apis.md)**: How entities are displayed and accessed +- **[SDK](./03-sdk.md)**: How Prometheus client libraries support entities +- **[Service Discovery](./04-service-discovery.md)**: How entities relate to Prometheus targets and discovered metadata +- **[Storage](./05-storage.md)**: How entities are stored efficiently in the TSDB +- **[Querying](./06-querying.md)**: PromQL extensions for working with entities +- **[Web UI and APIs](./07-web-ui-and-apis.md)**: How entities are displayed and accessed - **Remote Write (TBD)**: Protocol changes for transmitting entities over remote write - **Alerting (TBD)**: How entities interact with alerting rules and Alertmanager -- **SDKs (TBD)**: How entities can be generated by Prometheus SDKs --- diff --git a/proposals/0071-Entity/02-exposition-formats.md b/proposals/0071-Entity/02-exposition-formats.md index 6547ee55..20d89f74 100644 --- a/proposals/0071-Entity/02-exposition-formats.md +++ b/proposals/0071-Entity/02-exposition-formats.md @@ -167,11 +167,11 @@ k8s.pod{k8s.namespace.name="default",k8s.pod.uid="550e8400",k8s.pod.name="nginx" container_cpu_usage_seconds_total{k8s.namespace.name="default",k8s.pod.uid="550e8400",container="app"} 1234.5 ``` -Correlation is computed at ingestion time when Prometheus parses the exposition format. See [04-storage.md](./04-storage.md#correlation-index) for how Prometheus builds and maintains these correlations in storage. +Correlation is computed at ingestion time when Prometheus parses the exposition format. See [05-storage.md](./05-storage.md#correlation-index) for how Prometheus builds and maintains these correlations in storage. ### Conflict Detection -When a metric correlates with an entity, the query engine enriches the metric's labels with the entity's descriptive labels (see [05-querying.md](./05-querying.md)). This creates the possibility of label conflicts—a metric might have a label with the same name as an entity's descriptive label. +When a metric correlates with an entity, the query engine enriches the metric's labels with the entity's descriptive labels (see [06-querying.md](./06-querying.md)). This creates the possibility of label conflicts—a metric might have a label with the same name as an entity's descriptive label. A conflict occurs when: - A metric correlates with an entity (has all identifying labels) @@ -220,7 +220,7 @@ Note that **identifying labels cannot conflict** because they must be present on ## Technical Implementation -This section provides detailed implementation guidance for parsing entities and integrating with the scrape loop. The implementation should align with the storage layer defined in [04-storage.md](./04-storage.md). +This section provides detailed implementation guidance for parsing entities and integrating with the scrape loop. The implementation should align with the storage layer defined in [05-storage.md](./05-storage.md). ### Parser Interface Extensions @@ -576,10 +576,11 @@ In the [storage](04-storage.md) document, we go over the correlation index, WAL ## Related Documents - [01-context.md](./01-context.md) - Problem statement and motivation -- [03-service-discovery.md](./03-service-discovery.md) - How entities relate to Prometheus targets -- [04-storage.md](./04-storage.md) - How entities are stored in the TSDB -- [05-querying.md](./05-querying.md) - PromQL extensions for working with entities -- [06-web-ui-and-apis.md](./06-web-ui-and-apis.md) - How entities are displayed and accessed +- [03-sdk.md](./03-sdk.md) - How Prometheus client libraries support entities +- [04-service-discovery.md](./04-service-discovery.md) - How entities relate to Prometheus targets +- [05-storage.md](./05-storage.md) - How entities are stored in the TSDB +- [06-querying.md](./06-querying.md) - PromQL extensions for working with entities +- [07-web-ui-and-apis.md](./07-web-ui-and-apis.md) - How entities are displayed and accessed --- diff --git a/proposals/0071-Entity/03-sdk.md b/proposals/0071-Entity/03-sdk.md new file mode 100644 index 00000000..5eff2836 --- /dev/null +++ b/proposals/0071-Entity/03-sdk.md @@ -0,0 +1,417 @@ +# SDK Support for Entities + +## Abstract + +This document specifies how Prometheus client libraries should be extended to support the Entity concept. Using client_golang as the reference implementation, we define new types, interfaces, and patterns that enable applications to declare entities alongside metrics while maintaining backward compatibility with existing instrumentation code. + +The design prioritizes simplicity for the common case—an application instrumenting itself as a single entity—while providing flexibility for advanced scenarios like exporters that expose metrics for multiple entities. + +--- + +## Design Principles + +Before diving into implementation details, it's worth understanding the key design decisions that shaped this proposal. + +**Entities are not collectors.** In client_golang, metrics are managed through the Collector interface, which combines description and collection into a single abstraction. We considered making entities follow this pattern, but entities have fundamentally different characteristics: they represent the "things" that produce telemetry, not the telemetry itself. An entity like "this Kubernetes pod" cuts across multiple collectors (process metrics, Go runtime metrics, application metrics). Tying entities to collectors would create awkward ownership questions and unnecessary coupling. + +**The EntityRegistry is global and separate from the metric Registry.** This separation reflects the conceptual difference between "what is producing telemetry" (entities) and "what telemetry is being produced" (metrics). Making the EntityRegistry global (via `DefaultEntityRegistry`) enables validation at metric registration time—if a metric references a non-existent entity ref, registration fails immediately rather than silently producing invalid output at scrape time. + +**Descriptive labels are mutable, identifying labels are not.** An entity's identity (its type plus identifying labels) is immutable—changing it would make it a different entity. But descriptive labels like version numbers or human-readable names can change during the entity's lifetime. The API reflects this: `SetDescriptiveLabels()` atomically replaces all descriptive labels, while identifying labels are set only at construction. + +--- + +## Entity Types + +### Entity + +The `Entity` type represents a single entity instance: + +```go +type Entity struct { + ref uint64 // Assigned by EntityRegistry + entityType string // e.g., "service", "k8s.pod" + identifyingLabels Labels // Immutable after creation + descriptiveLabels Labels // Mutable via SetDescriptiveLabels + mtx sync.RWMutex // Protects descriptiveLabels +} + +// EntityOpts configures a new Entity +type EntityOpts struct { + Type string // Required: entity type name + Identifying Labels // Required: labels that uniquely identify this instance + Descriptive Labels // Optional: additional context labels +} + +// NewEntity creates an entity. +func NewEntity(opts EntityOpts) *Entity + +// Ref returns the entity's reference (0 if not yet registered) +func (e *Entity) Ref() uint64 + +// Type returns the entity type +func (e *Entity) Type() string + +// IdentifyingLabels returns a copy of the identifying labels +func (e *Entity) IdentifyingLabels() Labels + +// DescriptiveLabels returns a copy of the current descriptive labels +func (e *Entity) DescriptiveLabels() Labels + +// SetDescriptiveLabels atomically replaces all descriptive labels +func (e *Entity) SetDescriptiveLabels(labels Labels) +``` + +### EntityRegistry + +The `EntityRegistry` is a **global singleton**, similar to `prometheus.DefaultRegisterer`. This ensures that metrics can validate entity refs at registration time—if a metric references a non-existent entity, registration fails immediately rather than at scrape time. + +```go +// Global EntityRegistry instance +var DefaultEntityRegistry = NewEntityRegistry() + +type EntityRegistry struct { + mtx sync.RWMutex + byHash map[uint64]*Entity // hash(type+identifying) → Entity + byRef map[uint64]*Entity // ref → Entity + refCounter uint64 // Auto-increments on Register +} + + +// Register adds an entity and assigns its ref. +// Returns error if an entity with the same type+identifying labels exists. +func (er *EntityRegistry) Register(e *Entity) error + +// Unregister removes an entity by ref +func (er *EntityRegistry) Unregister(ref uint64) bool + +// Lookup finds an entity by type and identifying labels, returns its ref +func (er *EntityRegistry) Lookup(entityType string, identifying Labels) (ref uint64, found bool) + +// Get retrieves an entity by ref +func (er *EntityRegistry) Get(ref uint64) *Entity + +// Gather collects entities and metrics together into a MetricPayload. +// Only entities referenced by the gathered metrics are included. +func (er *EntityRegistry) Gather(gatherers ...Gatherer) (*dto.MetricPayload, error) +``` + +--- + +## Metric Integration + +Metrics declare their entity associations through the `EntityRefs` field in their options. This field contains the refs of entities that the metric correlates with. + +### Updated Metric Options + +```go +type CounterOpts struct { + Namespace string + Subsystem string + Name string + Help string + ConstLabels Labels + + // EntityRefs lists the refs of entities this metric correlates with. + // Obtain refs via Entity.Ref() after registering with EntityRegistry. + EntityRefs []uint64 +} + +// Same pattern for GaugeOpts, HistogramOpts, SummaryOpts, etc. +``` + +### Validation at Registration + +When a metric with `EntityRefs` is registered, the metric registry validates that all referenced entity refs exist in the global `DefaultEntityRegistry`. This catches configuration errors immediately: + +```go +// This works: entity is registered first +serviceEntity := prometheus.NewEntity(prometheus.EntityOpts{...}) +prometheus.RegisterEntity(serviceEntity) // Uses DefaultEntityRegistry + +counter := prometheus.NewCounter(prometheus.CounterOpts{ + Name: "requests_total", + EntityRefs: []uint64{serviceEntity.Ref()}, +}) +prometheus.MustRegister(counter) // Validates that serviceEntity.Ref() exists + +// This fails: entity ref doesn't exist +badCounter := prometheus.NewCounter(prometheus.CounterOpts{ + Name: "bad_counter", + EntityRefs: []uint64{999}, // No entity with this ref +}) +prometheus.MustRegister(badCounter) // PANIC: unknown entity ref 999 +``` + +### Usage Example + +```go +// Create and register entity +serviceEntity := prometheus.NewEntity(prometheus.EntityOpts{ + Type: "service", + Identifying: prometheus.Labels{ + "service.namespace": "production", + "service.name": "payment-api", + "service.instance.id": os.Getenv("INSTANCE_ID"), + }, + Descriptive: prometheus.Labels{ + "service.version": "1.0.0", + }, +}) +prometheus.RegisterEntity(serviceEntity) + +// Create metric that correlates with the entity +requestDuration := prometheus.NewHistogram(prometheus.HistogramOpts{ + Name: "http_request_duration_seconds", + Help: "HTTP request latency", + Buckets: prometheus.DefBuckets, + EntityRefs: []uint64{serviceEntity.Ref()}, +}) +prometheus.MustRegister(requestDuration) + +// Later: update descriptive labels during rolling deploy +serviceEntity.SetDescriptiveLabels(prometheus.Labels{ + "service.version": "2.0.0", +}) +``` + +### Multiple Entity Correlations + +A single metric can correlate with multiple entities. This is useful when a metric describes something that spans entity boundaries: + +```go +// Register both pod and node entities +podEntity := prometheus.NewEntity(prometheus.EntityOpts{ + Type: "k8s.pod", + Identifying: prometheus.Labels{ + "k8s.namespace.name": "default", + "k8s.pod.uid": "abc-123", + }, +}) +nodeEntity := prometheus.NewEntity(prometheus.EntityOpts{ + Type: "k8s.node", + Identifying: prometheus.Labels{ + "k8s.node.uid": "node-456", + }, +}) +entityRegistry.Register(podEntity) +entityRegistry.Register(nodeEntity) + +// Container CPU correlates with both pod AND node +containerCPU := prometheus.NewCounter(prometheus.CounterOpts{ + Name: "container_cpu_usage_seconds_total", + Help: "Total CPU usage by container", + EntityRefs: []uint64{podEntity.Ref(), nodeEntity.Ref()}, +}) +``` + +--- + +## Gathering and Exposition + +The `EntityRegistry.Gather()` method is the central coordination point. It accepts metric gatherers as arguments and returns a complete `dto.MetricPayload` containing both entities and metrics. This design enforces that entities are never gathered in isolation—they only make sense alongside their correlated metrics. + +### How Gather Works + +```go +func (er *EntityRegistry) Gather(gatherers ...Gatherer) (*dto.MetricPayload, error) { + // 1. Gather metrics from all provided gatherers + var allMetrics []*dto.MetricFamily + referencedRefs := make(map[uint64]struct{}) + + for _, g := range gatherers { + mfs, err := g.Gather() + if err != nil { + return nil, err + } + allMetrics = append(allMetrics, mfs...) + + // Track which entity refs are actually used by metrics + for _, mf := range mfs { + for _, ref := range mf.GetEntityRefs() { + referencedRefs[ref] = struct{}{} + } + } + } + + // 2. Only include entities that are referenced by at least one metric + // Orphan entities (not referenced by any metric) are excluded + entityFamilies := er.collectReferencedEntities(referencedRefs) + + // 3. Return complete payload + // - All metrics are included (with or without entity refs) + // - Only referenced entities are included + return &dto.MetricPayload{ + EntityFamily: entityFamilies, + MetricFamily: allMetrics, + }, nil +} +``` + +This filtering ensures that: +- **Metrics without entities** are still exposed +- **Entities without metrics** are excluded +- **Only the entities actually needed** are transmitted, reducing payload size + +### HTTP Handler Updates + +The promhttp package needs a handler that works with `EntityRegistry.Gather()`: + +```go +// HandlerFor creates an HTTP handler that exposes entities and metrics together +func HandlerFor(er *EntityRegistry, gatherers []Gatherer, opts HandlerOpts) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + payload, err := er.Gather(gatherers...) + if err != nil { + // error handling... + } + + contentType := expfmt.NegotiateIncludingOpenMetrics(r.Header) + w.Header().Set("Content-Type", string(contentType)) + + enc := expfmt.NewPayloadEncoder(w, contentType) + enc.EncodePayload(payload) + }) +} +``` + +### Usage Example + +```go +func main() { + // Register entity (uses global DefaultEntityRegistry) + serviceEntity := prometheus.NewEntity(prometheus.EntityOpts{...}) + prometheus.RegisterEntity(serviceEntity) + + // Register metrics + counter := prometheus.NewCounter(prometheus.CounterOpts{ + Name: "requests_total", + EntityRefs: []uint64{serviceEntity.Ref()}, + }) + prometheus.MustRegister(counter) + + // Expose via HTTP - uses global registries + http.Handle("/metrics", promhttp.Handler()) // Enhanced to use DefaultEntityRegistry + http.ListenAndServe(":8080", nil) +} +``` + +For custom registries, pass them explicitly: + +```go +entityReg := prometheus.NewEntityRegistry() +metricReg := prometheus.NewRegistry() + +http.Handle("/metrics", promhttp.HandlerFor(entityReg, []prometheus.Gatherer{metricReg}, promhttp.HandlerOpts{})) +``` + +--- + +## Changes to Supporting Libraries + +Implementing entity support requires coordinated changes across multiple repositories. + +### client_model + +The protobuf definitions need new message types: + +```protobuf +// EntityFamily groups entities of the same type +message EntityFamily { + required string type = 1; + repeated string identifying_label_names = 2; + repeated Entity entity = 3; +} + +// Entity represents a single entity instance +message Entity { + repeated LabelPair label = 1; // All labels (identifying + descriptive) +} + +// MetricPayload is the top-level message for combined exposition +message MetricPayload { + repeated EntityFamily entity_family = 1; + repeated MetricFamily metric_family = 2; +} +``` + +### common/expfmt + +The exposition format library needs encoder support for `MetricPayload`: + +```go +// PayloadEncoder encodes a complete MetricPayload +type PayloadEncoder interface { + EncodePayload(payload *dto.MetricPayload) error +} + +// NewPayloadEncoder creates an encoder for the combined format +func NewPayloadEncoder(w io.Writer, format Format) PayloadEncoder +``` + +For the text format, the encoder writes the payload in order: entity declarations first, then the `---` delimiter, then metric families. For the protobuf format, the encoder marshals the `MetricPayload` message directly. + +### client_golang + +The changes described in this document: +- New `Entity` and `EntityRegistry` types +- `EntityRegistry.Gather()` that accepts metric gatherers and returns `*dto.MetricPayload` +- Updated metric options with `EntityRefs` field +- Updated promhttp handlers + +--- + +## Backward Compatibility + +The design maintains full backward compatibility: + +**Existing metrics continue to work.** The `EntityRefs` field is optional. Metrics without entity associations work exactly as before—they simply don't correlate with any entity. + +**Existing registries are unaffected.** The metric `Registry` type is unchanged. Entity support is additive through the separate `EntityRegistry`. + +**Existing HTTP handlers work.** The standard `promhttp.Handler()` continues to expose metrics without entities. Applications opt into entity support by using the new `HandlerFor()` that accepts an `EntityRegistry`. + +**Gradual adoption is possible.** Applications can add entity support incrementally—register an entity, update a few metrics to reference it, and the rest continue working unchanged. + +--- + +## Advanced: Dynamic Entity Associations + +The design presented above works well for applications that instrument themselves, where entities are known at startup and metrics have fixed entity associations. However, some use cases require dynamic associations. + +### Exporters with Many Entities + +Exporters like kube-state-metrics expose metrics for thousands of entities (pods, nodes, deployments). Each metric sample correlates with a different entity based on its label values. For these cases, we propose a per-sample entity association: + +```go +// GaugeVec with per-sample entity support +podInfo := prometheus.NewGaugeVec(prometheus.GaugeVecOpts{ + Name: "kube_pod_info", + VariableLabels: []string{"pod_name", "node"}, +}) + +// When recording, specify which entity this sample correlates with +podInfo.WithEntityRef(podEntities[pod.UID].Ref()). + WithLabelValues("nginx", "node-1"). + Set(1) +``` + +This API extension is optional and can be added in a future iteration once the core entity support is stable. + +--- + +## Open Questions + +Several aspects of this design warrant community feedback: + +**promauto integration.** How should the promauto convenience package handle entities? + +**Entity unregistration and metrics.** If an entity is unregistered while metrics still reference it, what should happen? Options: prevent unregistration while referenced, allow it and have Gather skip the missing entity, or error at gather time. + +--- + +## Related Documents + +- [01-context.md](./01-context.md) — Problem statement and entity concept +- [02-exposition-formats.md](./02-exposition-formats.md) — Wire format for entities +- [05-storage.md](./05-storage.md) — How Prometheus stores entities + diff --git a/proposals/0071-Entity/03-service-discovery.md b/proposals/0071-Entity/04-service-discovery.md similarity index 99% rename from proposals/0071-Entity/03-service-discovery.md rename to proposals/0071-Entity/04-service-discovery.md index a9b0eba3..d59a32f4 100644 --- a/proposals/0071-Entity/03-service-discovery.md +++ b/proposals/0071-Entity/04-service-discovery.md @@ -679,7 +679,7 @@ An SD-derived entity is created when a target with matching `__meta_*` labels fi When a target is re-discovered (on each SD refresh) and `entity_from_sd: true`: 1. Entity identifying labels are checked against existing entities 2. If entity exists, descriptive labels are compared -3. If descriptive labels changed, a new snapshot is recorded (see [04-storage.md](./04-storage.md)) +3. If descriptive labels changed, a new snapshot is recorded (see [05-storage.md](./05-storage.md)) ### Entity Staleness @@ -783,9 +783,9 @@ When multiple SD mechanisms can discover similar resources (e.g., EC2, Azure, GC - [01-context.md](./01-context.md) - Problem statement, motivation, and use cases - [02-exposition-formats.md](./02-exposition-formats.md) - How entities are represented in wire formats -- [04-storage.md](./04-storage.md) - How entities are stored in the TSDB -- [05-querying.md](./05-querying.md) - PromQL extensions for working with entities -- [06-web-ui-and-apis.md](./06-web-ui-and-apis.md) - How entities are displayed and accessed +- [05-storage.md](./05-storage.md) - How entities are stored in the TSDB +- [06-querying.md](./06-querying.md) - PromQL extensions for working with entities +- [07-web-ui-and-apis.md](./07-web-ui-and-apis.md) - How entities are displayed and accessed --- diff --git a/proposals/0071-Entity/04-storage.md b/proposals/0071-Entity/05-storage.md similarity index 99% rename from proposals/0071-Entity/04-storage.md rename to proposals/0071-Entity/05-storage.md index 6cbbd1a1..b98e2df5 100644 --- a/proposals/0071-Entity/04-storage.md +++ b/proposals/0071-Entity/05-storage.md @@ -1,6 +1,6 @@ # Entity Storage -> **Recommended Approach**: This document describes the correlation-based storage design, which we recommend for initial implementation due to its incremental nature and backward compatibility. An alternative design that fundamentally changes how series identity works is described in [04b-storage-entity-native.md](04b-storage-entity-native.md). +> **Recommended Approach**: This document describes the correlation-based storage design, which we recommend for initial implementation due to its incremental nature and backward compatibility. An alternative design that fundamentally changes how series identity works is described in [05b-storage-entity-native.md](05b-storage-entity-native.md). ## Abstract diff --git a/proposals/0071-Entity/04b-storage-entity-native.md b/proposals/0071-Entity/05b-storage-entity-native.md similarity index 99% rename from proposals/0071-Entity/04b-storage-entity-native.md rename to proposals/0071-Entity/05b-storage-entity-native.md index 0eeaa790..c7404108 100644 --- a/proposals/0071-Entity/04b-storage-entity-native.md +++ b/proposals/0071-Entity/05b-storage-entity-native.md @@ -1,6 +1,6 @@ # Storage Design: Entity-Native Model -> **Alternative Approach**: This document describes an alternative storage design where series identity is based only on metric labels, with samples grouped into "streams" by entity. While this approach offers stronger alignment with OpenTelemetry's data model and addresses cardinality at a fundamental level, it requires significant changes to Prometheus's core architecture. We recommend the correlation-based approach described in [04-storage.md](04-storage.md) for initial implementation, as it can be built incrementally on the existing TSDB without breaking backward compatibility. This entity-native design remains valuable as a potential future evolution once entities prove their value in production. +> **Alternative Approach**: This document describes an alternative storage design where series identity is based only on metric labels, with samples grouped into "streams" by entity. While this approach offers stronger alignment with OpenTelemetry's data model and addresses cardinality at a fundamental level, it requires significant changes to Prometheus's core architecture. We recommend the correlation-based approach described in [05-storage.md](05-storage.md) for initial implementation, as it can be built incrementally on the existing TSDB without breaking backward compatibility. This entity-native design remains valuable as a potential future evolution once entities prove their value in production. ## Executive Summary diff --git a/proposals/0071-Entity/05-querying.md b/proposals/0071-Entity/06-querying.md similarity index 99% rename from proposals/0071-Entity/05-querying.md rename to proposals/0071-Entity/06-querying.md index f6c2ec2d..bb6cf148 100644 --- a/proposals/0071-Entity/05-querying.md +++ b/proposals/0071-Entity/06-querying.md @@ -552,4 +552,4 @@ func (e *EntityTypeOr) Matches(types map[string]bool) bool { --- -The next document will cover [Web UI and APIs](./06-web-ui-and-apis.md), detailing how these capabilities are exposed in Prometheus's user interface and HTTP APIs. +The next document will cover [Web UI and APIs](./07-web-ui-and-apis.md), detailing how these capabilities are exposed in Prometheus's user interface and HTTP APIs. diff --git a/proposals/0071-Entity/06-web-ui-and-apis.md b/proposals/0071-Entity/07-web-ui-and-apis.md similarity index 100% rename from proposals/0071-Entity/06-web-ui-and-apis.md rename to proposals/0071-Entity/07-web-ui-and-apis.md From fc116b719df9cef268043e8a3f479530e8749d9d Mon Sep 17 00:00:00 2001 From: Arthur Silva Sens Date: Mon, 15 Dec 2025 12:07:01 -0300 Subject: [PATCH 3/5] Add alerting rules and Alertmanager proposal Signed-off-by: Arthur Silva Sens --- proposals/0071-Entity/01-context.md | 2 +- proposals/0071-Entity/08-alerting.md | 625 +++++++++++++++++++++++++++ 2 files changed, 626 insertions(+), 1 deletion(-) create mode 100644 proposals/0071-Entity/08-alerting.md diff --git a/proposals/0071-Entity/01-context.md b/proposals/0071-Entity/01-context.md index 0e3863ae..1c1c90b2 100644 --- a/proposals/0071-Entity/01-context.md +++ b/proposals/0071-Entity/01-context.md @@ -447,8 +447,8 @@ This document establishes the context and motivation for native Entity support i - **[Storage](./05-storage.md)**: How entities are stored efficiently in the TSDB - **[Querying](./06-querying.md)**: PromQL extensions for working with entities - **[Web UI and APIs](./07-web-ui-and-apis.md)**: How entities are displayed and accessed +- **[Alerting](./08-alerting.md)**: How entities interact with alerting rules and Alertmanager - **Remote Write (TBD)**: Protocol changes for transmitting entities over remote write -- **Alerting (TBD)**: How entities interact with alerting rules and Alertmanager --- diff --git a/proposals/0071-Entity/08-alerting.md b/proposals/0071-Entity/08-alerting.md new file mode 100644 index 00000000..679ff6c4 --- /dev/null +++ b/proposals/0071-Entity/08-alerting.md @@ -0,0 +1,625 @@ +# Alerting: Entity-Aware Alert Evaluation + +## Abstract + +This document specifies how Prometheus alerting rules and Alertmanager interact with the Entity concept introduced in [01-context.md](./01-context.md). The central challenge is ensuring that alerts remain stable when entity descriptive labels change—a pod migrating between nodes or a service being upgraded should not cause alerts to "flap" (appearing to resolve and re-fire). + +We introduce the concept of **Alert Identity**—a stable identifier for an alert that persists even when some labels change. This builds on the existing Fingerprint mechanism but distinguishes between labels that define identity versus labels that provide context. The key insight is that labels explicitly used in an alert expression signal user intent and should contribute to identity, while labels added purely through automatic enrichment (as described in [06-querying.md](./06-querying.md)) are contextual metadata. + +This document explores the implications for both Prometheus and Alertmanager, including trade-offs and the need for Alertmanager API changes to fully realize stable alert identity across the pipeline. + +--- + +## Background + +### Alert Figerprint + +In current Prometheus, each alert has a **Fingerprint**—a hash computed from all of its labels: + +```go +// rules/alerting.go - current implementation +type Alert struct { + State AlertState + Labels labels.Labels + Annotations labels.Labels + Value float64 + // ... timestamps ... +} + +func (a *Alert) Fingerprint() model.Fingerprint { + return a.Labels.Fingerprint() // Hash of ALL labels +} +``` + +The fingerprint determines: +- **State tracking:** Which alerts are currently active (the `active` map in `AlertingRule`) +- **`for` clause:** Whether an alert has been pending long enough to fire +- **Deduplication:** Whether to send an alert again or skip it + +This works well today because labels are stable—they come from the metric's own labels, labels added by the rule configuration, and external labels. When any label changes, it's intentionally a different alert: `{instance="server-1"}` and `{instance="server-2"}` are distinct alerts tracking distinct issues. + +### The Challenge: Enriched Labels Change + +With entity support, query results are automatically enriched with entity labels as described in [06-querying.md](./06-querying.md). This enrichment includes **descriptive labels** that can change during an entity's lifetime: + +- A pod's `k8s.pod.status.phase` changes from `Pending` to `Running` +- A service's `service.version` changes during deployment +- A node's `k8s.node.name` could theoretically change + +If the existing fingerprint mechanism includes all enriched labels, alerts would "flap"—appearing to resolve and re-fire whenever a descriptive label changes, even though the underlying condition persists. + +**Example of the problem:** + +```yaml +alert: HighCPU +expr: container_cpu_usage_seconds_total > 0.9 +for: 5m +``` + +1. T0: Alert becomes Pending with labels `{pod_uid="abc", k8s.node.name="worker-1"}` +2. T3: Pod migrates, `k8s.node.name` changes to `worker-2` +3. With naive fingerprinting, Prometheus sees: + - Alert `{..., k8s.node.name="worker-1"}` disappeared (resolved?) + - Alert `{..., k8s.node.name="worker-2"}` appeared (new!) + - The `for: 5m` timer resets + +This defeats the purpose of the `for` clause and creates confusing behavior. + +--- + +## Introducing Alert Identity + +### Why We Need a New Concept + +The existing Fingerprint mechanism served Prometheus well because label stability was assumed. With entity enrichment, we need a more nuanced concept: **Alert Identity**. + +Alert Identity answers the question: "Is this the same alert as before, or a different one?" While Fingerprint simply hashes all labels, Alert Identity considers which labels are semantically significant for distinguishing alerts. + +The term "Alert Identity" is new to Prometheus—it doesn't exist in the current codebase. We introduce it here to describe the stable identifier we need, which will be implemented as a modified fingerprint computation that excludes certain labels. + +### Identifying Labels vs. Descriptive Labels in Alert Identity + +As established in [01-context.md](./01-context.md), entity labels fall into two categories: **identifying labels** (which uniquely identify an entity, like `k8s.pod.uid`) and **descriptive labels** (which provide additional context that may change, like `k8s.node.name`). + +For Alert Identity, we treat these categories differently: + +- **Entity identifying labels are always part of alert identity.** These labels uniquely identify the entity producing the alert. If two alerts have different `k8s.pod.uid` values, they're fundamentally about different pods and should be distinct alerts. + +- **Entity descriptive labels are only part of identity if explicitly used in the expression.** This is where user intent matters. + +Consider this rule: + +```yaml +alert: PodHighMemory +expr: container_memory_usage_bytes > 1e9 +``` + +When evaluated, the query engine enriches results with entity labels including both `k8s.pod.uid` (identifying) and `k8s.node.name` (descriptive). The identifying label `k8s.pod.uid` is always part of identity—different pods are different alerts. But the descriptive label `k8s.node.name` is NOT part of identity here because the user didn't filter on it. If a pod migrates from worker-1 to worker-2, it remains the same alert (same pod UID). + +Now consider a rule that explicitly filters on a descriptive label: + +```yaml +alert: NodeHighCPU +expr: cpu_usage{k8s.node.name="worker-1"} > 80 +``` + +Here, the user explicitly filtered by `k8s.node.name="worker-1"`. They're saying: "I specifically care about worker-1." The descriptive label `k8s.node.name` becomes part of identity because the user declared it significant by including it in their expression. If they wrote another rule for worker-2, those would be separate alerts. + +This leads to our core principle: + +> **Labels explicitly used in the expression signal user intent and contribute to identity. Labels added purely through enrichment are context that doesn't affect identity.** + +### What Constitutes Identity + +Based on this principle, Alert Identity is computed from: + +1. **Metric labels** — Original labels on the time series +2. **Entity identifying labels** — Labels that uniquely identify an entity (e.g., `k8s.pod.uid`) +3. **Explicit descriptive labels** — Descriptive labels the user filtered on in the expression +4. **Rule-defined labels** — Labels added in the rule's `labels:` configuration +5. **External labels** — Prometheus-wide labels from configuration + +Labels **excluded** from identity: + +1. **Enriched descriptive labels** — Descriptive labels added by automatic enrichment that weren't explicitly referenced + +### The Alertmanager Challenge + +Here's where things get complicated. Prometheus computes Alert Identity internally, but **Alertmanager also computes its own fingerprint** from the labels it receives. If we send all labels (including enriched descriptive) to Alertmanager: + +``` +Prometheus Alertmanager +┌──────────────────┐ ┌──────────────────┐ +│ Identity = hash( │ │ Fingerprint = │ +│ metric_labels │ sends all │ hash(all │ +│ + identifying │ labels │ received │ +│ + explicit │ ──────────────► │ labels) │ +│ + rule_labels │ │ │ +│ ) │ │ │ +│ │ │ If labels change,│ +│ ✓ Stable │ │ fingerprint │ +│ │ │ changes! │ +└──────────────────┘ └──────────────────┘ +``` + +If descriptive labels change between alert sends: +- Prometheus sees the same alert (stable identity) +- Alertmanager sees a "new" alert (different fingerprint) +- Alertmanager might send duplicate notifications +- Alert might move between groups +- Silences might stop matching + +This is a real problem that we must address explicitly. + +--- + +## Design Options + +We have three main approaches to handle this challenge: + +### Option A: Identity is Prometheus-Internal Only + +Prometheus uses Alert Identity internally for state tracking and the `for` clause. When sending to Alertmanager, it sends all labels. Alertmanager's behavior with changing labels is documented but accepted. + +**Prometheus changes:** +- Compute identity from identity labels for internal state tracking +- Send all labels to Alertmanager + +**Alertmanager changes:** None + +**Trade-offs:** +- ✅ Simple—no Alertmanager API changes +- ✅ Prometheus internal state is stable +- ❌ Alertmanager may re-notify when descriptive labels change +- ❌ Groups may split/merge unexpectedly +- ❌ Silences by descriptive labels may break + +**Mitigation:** Document that users should group/silence by stable labels (identifying labels) for predictable behavior. + +### Option B: Alertmanager API Receives Identity Separately + +Extend the Alertmanager API to receive identity labels separately from all labels. + +**Prometheus changes:** +- Compute identity labels +- Send both `identityLabels` and `labels` to Alertmanager + +**Alertmanager changes:** +- API accepts new `identityLabels` field +- Use `identityLabels` for fingerprinting and deduplication +- Use full `labels` for routing matchers and notification templates + +**Trade-offs:** +- ✅ Full stability across the pipeline +- ✅ Alertmanager can correctly deduplicate +- ❌ Requires API version bump +- ❌ Requires coordinated changes to both systems +- ❌ Breaking change for existing Alertmanager integrations + +### Option C: Only Send Identity Labels + +Prometheus only sends identity labels to Alertmanager. Enriched descriptive labels are either dropped or moved to annotations. + +**Trade-offs:** +- ✅ Simple Alertmanager, stable fingerprints +- ❌ Loses rich context in notification templates +- ❌ Awkward if users want to route by descriptive labels + +### Recommendation + +We recommend **Option B** for full correctness, with **Option A** as an acceptable intermediate step that doesn't require Alertmanager changes. + +Option A is sufficient for ensuring Prometheus's `for` clause works correctly. The Alertmanager "churn" is manageable if users follow best practices (group and silence by stable labels). Option B can be implemented later as an enhancement. + +The rest of this document assumes Option B as the target design, with notes on Option A where relevant. + +--- + +## Prometheus Implementation + +### Tracking Explicit Labels + +The alerting rule must track which labels were explicitly used in the expression. During rule creation, we parse the expression AST and extract label names from all matchers: + +```go +type AlertingRule struct { + name string + vector parser.Expr + holdDuration time.Duration + labels labels.Labels + annotations labels.Labels + + // Labels explicitly referenced in matchers within the expression + explicitLabels map[string]struct{} + + // Reference to entity store for identifying/descriptive label lookup + entityStore storage.EntityQuerier +} + +func NewAlertingRule(name string, expr parser.Expr, ...) *AlertingRule { + rule := &AlertingRule{ + name: name, + vector: expr, + // ... + } + rule.explicitLabels = extractExplicitLabels(expr) + return rule +} + +func extractExplicitLabels(expr parser.Expr) map[string]struct{} { + explicit := make(map[string]struct{}) + + parser.Inspect(expr, func(node parser.Node, _ []parser.Node) error { + if vs, ok := node.(*parser.VectorSelector); ok { + for _, matcher := range vs.LabelMatchers { + if matcher.Name != labels.MetricName { + explicit[matcher.Name] = struct{}{} + } + } + } + return nil + }) + + return explicit +} +``` + +### Computing Identity Labels + +When evaluating an alert, we separate identity labels from the full enriched label set. The query engine returns enriched results as described in [06-querying.md](./06-querying.md), and we filter them: + +```go +func (r *AlertingRule) computeIdentityLabels(allLabels labels.Labels) labels.Labels { + builder := labels.NewBuilder(nil) + + for _, lbl := range allLabels { + if r.isIdentityLabel(lbl.Name) { + builder.Set(lbl.Name, lbl.Value) + } + } + + return builder.Labels() +} + +func (r *AlertingRule) isIdentityLabel(name string) bool { + // Metric name is always part of identity + if name == labels.MetricName { + return true + } + + // Entity identifying labels are always part of identity + if r.entityStore != nil && r.entityStore.IsIdentifyingLabel(name) { + return true + } + + // Descriptive labels that were explicitly filtered are part of identity + if _, explicit := r.explicitLabels[name]; explicit { + return true + } + + // If it's NOT a known entity label, it's an original metric label → identity + if r.entityStore == nil { + return true + } + if !r.entityStore.IsDescriptiveLabel(name) { + return true + } + + // Enriched descriptive label → NOT part of identity + return false +} +``` + +### Alert Evaluation Flow + +The `Eval` method uses identity labels for internal state while tracking full labels for sending: + +```go +func (r *AlertingRule) Eval(ctx context.Context, ts time.Time, ...) (Vector, error) { + // Query engine returns enriched results (see 06-querying.md) + res, err := r.vector.Eval(ctx, ts, ...) + if err != nil { + return nil, err + } + + for _, sample := range res { + // Compute identity labels (subset used for fingerprinting) + identityLabels := r.computeIdentityLabels(sample.Metric) + + // Full labels include everything (for sending to Alertmanager) + fullLabels := sample.Metric + + // Add rule-defined labels to both + for _, l := range r.labels { + identityLabels = append(identityLabels, l) + fullLabels = append(fullLabels, l) + } + + // Look up or create alert using IDENTITY labels for fingerprint + fp := identityLabels.Fingerprint() + alert := r.active[fp] + if alert == nil { + alert = &Alert{ + IdentityLabels: identityLabels, + Labels: fullLabels, + Annotations: r.annotations, + ActiveAt: ts, + } + r.active[fp] = alert + } else { + // Alert exists—update full labels (descriptive may have changed) + alert.Labels = fullLabels + } + + alert.Value = sample.V + } + + // ... rest of evaluation (state transitions, for clause, etc.) +} +``` + +### The Alert Struct + +We rename the fields to make the distinction clear: + +```go +type Alert struct { + State AlertState + + // IdentityLabels are used for fingerprinting and state tracking. + // These labels are stable even when descriptive labels change. + IdentityLabels labels.Labels + + // Labels includes all labels: identity + enriched descriptive. + // This is what gets sent to Alertmanager for routing and templates. + Labels labels.Labels + + Annotations labels.Labels + Value float64 + + ActiveAt time.Time + FiredAt time.Time + ResolvedAt time.Time + LastSentAt time.Time + ValidUntil time.Time + KeepFiringSince time.Time +} + +// Fingerprint uses identity labels for stability +func (a *Alert) Fingerprint() model.Fingerprint { + return a.IdentityLabels.Fingerprint() +} +``` + +Note that `Labels` (full labels) is **not** redundantly storing identity labels—it's the complete set. We could optimize storage by only storing the "extra" descriptive labels and computing full labels on demand, but this complicates the code for minimal gain. + +--- + +## Alertmanager Changes + +### Option A: No Changes (Intermediate) + +If we proceed with Option A (Prometheus-internal identity only), Alertmanager receives alerts as today with `labels` containing all labels. Users must be aware: + +- Grouping by descriptive labels may cause groups to change over time +- Silences by descriptive labels may stop matching if labels change +- Notification deduplication may re-notify on label changes + +**Best practices for Option A:** +- Group by identifying labels: `group_by: [alertname, k8s.pod.uid]` not `k8s.pod.name` +- Silence by identifying labels for stability +- Accept that notifications may include different descriptive label values over time + +### Option B: API Extension (Target Design) + +Extend the Alertmanager API to accept identity label references. To avoid duplicating label strings, `identityLabelRefs` contains indices into the `labels` array: + +```json +// POST /api/v2/alerts - Extended payload +[ + { + "labels": [ + { "name": "alertname", "value": "HighCPU" }, + { "name": "k8s.pod.uid", "value": "abc-123" }, + { "name": "k8s.pod.name", "value": "nginx-7b9f5" }, + { "name": "k8s.node.name", "value": "worker-1" }, + { "name": "severity", "value": "warning" } + ], + "identityLabelRefs": [0, 1, 4], + "annotations": { ... }, + "startsAt": "2024-01-15T10:30:00Z", + "endsAt": "0001-01-01T00:00:00Z", + "generatorURL": "..." + } +] +``` + +Here, `identityLabelRefs: [0, 1, 4]` indicates that labels at positions 0 (`alertname`), 1 (`k8s.pod.uid`), and 4 (`severity`) constitute the alert's identity. Alertmanager reconstructs identity labels by indexing into the `labels` array. + +Alertmanager changes: +1. Accept `identityLabelRefs` field (optional for backward compatibility) +2. If present, construct identity labels from the referenced indices in `labels` +3. Use identity labels for fingerprinting and deduplication +4. Use full `labels` for routing matchers and notification templates +5. Groups are keyed by identity labels, not full labels + +This ensures the entire pipeline respects Alert Identity. + +### Routing and Grouping + +With either option, routing matchers operate on full `labels`: + +```yaml +route: + group_by: [alertname, k8s.namespace.name] # Both are identity labels + routes: + - matchers: + - k8s.node.name=~"worker-.*" # Can match descriptive labels + receiver: node-team +``` + +With Option B, even though `k8s.node.name` changes, the alert stays in the same group because grouping uses identity labels internally. + +### Silencing and Inhibition + +Silences match against full `labels`: + +```yaml +matchers: + - k8s.pod.uid="abc-123" # Identifying - stable match + - k8s.node.name="worker-1" # Descriptive - may stop matching if pod migrates +``` + +With Option B, the silence correctly continues matching because the alert's identity hasn't changed, even if `k8s.node.name` changed. + +--- + +## Temporal Semantics + +### Which Label Values Are Sent? + +When Prometheus evaluates an alerting rule at time T, the query engine enriches results with descriptive labels as they exist at time T (see [06-querying.md](./06-querying.md) for details on point-in-time label resolution). These are the values included in `Labels` when sending to Alertmanager. + +If an alert persists across multiple evaluation cycles: +- T1: Labels include `{service.version="1.0.0"}` +- T2: Service upgrades +- T3: Labels include `{service.version="2.0.0"}` + +With stable Alert Identity, this is still the same alert. Notifications at T3 reflect the current state. + +### The `for` Clause + +The `for` clause requires an alert to be continuously active for a duration before firing: + +```yaml +alert: HighCPU +expr: cpu_usage > 0.9 +for: 5m +``` + +This works correctly because Prometheus tracks alerts by `IdentityLabels.Fingerprint()`. Descriptive label changes don't reset the timer: + +1. T0: Alert becomes Pending, identity `{instance="server-1", k8s.pod.uid="abc"}` +2. T1-T4: Entity's `k8s.node.name` changes multiple times +3. T5: Alert fires (5 minutes elapsed, same identity throughout) + +--- + +## Examples + +### Basic Alert with Enrichment + +```yaml +alert: PodHighMemory +expr: container_memory_usage_bytes > 1e9 +for: 2m +labels: + severity: warning +annotations: + summary: "Pod {{ $labels.k8s.pod.name }} high memory on {{ $labels.k8s.node.name }}" +``` + +**Identity labels:** `{__name__, container, k8s.pod.uid, alertname, severity}` + +**Full labels (sent to Alertmanager):** Identity + `k8s.pod.name`, `k8s.node.name`, `k8s.pod.status.phase`, etc. + +The annotation templates can reference enriched descriptive labels. + +### Alert with Explicit Descriptive Filter + +```yaml +alert: CriticalPodPending +expr: kube_pod_status_phase{k8s.pod.status.phase="Pending"} == 1 +for: 10m +labels: + severity: critical +``` + +**Identity labels:** `{__name__, k8s.pod.uid, k8s.pod.status.phase, alertname, severity}` + +Here `k8s.pod.status.phase` IS part of identity because the user explicitly filtered on it. This alert resolves when the pod transitions to `Running`. + +### Two Alerts Distinguished by Descriptive Labels + +```yaml +# Alert 1 +alert: WorkerOneHighCPU +expr: cpu_usage{k8s.node.name="worker-1"} > 80 + +# Alert 2 +alert: WorkerTwoHighCPU +expr: cpu_usage{k8s.node.name="worker-2"} > 80 +``` + +These have different `alertname` values, so they're distinct regardless of whether `k8s.node.name` is considered identity. But even with the same alert name, the explicit filter makes `k8s.node.name` part of identity for each rule. + +--- + +## Backward Compatibility + +For metrics without entity correlation: +- `explicitLabels` contains labels from the expression +- `entityStore.IsIdentifyingLabel()` and `IsDescriptiveLabel()` return false +- All labels are treated as identity labels +- Behavior matches current Prometheus exactly + +Existing alerting rules work unchanged. Entity-aware behavior only activates for metrics that have entity correlations. + +--- + +## Open Questions + +### Migration Path for Alertmanager + +If we implement Option B, what's the migration path? +- New API version with `identityLabels` field? +- Backward compatible: if `identityLabels` absent, use `labels`? +- How do we handle mixed Prometheus/Alertmanager versions during rollout? + +### Recording Rules + +If a recording rule aggregates entity-correlated metrics: + +```yaml +record: job:requests:rate5m +expr: sum by (job) (rate(http_requests_total[5m])) +``` + +The recorded metric loses entity correlation (aggregated away). Alerting on this metric behaves as today (no entity enrichment). Is this acceptable, or should we track "derived" correlations? + +### Relabeling Entity Labels + +Should alert relabeling be able to manipulate entity labels? + +```yaml +alerting: + alert_relabel_configs: + - source_labels: [k8s.node.name] + action: drop +``` + +This works on full `labels`. Should there be restrictions or warnings when dropping identity labels? + +--- + +## Summary + +Entity-aware alerting introduces Alert Identity as a concept built on top of the existing Fingerprint mechanism. The core principle is that **explicit labels signal user intent**, while **enriched labels provide context**. + +| Component | Change | +|-----------|--------| +| Prometheus alerting rules | Track explicit labels, compute identity separately | +| Prometheus `Alert` struct | Split into `IdentityLabels` and `Labels` | +| Alertmanager (Option A) | None—document behavioral implications | +| Alertmanager (Option B) | Accept `identityLabels` for fingerprinting | + +The key insight: **"If you mentioned it, you meant it."** Labels in the expression contribute to identity. Labels from enrichment provide context without affecting identity. + +--- + +## Related Documents + +- [01-context.md](./01-context.md) — Problem statement and entity concept +- [05-storage.md](./05-storage.md) — How entities and correlations are stored +- [06-querying.md](./06-querying.md) — Entity-aware PromQL and automatic enrichment +- [07-web-ui-and-apis.md](./07-web-ui-and-apis.md) — UI and API exposure of alerts From 418b2e9c60d9b70414ffe36aa6500c9e1454ddcc Mon Sep 17 00:00:00 2001 From: Arthur Silva Sens Date: Mon, 29 Dec 2025 15:33:27 -0300 Subject: [PATCH 4/5] Reword proposal to rely more on info metrics Signed-off-by: Arthur Silva Sens --- proposals/0071-Entity/01-context.md | 67 +-- .../0071-Entity/02-exposition-formats.md | 481 ++++++++---------- proposals/0071-Entity/99-alternatives.md | 52 ++ 3 files changed, 306 insertions(+), 294 deletions(-) create mode 100644 proposals/0071-Entity/99-alternatives.md diff --git a/proposals/0071-Entity/01-context.md b/proposals/0071-Entity/01-context.md index 1c1c90b2..7a3ef710 100644 --- a/proposals/0071-Entity/01-context.md +++ b/proposals/0071-Entity/01-context.md @@ -2,15 +2,15 @@ ## Abstract -This proposal introduces native support for **Entities** in Prometheus—a first-class representation of the things that produce telemetry, distinct from the telemetry they produce. +This proposal introduces native support for **Entities** in Prometheus—a first-class concept representing **the "things" that produce telemetry**. -Today, Prometheus relies on Info-type metrics to represent metadata about monitored objects: gauges with an `_info` suffix, a constant value of `1`, and labels containing the metadata. But this approach is fundamentally flawed: **the thing that produces metrics is not itself a metric**. A Kubernetes pod, a service instance, or a host are entities with their own identity and lifecycle—they should not be stored as time series with sample values. +A Kubernetes pod, a service instance, a physical host—these are not metrics themselves, but rather the *sources* of metrics. They have their own identity, lifecycle, and attributes that provide context for understanding the telemetry they produce. Today, Prometheus lacks a native way to represent these "things". While the ecosystem has developed conventions (like info metrics) to work around this gap, Prometheus itself doesn't understand what these conventions represent. -This conflation forces users to rely on verbose `group_left` joins to attach metadata to metrics, creates storage inefficiency for constant values, and loses the semantic distinction between what identifies an entity and what describes it. +**This proposal establishes Entities as a foundational concept in Prometheus.** An Entity represents a distinct object of interest in your infrastructure or application—something that has an identity, produces telemetry, and whose metadata helps you understand that telemetry. -By introducing Entities as a native concept, Prometheus can provide cleaner query ergonomics, optimized storage for metadata, explicit lifecycle management, and proper semantics that distinguish between identifying labels (what makes an entity unique) and descriptive labels (additional context about that entity). +By making Entities first-class, this proposal enables Prometheus to support them consistently across all layers. Exposition formats gain semantics to declare entity information; SDKs provide clean abstractions for instrumenting entities; storage optimizes for entity metadata and relationships; the query language automatically correlates entity context with metrics; and alerting maintains stable alert identity as entity attributes change. -This proposal also aligns with Prometheus's commitment to being the default store for OpenTelemetry metrics. OpenTelemetry's Entity model provides a well-defined structure for representing monitored objects, and native Entity support enables seamless translation between OTel Entities and Prometheus. +This proposal also aligns with Prometheus's commitment to being the default store for OpenTelemetry metrics, which has a well-defined Entity model. Native Entity support enables seamless integration between OpenTelemetry's view of the world and Prometheus's. --- @@ -28,11 +28,20 @@ build_info{version="1.2.3", revision="abc123", goversion="go1.21"} 1 #### Entity -An **Entity** represents a distinct object of interest that produces or is associated with telemetry. Unlike Info metrics, Entities are not metrics—they are first-class objects with their own identity, labels, and lifecycle. +An **Entity** represents a distinct object of interest that produces or is associated with telemetry. Unlike Info metrics, Entities are not metrics—they are first-class objects with their own identity, labels, and +lifecycle. -In OpenTelemetry, an entity is an object of interest that produces telemetry data. Entities represent things like services, hosts, containers, or Kubernetes pods. Each entity has a type (e.g., `k8s.pod`, `host`, `service`) and a set of attributes that describe it. +Examples: a Kubernetes pod, a physical host, a service instance, a database table. -This proposal adopts the Entity concept as the native Prometheus representation for what was previously expressed through Info metric conventions. +Each entity has: +- A **type** (e.g., `k8s.pod`, `host`, `service`) +- **Identifying labels** that uniquely define it (immutable for the entity's lifetime) +- **Descriptive labels** that provide additional context (may change over time) +- **Lifecycle boundaries** (creation time, end time) + +In OpenTelemetry, an entity is an object of interest that produces telemetry data. This proposal adopts a compatible Entity concept as Prometheus's native representation for what was previously expressed only through info metric conventions. + +**The relationship:** Entities are the concept; info metrics are how they're serialized in the exposition format. #### Resource Attributes @@ -66,46 +75,26 @@ Examples: ## Problem Statement -### Entities Are Not Metrics +### Prometheus Is Missing the Entity Concept -At the heart of the Info metric pattern lies a conceptual mismatch: **the thing that produces metrics is not itself a metric**. +Prometheus has a powerful data model for representing **metrics**—time series of numeric measurements identified by labels. But it lacks a native representation for "things" that produce metrics. -Consider a Kubernetes pod. It has an identity (namespace, UID), labels that describe it (name, node, pod labels), a lifecycle (creation time, termination), and it produces telemetry (CPU usage, memory consumption, request counts). The pod is the *source* of metrics—it is not *a* metric. +Consider a Kubernetes pod. It has an identity (namespace, UID), labels that describe it (name, node, status), a lifecycle (creation time, termination), and it produces telemetry (CPU usage, memory consumption, request counts). The pod is the *source* of metrics—it is conceptually distinct from the metrics it produces. -Yet today, we represent this pod as a metric: +Today, the Prometheus ecosystem uses **info metrics** to represent entity metadata: ```promql kube_pod_info{namespace="production", pod="api-server-7b9f5", uid="550e8400", node="worker-2"} 1 ``` -This representation has several conceptual problems: - -1. **The value is meaningless**: The `1` carries no information. It exists only because Prometheus's data model requires a numeric value. -2. **Identity is conflated with data**: All labels are treated equally. There's no distinction between `uid` (which identifies the pod) and `node` (which describes where it's running and could change). -3. **Lifecycle is implicit**: When a pod is deleted and recreated with the same name, Prometheus sees label churn. There's no explicit representation of "this entity ended, a new one began." -4. **Correlation requires workarounds**: To associate the pod's metadata with its metrics, users must write complex `group_left` joins—essentially reconstructing a relationship that should be built into the data model. - -The Prometheus data model was designed for metrics: measurements that change over time, represented as (timestamp, value) pairs with identifying labels. Entities don't fit this model. They have: - -- **Stable identity** (not a stream of values) -- **Mutable descriptions** (labels that change independently of any "sample") -- **Explicit lifecycle** (creation and termination events) -- **Correlation relationships** (many metrics belong to one entity) +Info metrics have served the community well as a **pragmatic convention** for representing entity information. They work, and thousands of dashboards and exporters rely on them. However, because Prometheus treats them as regular metrics rather than recognizing them as entity representations, several limitations emerge: -**This proposal introduces Entities as a first-class concept in Prometheus**, separate from metrics, with their own storage, lifecycle management, and query semantics. Info metrics will continue to work for backward compatibility, but new instrumentation and the OTel integration can use proper Entity semantics. - -### The Current Workaround: Info Metrics as Gauges - -Prometheus does not have a native Entity type. Instead, users follow a convention: create a gauge with an `_info` suffix, set its value to `1`, and encode metadata as labels. - -```promql -node_uname_info{nodename="server-1", release="5.15.0", version="#1 SMP", machine="x86_64"} 1 -``` +1. **The value is a placeholder**: The `1` carries no information—it exists only because Prometheus's storage requires a numeric value for every series. +2. **Identity is conflated with description**: All labels are treated equally. There's no way to declare that `uid` uniquely identifies the pod while `node` is descriptive metadata that may change. +3. **Lifecycle is implicit**: When a pod is deleted and recreated, Prometheus sees label churn. There's no first-class representation of "this entity ended; a new one began." +4. **Correlation is manual**: To associate entity metadata with metrics, users must write complex `group_left` joins—reconstructing a relationship that should be understood by the system. -While OpenMetrics formally defines an Info type, the Prometheus text exposition format does not support it. This means: -- Info metrics consume storage for a constant value (`1`) that carries no information -- There's no semantic distinction between info metrics and regular gauges -- Query engines cannot optimize for the unique characteristics of metadata +What Prometheus needs is not a replacement for info metrics, but rather **recognition of Entities as a first-class concept**. Info metrics are already representing entities—this proposal gives Prometheus the semantics to understand what they represent. ### Joining Info Metrics Requires `group_left` @@ -371,7 +360,7 @@ The following are explicitly out of scope for this proposal: ### Changing behavior for existing `*_info` Gauges -This proposal defines new semantics for Entities. Existing gauges with `_info` suffix will continue to work as gauges and joins will continue to work. Migration or automatic conversion is not in scope. +This proposal defines new semantics for Entities. Existing **gauges** with `_info` suffix will continue to work as gauges and joins will continue to work. Migration or automatic conversion is not in scope. ### Complete OTel Data Model Parity diff --git a/proposals/0071-Entity/02-exposition-formats.md b/proposals/0071-Entity/02-exposition-formats.md index 20d89f74..df9ab2d9 100644 --- a/proposals/0071-Entity/02-exposition-formats.md +++ b/proposals/0071-Entity/02-exposition-formats.md @@ -2,27 +2,42 @@ ## Abstract -This document specifies how Prometheus exposition formats should be extended to support the Entity concept introduced in [01-context.md](./01-context.md). It covers syntax additions to the text format and new protobuf message definitions. +This document specifies how Prometheus exposition formats represent **Entities** using info metrics. As established in [01-context.md](./01-context.md), Entities are the first-class concept representing things that produce telemetry. This document defines how they are serialized in the wire format. -The goal is to enable first-class representation of entities—the things that produce telemetry—while maintaining backward compatibility with existing scrapers that don't understand entities. +Info metrics have long been used to represent entity metadata in the Prometheus ecosystem. This proposal enhances them with markers that allow Prometheus to recognize them as entity representations rather than ordinary metrics. The key addition is the `# IDENTIFYING_LABELS` declaration, which distinguishes which labels uniquely identify the entity from which labels describe it. --- -## The Entity Concept +## Entities vs. Info Metrics: Concepts and Representation -An **Entity** represents a distinct object of interest that produces or is described by telemetry. Examples include: +Before diving into syntax, it's important to clarify the relationship between two terms used in this proposal: -| Component | Description | -|-----------|-------------| -| **Type** | The entity type this instance belongs to (e.g., `k8s.pod`) | -| **Identifying Labels** | Labels that uniquely identify this entity instance. Must remain constant for the entity's lifetime. | -| **Descriptive Labels** | Additional context about the entity. May change over time. | +### Entity (Concept) -Examples of entities: +An **Entity** is the conceptual abstraction—the "thing" that produces telemetry: +- A Kubernetes pod +- A physical host +- A service instance +- A database table -- A Kubernetes pod (`k8s.pod`) identified by namespace and UID -- A host or node (`k8s.node`) identified by node UID -- A service instance (`service`) identified by namespace, name, and instance ID +Entities have: +- **Type** (e.g., `k8s.pod`, `service`, `host`) +- **Identifying labels** (immutable, define unique identity) +- **Descriptive labels** (mutable, provide context) +- **Lifecycle** (creation time, end time) + +### Info Metric (Wire Format) + +An **info metric** is how entities are represented in the exposition format: +- Uses the familiar `*_info` naming convention +- Declares `# TYPE ... info` +- Now includes `# IDENTIFYING_LABELS` to mark which labels are identifying +- Has a placeholder value of `1` + +Throughout this proposal: +- When we say **"Entity,"** we mean the conceptual abstraction +- When we say **"info metric,"** we mean the wire format representation +- The two are closely related: info metrics *represent* entities --- @@ -32,66 +47,75 @@ Examples of entities: | Element | Syntax | Description | |---------|--------|-------------| -| Entity type declaration | `# ENTITY_TYPE ` | Declares an entity type for subsequent entities | -| Identifying labels | `# ENTITY_IDENTIFYING ...` | Lists which labels form the identity | -| Entity instance | `{}` | An entity instance (no value) | +| Identifying labels declaration | `# IDENTIFYING_LABELS ...` | Declares which labels uniquely identify the info metric instance | +| Info section delimiter | `---` | Marks the end of the info metrics section | ### Complete Example ``` -# ENTITY_TYPE k8s.pod -# ENTITY_IDENTIFYING k8s.namespace.name k8s.pod.uid -k8s.pod{k8s.namespace.name="default",k8s.pod.uid="550e8400-e29b-41d4-a716-446655440000",k8s.pod.name="nginx-7b9f5"} -k8s.pod{k8s.namespace.name="default",k8s.pod.uid="660e8400-e29b-41d4-a716-446655440001",k8s.pod.name="redis-cache-0"} -k8s.pod{k8s.namespace.name="kube-system",k8s.pod.uid="770e8400-e29b-41d4-a716-446655440002",k8s.pod.name="coredns-5dd5756b68-abcde"} - -# ENTITY_TYPE k8s.node -# ENTITY_IDENTIFYING k8s.node.uid -k8s.node{k8s.node.uid="node-uid-001",k8s.node.name="worker-1",k8s.node.os="linux",k8s.node.kernel="5.15.0"} -k8s.node{k8s.node.uid="node-uid-002",k8s.node.name="worker-2",k8s.node.os="linux",k8s.node.kernel="5.15.0"} - -# ENTITY_TYPE service -# ENTITY_IDENTIFYING service.namespace service.name service.instance.id -service{service.namespace="production",service.name="payment-service",service.instance.id="i-abc123",service.version="2.1.0"} +# HELP kube_pod_info Information about pods +# TYPE kube_pod_info info +# IDENTIFYING_LABELS namespace pod_uid +kube_pod_info{namespace="default",pod_uid="550e8400-e29b-41d4-a716-446655440000",pod="nginx-7b9f5"} 1 +kube_pod_info{namespace="default",pod_uid="660e8400-e29b-41d4-a716-446655440001",pod="redis-cache-0"} 1 +kube_pod_info{namespace="kube-system",pod_uid="770e8400-e29b-41d4-a716-446655440002",pod="coredns-5dd5756b68-abcde"} 1 + +# HELP kube_node_info Information about nodes +# TYPE kube_node_info info +# IDENTIFYING_LABELS node_uid +kube_node_info{node_uid="node-uid-001",node="worker-1",os="linux",kernel_version="5.15.0"} 1 +kube_node_info{node_uid="node-uid-002",node="worker-2",os="linux",kernel_version="5.15.0"} 1 + +# HELP target_info Target metadata from OpenTelemetry +# TYPE target_info info +# IDENTIFYING_LABELS job instance +target_info{job="payment-service",instance="10.0.1.5:8080",service_version="2.1.0",deployment_environment="production"} 1 --- -# TYPE container_cpu_usage_seconds counter -# HELP container_cpu_usage_seconds Total CPU usage in seconds -# This metric correlates with BOTH k8s.pod and k8s.node entities -# (it contains the identifying labels of both) -container_cpu_usage_seconds_total{k8s.namespace.name="default",k8s.pod.uid="550e8400-e29b-41d4-a716-446655440000",k8s.node.uid="node-uid-001",container="nginx"} 1234.5 -container_cpu_usage_seconds_total{k8s.namespace.name="default",k8s.pod.uid="660e8400-e29b-41d4-a716-446655440001",k8s.node.uid="node-uid-002",container="redis"} 567.8 +# HELP container_cpu_usage_seconds_total Total CPU usage in seconds +# TYPE container_cpu_usage_seconds_total counter +container_cpu_usage_seconds_total{namespace="default",pod_uid="550e8400-e29b-41d4-a716-446655440000",node_uid="node-uid-001",container="nginx"} 1234.5 +container_cpu_usage_seconds_total{namespace="default",pod_uid="660e8400-e29b-41d4-a716-446655440001",node_uid="node-uid-002",container="redis"} 567.8 -# TYPE http_requests counter -# HELP http_requests Total HTTP requests -http_requests_total{service.namespace="production",service.name="payment-service",service.instance.id="i-abc123",method="GET",status="200"} 9999 +# HELP http_requests_total Total HTTP requests +# TYPE http_requests_total counter +http_requests_total{job="payment-service",instance="10.0.1.5:8080",method="GET",status="200"} 9999 # EOF ``` ### Parsing Rules -1. `# ENTITY_TYPE` starts a new entity family block -2. `# ENTITY_IDENTIFYING` must follow `# ENTITY_TYPE` before any entity instances -3. Entity instances (lines matching `{...}` with no value) are ONLY valid after an `# ENTITY_TYPE` declaration. A line like `foo{bar="baz"}` without a preceding entity type declaration is a parse error. -4. Entity instances MUST contain all identifying labels declared in `# ENTITY_IDENTIFYING` -5. The entity type name in the instance line MUST match the declared `# ENTITY_TYPE` +1. `# TYPE ... info` MUST be followed by `# IDENTIFYING_LABELS` before any metric instances +2. `# IDENTIFYING_LABELS` applies to the info metric family declared by the preceding `# TYPE` +3. All labels listed in `# IDENTIFYING_LABELS` must be present on every instance of that info metric +4. Labels not listed in `# IDENTIFYING_LABELS` are considered descriptive labels +5. The info metrics section ends with a `---` delimiter on its own line +6. After the `---` delimiter, any info metric declarations are a parse error -### Entity Section Ordering +### Ordering -**All entities MUST appear at the beginning of the scrape response, before any metrics.** The entity section ends with a `---` delimiter on its own line. +**All info metrics MUST appear at the beginning of the scrape response, before any regular metrics.** The info metrics section ends with a `---` delimiter. -This ordering requirement exists for practical reasons: when Prometheus parses a metric, it needs to immediately correlate that metric with any relevant entities. If entities could appear anywhere in the response, Prometheus would need to either buffer all metrics until the entire response is parsed, or make a second pass through the data. Both approaches add complexity and memory overhead. +This ordering requirement exists for practical reasons: when Prometheus parses a metric, it needs to immediately correlate that metric with any relevant info metrics. If info metrics could appear anywhere in the response, Prometheus would need to either buffer all metrics until the entire response is parsed, or make a second pass through the data. Both approaches add complexity and memory overhead. -By requiring entities first, the parser can process the exposition in a single pass. When it encounters a metric, all potentially correlated entities are already in memory and correlation can happen immediately. +By requiring info metrics first, the parser can process the exposition in a single pass. When it encounters a regular metric, all potentially correlated info metrics are already in memory and correlation can happen immediately. -If no entities are present, the `---` delimiter may be omitted. If entities are present but metrics appear before the `---` delimiter (or without one), the scrape fails with a parse error. +If no info metrics are present, the `---` delimiter may be omitted. + +#### Breaking Change + +**This ordering requirement is a breaking change.** Currently, Prometheus parses info metrics as regular gauges, allowing them to appear anywhere in the scrape response. Applications that expose info metrics after regular metrics will need to be updated to comply with this ordering requirement. + +This trade-off was accepted because the benefits of single-pass parsing and immediate correlation outweigh the migration cost. See [99-alternatives.md](./99-alternatives.md#alternative-introduce-a-new-entity-concept) for an alternative approach that would not have this breaking change. --- ## Protobuf Format +While the text format uses info metrics to represent entities (for familiarity), the protobuf format uses a dedicated `EntityFamily` structure. This provides a cleaner representation without the need for placeholder values. + ### New Message Definitions ```protobuf @@ -101,7 +125,7 @@ package io.prometheus.client; // EntityFamily groups entities of the same type message EntityFamily { - // Entity type name (e.g., "k8s.pod", "service", "build") + // Entity type name required string type = 1; // Names of labels that form the unique identity @@ -126,9 +150,10 @@ The existing `MetricFamily` structure remains unchanged. A new top-level message // MetricPayload is the top-level message for scrape responses // that include both entities and metrics message MetricPayload { - // Entity families + // Entity families (must come before metric families) repeated EntityFamily entity_family = 1; + // Metric families repeated MetricFamily metric_family = 2; } ``` @@ -141,41 +166,51 @@ For protobuf with entity support: application/vnd.google.protobuf;proto=io.prometheus.client.MetricPayload;encoding=delimited ``` -For protobuf with entity support, the `proto` parameter changes from `MetricFamily` to `MetricPayload` to indicate the new top-level message type. +The `proto` parameter changes from `MetricFamily` to `MetricPayload` to indicate the new top-level message type. + +### Translation Between Formats + +Entities can be losslessly translated between text and protobuf formats: + +| Text Format | Protobuf | +|-------------|----------| +| `# TYPE kube_pod_info info` | `EntityFamily.type = "kube_pod"` | +| `# IDENTIFYING_LABELS namespace pod_uid` | `EntityFamily.identifying_label_names = ["namespace", "pod_uid"]` | +| `kube_pod_info{namespace="default",pod="nginx"} 1` | `Entity.label = [{name: "namespace", value: "default"}, {name: "pod", value: "nginx"}]` | + +Note that the placeholder value `1` from the text format is not stored in protobuf—it's implicit for entities. --- -## Entity-Metric Correlation +## Info Metric to Regular Metric Correlation ### How Correlation Works -Entities correlate with metrics through **shared identifying labels**: +Info metrics correlate with regular metrics through **shared identifying labels**: -- If a metric has labels that match ALL identifying labels of an entity (same names, same values), that metric is associated with that entity. -- A single metric can correlate with multiple entities (of different types) if it contains the identifying labels of each. +- If a metric has labels that match ALL identifying labels of an info metric (same names, same values), that metric is associated with that info metric. +- A single metric can correlate with multiple info metrics if it contains the identifying labels of each. **Example:** ``` -# ENTITY_TYPE k8s.pod -# ENTITY_IDENTIFYING k8s.namespace.name k8s.pod.uid -k8s.pod{k8s.namespace.name="default",k8s.pod.uid="550e8400",k8s.pod.name="nginx"} - +# TYPE kube_pod_info info +# IDENTIFYING_LABELS namespace pod_uid +kube_pod_info{namespace="default",pod_uid="550e8400",pod="nginx",node="worker-1"} 1 --- - -# This metric correlates with the entity above (has both identifying labels) -container_cpu_usage_seconds_total{k8s.namespace.name="default",k8s.pod.uid="550e8400",container="app"} 1234.5 +# This metric correlates with kube_pod_info above (has both identifying labels) +container_cpu_usage_seconds_total{namespace="default",pod_uid="550e8400",container="app"} 1234.5 ``` Correlation is computed at ingestion time when Prometheus parses the exposition format. See [05-storage.md](./05-storage.md#correlation-index) for how Prometheus builds and maintains these correlations in storage. ### Conflict Detection -When a metric correlates with an entity, the query engine enriches the metric's labels with the entity's descriptive labels (see [06-querying.md](./06-querying.md)). This creates the possibility of label conflicts—a metric might have a label with the same name as an entity's descriptive label. +When a metric correlates with an info metric, the query engine enriches the metric's labels with the info metric's descriptive labels (see [06-querying.md](./06-querying.md)). This creates the possibility of label conflicts. A conflict occurs when: -- A metric correlates with an entity (has all identifying labels) -- The metric has a label with the same name as one of the entity's descriptive labels +- A metric correlates with an info metric (has all identifying labels) +- The metric has a label with the same name as one of the info metric's descriptive labels - The values differ ``` @@ -183,15 +218,15 @@ A conflict occurs when: │ Label Conflict Detection │ └─────────────────────────────────────────────────────────────────────────────┘ -Entity (k8s.pod) Metric (my_metric) +Info Metric (kube_pod_info) Regular Metric (my_metric) ┌─────────────────────────────────┐ ┌─────────────────────────────────┐ │ Identifying Labels: │ │ Labels: │ -│ k8s.namespace.name = "default"│◄─────────►│ k8s.namespace.name = "default"│ ✓ Match -│ k8s.pod.uid = "abc-123" │◄─────────►│ k8s.pod.uid = "abc-123" │ ✓ Match +│ namespace = "default" │◄─────────►│ namespace = "default" │ ✓ Match +│ pod_uid = "abc-123" │◄─────────►│ pod_uid = "abc-123" │ ✓ Match ├─────────────────────────────────┤ ├─────────────────────────────────┤ │ Descriptive Labels: │ │ │ │ version = "2.0" │◄────╳────►│ version = "1.0" │ ✗ CONFLICT! -│ k8s.pod.name = "nginx" │ │ │ +│ pod = "nginx" │ │ │ └─────────────────────────────────┘ │ Value: 42 │ └─────────────────────────────────┘ @@ -200,38 +235,32 @@ but "version" exists in both with different values → Scrape fails! ``` **Example conflict in exposition format:** -``` -# ENTITY_TYPE k8s.pod -# ENTITY_IDENTIFYING k8s.namespace.name k8s.pod.uid -k8s.pod{k8s.namespace.name="default",k8s.pod.uid="abc-123",version="2.0"} +``` +# TYPE kube_pod_info info +# IDENTIFYING_LABELS namespace pod_uid +kube_pod_info{namespace="default",pod_uid="abc-123",version="2.0",pod="nginx"} 1 --- - -# This metric has k8s.pod identifying labels, so it correlates with the entity. -# But it also has a "version" label that conflicts with the entity's "version" label! -my_metric{k8s.namespace.name="default",k8s.pod.uid="abc-123",version="1.0"} 42 +# This metric has kube_pod_info identifying labels, so it correlates. +# But it also has a "version" label that conflicts! +my_metric{namespace="default",pod_uid="abc-123",version="1.0"} 42 ``` -When a conflict is detected during scrape, **the scrape fails with an error**. +When a conflict is detected during scrape, **the scrape fails with an error**. -Note that **identifying labels cannot conflict** because they must be present on the metric for correlation to occur—if the metric has the same label name with a different value, it simply won't correlate with that entity. +Note that **identifying labels cannot conflict** because they must be present on the metric for correlation to occur—if the metric has the same label name with a different value, it simply won't correlate with that info metric. --- ## Technical Implementation -This section provides detailed implementation guidance for parsing entities and integrating with the scrape loop. The implementation should align with the storage layer defined in [05-storage.md](./05-storage.md). - ### Parser Interface Extensions -The existing `Parser` interface in `model/textparse/interface.go` needs new methods and entry types to handle entities: +The existing `Parser` interface needs minimal changes: #### New Entry Types -The `Entry` type is extended with new values for entity handling: - ```go -// Current Entry types (model/textparse/interface.go:206-213) const ( EntryInvalid Entry = -1 EntryType Entry = 0 @@ -241,97 +270,66 @@ const ( EntryUnit Entry = 4 EntryHistogram Entry = 5 - // New entity entry types - EntryEntityType Entry = 6 // # ENTITY_TYPE - EntryEntityIdentifying Entry = 7 // # ENTITY_IDENTIFYING ... - EntryEntity Entry = 8 // {} (no value) - EntryEntityDelimiter Entry = 9 // --- (marks end of entity section) + // NEW: Identifying labels declaration + EntryIdentifyingLabels Entry = 6 // # IDENTIFYING_LABELS ... + // NEW: Info section delimiter + EntryInfoDelimiter Entry = 7 // --- (marks end of info metrics section) ) ``` -When the parser encounters `---`, it returns `EntryEntityDelimiter`. After this point, any entity declarations are a parse error—all entities must appear before the delimiter. - -#### New Parser Methods +#### New Parser Method ```go -// Parser interface additions +// Parser interface addition type Parser interface { // ... existing methods (Series, Histogram, Help, Type, Unit, etc.) ... - // EntityType returns the entity type name from an ENTITY_TYPE declaration. - // Must only be called after Next() returned EntryEntityType. - // The returned byte slice becomes invalid after the next call to Next. - EntityType() []byte - - // EntityIdentifying returns the list of identifying label names. - // Must only be called after Next() returned EntryEntityIdentifying. + // IdentifyingLabels returns the list of identifying label names. + // Must only be called after Next() returned EntryIdentifyingLabels. // The returned slice becomes invalid after the next call to Next. - EntityIdentifying() [][]byte - - // EntityLabels writes the entity labels into the passed labels. - // Must only be called after Next() returned EntryEntity. - // All labels (both identifying and descriptive) are included. - EntityLabels(l *labels.Labels) + IdentifyingLabels() [][]byte } ``` ### Scrape Loop Integration -The scrape loop in `scrape/scrape.go` needs significant changes to process entities alongside metrics. - -#### Entity Cache - -Extend `scrapeCache` to track entities similar to how it tracks series: +The scrape loop tracks info metrics with identifying labels separately: ```go -// Entity cache entry (analogous to cacheEntry for series) -type entityCacheEntry struct { - ref storage.EntityRef +// Info metric cache entry +type infoMetricCacheEntry struct { + ref storage.SeriesRef lastIter uint64 hash uint64 identifyingLabels labels.Labels descriptiveLabels labels.Labels + infoType string // Derived from metric name (e.g., "kube_pod" from "kube_pod_info") } type scrapeCache struct { - // ... existing fields (series, droppedSeries, seriesCur, seriesPrev, metadata) ... + // ... existing fields ... - // Entity parsing state (reset each scrape) - currentEntityType string - currentIdentifyingNames []string + // Info metric parsing state (reset each scrape) + currentInfoType string // Current info metric name being parsed + currentIdentifyingNames []string // Identifying label names for current info metric + infoSectionEnded bool // True after --- delimiter is encountered - // Entity tracking (persists across scrapes) - entities map[string]*entityCacheEntry // key: hash of identifying attrs - entityCur map[storage.EntityRef]*entityCacheEntry - entityPrev map[storage.EntityRef]*entityCacheEntry -} - -func newScrapeCache(metrics *scrapeMetrics) *scrapeCache { - return &scrapeCache{ - // ... existing initialization ... - entities: map[string]*entityCacheEntry{}, - entityCur: map[storage.EntityRef]*entityCacheEntry{}, - entityPrev: map[storage.EntityRef]*entityCacheEntry{}, - } + // Info metric tracking (persists across scrapes) + infoMetrics map[string]*infoMetricCacheEntry // key: hash of type + identifying labels + infoMetricsCur map[storage.SeriesRef]*infoMetricCacheEntry + infoMetricsPrev map[storage.SeriesRef]*infoMetricCacheEntry } ``` -#### Entity Processing in append() - -The main append loop in `scrapeLoop.append()` is extended: +#### Processing in append() ```go func (sl *scrapeLoop) append(app storage.Appender, b []byte, contentType string, ts time.Time) (total, added, seriesAdded int, err error) { defTime := timestamp.FromTime(ts) - // ... existing parser creation ... + var currentMetricName string + var currentMetricType textparse.MetricType - var ( - // ... existing variables ... - entitiesTotal int - entitiesAdded int - ) - loop: for { et, err := p.Next() @@ -343,89 +341,100 @@ loop: } switch et { - case textparse.EntryEntityType: - sl.cache.currentEntityType = string(p.EntityType()) + case textparse.EntryType: + currentMetricName, currentMetricType = p.Type() + // Reset identifying labels for new metric family sl.cache.currentIdentifyingNames = nil + if currentMetricType == textparse.MetricTypeInfo { + // Info metrics not allowed after delimiter + if sl.cache.infoSectionEnded { + return 0, 0, 0, fmt.Errorf("TYPE info not allowed after --- delimiter") + } + sl.cache.currentInfoType = deriveInfoType(string(currentMetricName)) + } else { + sl.cache.currentInfoType = "" + } continue - case textparse.EntryEntityIdentifying: - names := p.EntityIdentifying() + case textparse.EntryIdentifyingLabels: + // Only valid after TYPE info declaration + if sl.cache.currentInfoType == "" { + return 0, 0, 0, fmt.Errorf("IDENTIFYING_LABELS without preceding TYPE info declaration") + } + names := p.IdentifyingLabels() sl.cache.currentIdentifyingNames = make([]string, len(names)) for i, name := range names { sl.cache.currentIdentifyingNames[i] = string(name) } continue - case textparse.EntryEntity: - entitiesTotal++ - if err := sl.processEntity(app, p, defTime); err != nil { - sl.l.Debug("Entity processing error", "err", err) - // Depending on error type, may break or continue - if isEntityLimitError(err) { - break loop + case textparse.EntryInfoDelimiter: + sl.cache.infoSectionEnded = true + continue + + case textparse.EntrySeries: + // Info metrics require IDENTIFYING_LABELS + if sl.cache.currentInfoType != "" && len(sl.cache.currentIdentifyingNames) == 0 { + return 0, 0, 0, fmt.Errorf("TYPE info requires IDENTIFYING_LABELS declaration") + } + + // Process info metric + if sl.cache.currentInfoType != "" { + if err := sl.processInfoMetric(app, p, defTime); err != nil { + sl.l.Debug("Info metric processing error", "err", err) } - continue } - entitiesAdded++ - continue + // Continue with normal series processing... - case textparse.EntryType: - // ... existing handling ... - case textparse.EntryHelp: - // ... existing handling ... - case textparse.EntrySeries, textparse.EntryHistogram: - // ... existing metric handling ... - // ADD: conflict detection before appending + // ... rest of existing handling ... } } - // Update stale markers for both series AND entities - if err == nil { - err = sl.updateStaleMarkers(app, defTime) - sl.updateEntityStaleMarkers(app, defTime) - } - return total, added, seriesAdded, err } + +// deriveInfoType extracts the type from an info metric name +func deriveInfoType(metricName string) string { + if strings.HasSuffix(metricName, "_info") { + return strings.TrimSuffix(metricName, "_info") + } + return metricName +} ``` -#### Entity Processing Method +#### Info Metric Processing ```go -func (sl *scrapeLoop) processEntity(app storage.Appender, p textparse.Parser, ts int64) error { +func (sl *scrapeLoop) processInfoMetric(app storage.Appender, p textparse.Parser, ts int64) error { var allLabels labels.Labels - p.EntityLabels(&allLabels) + p.Labels(&allLabels) + + // Split into identifying and descriptive labels + identifying, descriptive := sl.splitInfoLabels(allLabels) // Validate: all identifying labels must be present - identifying, descriptive := sl.splitEntityLabels(allLabels) if len(identifying) != len(sl.cache.currentIdentifyingNames) { - return fmt.Errorf("entity missing required identifying labels: expected %v", + return fmt.Errorf("info metric missing required identifying labels: expected %v", sl.cache.currentIdentifyingNames) } - // Check entity limit - if sl.entityLimit > 0 && len(sl.cache.entities) >= sl.entityLimit { - return errEntityLimit - } - hash := identifying.Hash() - hashKey := fmt.Sprintf("%s:%d", sl.cache.currentEntityType, hash) + hashKey := fmt.Sprintf("%s:%d", sl.cache.currentInfoType, hash) - // Check cache for existing entity - ce, cached := sl.cache.entities[hashKey] + // Check cache and update + ce, cached := sl.cache.infoMetrics[hashKey] if cached { ce.lastIter = sl.cache.iter // Check if descriptive labels changed if !labels.Equal(ce.descriptiveLabels, descriptive) { ce.descriptiveLabels = descriptive - // Will trigger a WAL write via AppendEntity } } - // Call storage appender - ref, err := app.AppendEntity( - sl.cache.currentEntityType, + // Store info metric metadata for correlation + ref, err := app.AppendInfoMetric( + sl.cache.currentInfoType, identifying, descriptive, ts, @@ -434,25 +443,25 @@ func (sl *scrapeLoop) processEntity(app storage.Appender, p textparse.Parser, ts return err } - // Update cache if !cached { - ce = &entityCacheEntry{ + ce = &infoMetricCacheEntry{ ref: ref, lastIter: sl.cache.iter, hash: hash, identifyingLabels: identifying, descriptiveLabels: descriptive, + infoType: sl.cache.currentInfoType, } - sl.cache.entities[hashKey] = ce + sl.cache.infoMetrics[hashKey] = ce } else { ce.ref = ref } - sl.cache.entityCur[ref] = ce + sl.cache.infoMetricsCur[ref] = ce return nil } -func (sl *scrapeLoop) splitEntityLabels(allLabels labels.Labels) (labels.Labels, labels.Labels) { +func (sl *scrapeLoop) splitInfoLabels(allLabels labels.Labels) (labels.Labels, labels.Labels) { identifyingSet := make(map[string]struct{}) for _, name := range sl.cache.currentIdentifyingNames { identifyingSet[name] = struct{}{} @@ -471,57 +480,20 @@ func (sl *scrapeLoop) splitEntityLabels(allLabels labels.Labels) (labels.Labels, } ``` -#### Entity Staleness - -Entity staleness works similarly to series staleness, but marks entities as dead rather than writing StaleNaN: - -```go -func (sl *scrapeLoop) updateEntityStaleMarkers(app storage.Appender, ts int64) error { - for ref, ce := range sl.cache.entityPrev { - if _, ok := sl.cache.entityCur[ref]; ok { - continue // Entity still present - } - - // Entity disappeared - mark it dead - // The storage layer handles this by setting endTime - if err := app.MarkEntityDead(ref, ts); err != nil { - sl.l.Debug("Error marking entity dead", "ref", ref, "err", err) - } - - // Remove from cache - for hashKey, e := range sl.cache.entities { - if e.ref == ref { - delete(sl.cache.entities, hashKey) - break - } - } - } - - return nil -} - -func (c *scrapeCache) entityIterDone(flush bool) { - // Swap current and previous (same pattern as series) - c.entityPrev, c.entityCur = c.entityCur, c.entityPrev - clear(c.entityCur) -} -``` - ### Scrape Configuration -New configuration options in `config/config.go`: - ```go type ScrapeConfig struct { // ... existing fields ... - // EnableEntityScraping enables parsing of entity declarations. + // EnableInfoMetricCorrelation enables processing of IDENTIFYING_LABELS + // and automatic enrichment of correlated metrics. // Default: false for backward compatibility. - EnableEntityScraping bool `yaml:"enable_entity_scraping,omitempty"` + EnableInfoMetricCorrelation bool `yaml:"enable_info_metric_correlation,omitempty"` - // EntityLimit is the maximum number of entities per scrape target. - // 0 means no limit. - EntityLimit int `yaml:"entity_limit,omitempty"` + // InfoMetricLimit is the maximum number of info metrics with identifying + // labels per scrape target. 0 means no limit. + InfoMetricLimit int `yaml:"info_metric_limit,omitempty"` } ``` @@ -534,21 +506,22 @@ type ScrapeConfig struct { Target /metrics Prometheus Scrape Loop ┌─────────────────┐ ┌─────────────────────────────────────────┐ - │ # ENTITY_TYPE │ │ │ - │ # ENTITY_IDENT │ ──HTTP GET──► │ 1. Create Parser (textparse.New) │ - │ entity{...} │ │ │ - │ │ │ 2. Loop: p.Next() │ - │ # TYPE metric │ │ ├─ EntryEntityType → cache type │ - │ metric{...} 123 │ │ ├─ EntryEntityIdent → cache names │ - │ # EOF │ │ ├─ EntryEntity → processEntity() │ - └─────────────────┘ │ │ └─ app.AppendEntity() │ + │ # TYPE pod info │ │ │ + │ # IDENT_LABELS │ ──HTTP GET──► │ 1. Create Parser (textparse.New) │ + │ pod_info{...} 1 │ │ │ + │ --- │ │ 2. Loop: p.Next() │ + │ # TYPE metric │ │ ├─ EntryType → check if info type │ + │ metric{...} 123 │ │ ├─ EntryIdentifyingLabels → cache │ + │ # EOF │ │ ├─ EntrySeries (info) → process │ + └─────────────────┘ │ │ └─ app.AppendInfoMetric() │ + │ ├─ EntryInfoDelimiter → mark ended │ │ ├─ EntrySeries → checkConflicts() │ │ │ └─ app.Append() │ │ └─ EntryHistogram → ... │ │ │ │ 3. updateStaleMarkers() │ │ ├─ Series: Write StaleNaN │ - │ └─ Entities: app.MarkEntityDead() │ + │ └─ Info metrics: mark stale │ │ │ │ 4. app.Commit() │ │ ├─ Write WAL records │ @@ -562,27 +535,25 @@ type ScrapeConfig struct { │ ┌─────────────┐ ┌─────────────────┐ │ │ │ WAL Records │ │ Head Block │ │ │ │ - Series │ │ - memSeries │ │ - │ │ - Samples │ │ - memEntity │ │ - │ │ - Entities │ │ - Correlation │ │ - │ └─────────────┘ │ Index │ │ + │ │ - Samples │ │ - infoMetric │ │ + │ │ - InfoMeta │ │ Metadata │ │ + │ └─────────────┘ │ - Correlation │ │ + │ │ Index │ │ │ └─────────────────┘ │ └─────────────────────────────────────────┘ ``` -In the [storage](04-storage.md) document, we go over the correlation index, WAL and memEntity struct in greater details. - --- ## Related Documents - [01-context.md](./01-context.md) - Problem statement and motivation -- [03-sdk.md](./03-sdk.md) - How Prometheus client libraries support entities -- [04-service-discovery.md](./04-service-discovery.md) - How entities relate to Prometheus targets -- [05-storage.md](./05-storage.md) - How entities are stored in the TSDB -- [06-querying.md](./06-querying.md) - PromQL extensions for working with entities -- [07-web-ui-and-apis.md](./07-web-ui-and-apis.md) - How entities are displayed and accessed +- [03-sdk.md](./03-sdk.md) - How Prometheus client libraries support info metrics with identifying labels +- [04-service-discovery.md](./04-service-discovery.md) - How info metrics relate to Prometheus targets +- [05-storage.md](./05-storage.md) - How info metric metadata is stored in the TSDB +- [06-querying.md](./06-querying.md) - PromQL extensions for working with info metrics +- [07-web-ui-and-apis.md](./07-web-ui-and-apis.md) - How info metrics are displayed and accessed --- *This proposal is a work in progress. Feedback is welcome.* - diff --git a/proposals/0071-Entity/99-alternatives.md b/proposals/0071-Entity/99-alternatives.md new file mode 100644 index 00000000..d41cc124 --- /dev/null +++ b/proposals/0071-Entity/99-alternatives.md @@ -0,0 +1,52 @@ +# Alternatives Considered + +This document captures alternative approaches that were evaluated during the design of native Entity support in Prometheus. For each alternative, we describe what was considered, why it was appealing, and ultimately why it was not chosen. + +The goal is to preserve institutional knowledge about design decisions and help future contributors understand the reasoning behind the current proposal. + +--- + +## Exposition Formats + +*See [Exposition Formats](./02-exposition-formats.md) for the chosen approach.* + +### Alternative: Introduce a New "Entity" Concept in for OpenMetrics-text + +#### Description + +Instead of extending info metrics, introduce a completely new "Entity" concept with dedicated syntax: + +``` +# ENTITY_TYPE k8s.pod +# ENTITY_IDENTIFYING namespace pod_uid +k8s.pod{namespace="default",pod_uid="abc-123",pod="nginx"} + +--- + +# TYPE container_cpu_usage_seconds_total counter +container_cpu_usage_seconds_total{namespace="default",pod_uid="abc-123",container="app"} 1234.5 +``` + +Key differences from the chosen approach: +- New `# ENTITY_TYPE` declaration instead of `# TYPE ... info` +- New `# ENTITY_IDENTIFYING` declaration instead of `# IDENTIFYING_LABELS` +- Entity instances have **no value** (no `1` placeholder) +- Entity type is explicit in the declaration, not derived from the metric name + +#### Motivation + +- **Semantic clarity**: Entities truly aren't metrics—they don't have values because they represent the *producers* of telemetry, not telemetry itself +- **Cleaner data model**: No meaningless `1` value wasting storage +- **Better alignment with OpenTelemetry**: OTel's Entity model treats entities as first-class objects, not metrics +- **No breaking change for existing info metrics**: Since this introduces completely new syntax, existing applications exposing info metrics in any order would continue to work unchanged. The new Entity syntax would be opt-in for applications that want correlation features. + +#### Concerns / Reasons for Rejection + +- **Cognitive load**: Users must learn a new concept ("Entity") rather than building on the familiar info metric pattern they already understand +- **Larger syntax change**: Three new declarations vs. one (`# ENTITY_TYPE`, `# ENTITY_IDENTIFYING`, value-less lines vs. just `# IDENTIFYING_LABELS`) +- **Community familiarity**: The `*_info` metric pattern is well-established across the ecosystem (kube-state-metrics, node_exporter, OTel SDK). Extending it is less disruptive than replacing it. +- **Incremental evolution**: Prometheus has historically evolved through incremental changes rather than whole new concepts + +The extended info metrics approach achieves the same functional goals (identifying vs. descriptive labels, automatic enrichment, correlation) while requiring less conceptual overhead. + +--- \ No newline at end of file From 1d0e71f29c899478c7109ac1b5512040ab5f82f9 Mon Sep 17 00:00:00 2001 From: Arthur Silva Sens Date: Mon, 29 Dec 2025 16:56:46 -0300 Subject: [PATCH 5/5] Reduce amount of code samples Signed-off-by: Arthur Silva Sens --- .../0071-Entity/02-exposition-formats.md | 293 +----- proposals/0071-Entity/03-sdk.md | 62 +- proposals/0071-Entity/04-service-discovery.md | 191 +--- proposals/0071-Entity/05-storage.md | 578 +---------- .../0071-Entity/05b-storage-entity-native.md | 934 ------------------ proposals/0071-Entity/06-querying.md | 120 --- proposals/0071-Entity/08-alerting.md | 146 +-- 7 files changed, 95 insertions(+), 2229 deletions(-) delete mode 100644 proposals/0071-Entity/05b-storage-entity-native.md diff --git a/proposals/0071-Entity/02-exposition-formats.md b/proposals/0071-Entity/02-exposition-formats.md index df9ab2d9..2aaf2343 100644 --- a/proposals/0071-Entity/02-exposition-formats.md +++ b/proposals/0071-Entity/02-exposition-formats.md @@ -252,252 +252,30 @@ Note that **identifying labels cannot conflict** because they must be present on --- -## Technical Implementation - -### Parser Interface Extensions - -The existing `Parser` interface needs minimal changes: - -#### New Entry Types - -```go -const ( - EntryInvalid Entry = -1 - EntryType Entry = 0 - EntryHelp Entry = 1 - EntrySeries Entry = 2 - EntryComment Entry = 3 - EntryUnit Entry = 4 - EntryHistogram Entry = 5 - - // NEW: Identifying labels declaration - EntryIdentifyingLabels Entry = 6 // # IDENTIFYING_LABELS ... - // NEW: Info section delimiter - EntryInfoDelimiter Entry = 7 // --- (marks end of info metrics section) -) -``` +## Implementation Overview -#### New Parser Method - -```go -// Parser interface addition -type Parser interface { - // ... existing methods (Series, Histogram, Help, Type, Unit, etc.) ... - - // IdentifyingLabels returns the list of identifying label names. - // Must only be called after Next() returned EntryIdentifyingLabels. - // The returned slice becomes invalid after the next call to Next. - IdentifyingLabels() [][]byte -} -``` +This section summarizes the key implementation changes required to support the exposition format extensions. Detailed implementation specifics are deferred until the fundamental direction is agreed upon. -### Scrape Loop Integration +### Parser Changes -The scrape loop tracks info metrics with identifying labels separately: +The text parser requires two new entry types: -```go -// Info metric cache entry -type infoMetricCacheEntry struct { - ref storage.SeriesRef - lastIter uint64 - hash uint64 - identifyingLabels labels.Labels - descriptiveLabels labels.Labels - infoType string // Derived from metric name (e.g., "kube_pod" from "kube_pod_info") -} +1. **`EntryIdentifyingLabels`** — Returned when the parser encounters `# IDENTIFYING_LABELS` +2. **`EntryInfoDelimiter`** — Returned when the parser encounters `---` -type scrapeCache struct { - // ... existing fields ... - - // Info metric parsing state (reset each scrape) - currentInfoType string // Current info metric name being parsed - currentIdentifyingNames []string // Identifying label names for current info metric - infoSectionEnded bool // True after --- delimiter is encountered - - // Info metric tracking (persists across scrapes) - infoMetrics map[string]*infoMetricCacheEntry // key: hash of type + identifying labels - infoMetricsCur map[storage.SeriesRef]*infoMetricCacheEntry - infoMetricsPrev map[storage.SeriesRef]*infoMetricCacheEntry -} -``` +A new method `IdentifyingLabels()` returns the list of label names declared in the `# IDENTIFYING_LABELS` line. -#### Processing in append() - -```go -func (sl *scrapeLoop) append(app storage.Appender, b []byte, contentType string, ts time.Time) (total, added, seriesAdded int, err error) { - defTime := timestamp.FromTime(ts) - - var currentMetricName string - var currentMetricType textparse.MetricType - -loop: - for { - et, err := p.Next() - if err != nil { - if errors.Is(err, io.EOF) { - err = nil - } - break - } - - switch et { - case textparse.EntryType: - currentMetricName, currentMetricType = p.Type() - // Reset identifying labels for new metric family - sl.cache.currentIdentifyingNames = nil - if currentMetricType == textparse.MetricTypeInfo { - // Info metrics not allowed after delimiter - if sl.cache.infoSectionEnded { - return 0, 0, 0, fmt.Errorf("TYPE info not allowed after --- delimiter") - } - sl.cache.currentInfoType = deriveInfoType(string(currentMetricName)) - } else { - sl.cache.currentInfoType = "" - } - continue - - case textparse.EntryIdentifyingLabels: - // Only valid after TYPE info declaration - if sl.cache.currentInfoType == "" { - return 0, 0, 0, fmt.Errorf("IDENTIFYING_LABELS without preceding TYPE info declaration") - } - names := p.IdentifyingLabels() - sl.cache.currentIdentifyingNames = make([]string, len(names)) - for i, name := range names { - sl.cache.currentIdentifyingNames[i] = string(name) - } - continue - - case textparse.EntryInfoDelimiter: - sl.cache.infoSectionEnded = true - continue - - case textparse.EntrySeries: - // Info metrics require IDENTIFYING_LABELS - if sl.cache.currentInfoType != "" && len(sl.cache.currentIdentifyingNames) == 0 { - return 0, 0, 0, fmt.Errorf("TYPE info requires IDENTIFYING_LABELS declaration") - } - - // Process info metric - if sl.cache.currentInfoType != "" { - if err := sl.processInfoMetric(app, p, defTime); err != nil { - sl.l.Debug("Info metric processing error", "err", err) - } - } - // Continue with normal series processing... - - // ... rest of existing handling ... - } - } - - return total, added, seriesAdded, err -} +### Scrape Loop Changes -// deriveInfoType extracts the type from an info metric name -func deriveInfoType(metricName string) string { - if strings.HasSuffix(metricName, "_info") { - return strings.TrimSuffix(metricName, "_info") - } - return metricName -} -``` +The scrape loop needs to: -#### Info Metric Processing - -```go -func (sl *scrapeLoop) processInfoMetric(app storage.Appender, p textparse.Parser, ts int64) error { - var allLabels labels.Labels - p.Labels(&allLabels) - - // Split into identifying and descriptive labels - identifying, descriptive := sl.splitInfoLabels(allLabels) - - // Validate: all identifying labels must be present - if len(identifying) != len(sl.cache.currentIdentifyingNames) { - return fmt.Errorf("info metric missing required identifying labels: expected %v", - sl.cache.currentIdentifyingNames) - } - - hash := identifying.Hash() - hashKey := fmt.Sprintf("%s:%d", sl.cache.currentInfoType, hash) - - // Check cache and update - ce, cached := sl.cache.infoMetrics[hashKey] - if cached { - ce.lastIter = sl.cache.iter - - // Check if descriptive labels changed - if !labels.Equal(ce.descriptiveLabels, descriptive) { - ce.descriptiveLabels = descriptive - } - } - - // Store info metric metadata for correlation - ref, err := app.AppendInfoMetric( - sl.cache.currentInfoType, - identifying, - descriptive, - ts, - ) - if err != nil { - return err - } - - if !cached { - ce = &infoMetricCacheEntry{ - ref: ref, - lastIter: sl.cache.iter, - hash: hash, - identifyingLabels: identifying, - descriptiveLabels: descriptive, - infoType: sl.cache.currentInfoType, - } - sl.cache.infoMetrics[hashKey] = ce - } else { - ce.ref = ref - } - - sl.cache.infoMetricsCur[ref] = ce - return nil -} - -func (sl *scrapeLoop) splitInfoLabels(allLabels labels.Labels) (labels.Labels, labels.Labels) { - identifyingSet := make(map[string]struct{}) - for _, name := range sl.cache.currentIdentifyingNames { - identifyingSet[name] = struct{}{} - } - - var identifying, descriptive labels.Labels - allLabels.Range(func(l labels.Label) { - if _, ok := identifyingSet[l.Name]; ok { - identifying = append(identifying, l) - } else { - descriptive = append(descriptive, l) - } - }) - - return identifying, descriptive -} -``` +1. **Track info metric state during parsing** — Remember which info type is being parsed and its identifying label names +2. **Enforce ordering** — Reject info metrics that appear after the `---` delimiter +3. **Split labels** — Separate identifying labels from descriptive labels based on the declaration +4. **Build correlations** — When processing regular metrics, check if they contain identifying labels that match any parsed info metric +5. **Detect conflicts** — Fail the scrape if a metric's label conflicts with an info metric's descriptive label -### Scrape Configuration - -```go -type ScrapeConfig struct { - // ... existing fields ... - - // EnableInfoMetricCorrelation enables processing of IDENTIFYING_LABELS - // and automatic enrichment of correlated metrics. - // Default: false for backward compatibility. - EnableInfoMetricCorrelation bool `yaml:"enable_info_metric_correlation,omitempty"` - - // InfoMetricLimit is the maximum number of info metrics with identifying - // labels per scrape target. 0 means no limit. - InfoMetricLimit int `yaml:"info_metric_limit,omitempty"` -} -``` - -### Data Flow Summary +### Data Flow ``` ┌───────────────────────────────────────────────────────────────────────────────┐ @@ -507,39 +285,26 @@ type ScrapeConfig struct { Target /metrics Prometheus Scrape Loop ┌─────────────────┐ ┌─────────────────────────────────────────┐ │ # TYPE pod info │ │ │ - │ # IDENT_LABELS │ ──HTTP GET──► │ 1. Create Parser (textparse.New) │ - │ pod_info{...} 1 │ │ │ - │ --- │ │ 2. Loop: p.Next() │ - │ # TYPE metric │ │ ├─ EntryType → check if info type │ - │ metric{...} 123 │ │ ├─ EntryIdentifyingLabels → cache │ - │ # EOF │ │ ├─ EntrySeries (info) → process │ - └─────────────────┘ │ │ └─ app.AppendInfoMetric() │ - │ ├─ EntryInfoDelimiter → mark ended │ - │ ├─ EntrySeries → checkConflicts() │ - │ │ └─ app.Append() │ - │ └─ EntryHistogram → ... │ - │ │ - │ 3. updateStaleMarkers() │ - │ ├─ Series: Write StaleNaN │ - │ └─ Info metrics: mark stale │ + │ # IDENT_LABELS │ ──HTTP GET──► │ 1. Parse info metrics first │ + │ pod_info{...} 1 │ │ - Extract identifying labels │ + │ --- │ │ - Store for correlation │ + │ # TYPE metric │ │ │ + │ metric{...} 123 │ │ 2. Parse regular metrics │ + │ # EOF │ │ - Check for correlation matches │ + └─────────────────┘ │ - Detect label conflicts │ │ │ - │ 4. app.Commit() │ - │ ├─ Write WAL records │ - │ ├─ Update Head structures │ - │ └─ Build correlation index │ + │ 3. Commit to storage │ + │ - Write info metric metadata │ + │ - Write series samples │ + │ - Build correlation index │ └─────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────┐ │ Storage (TSDB) │ - │ ┌─────────────┐ ┌─────────────────┐ │ - │ │ WAL Records │ │ Head Block │ │ - │ │ - Series │ │ - memSeries │ │ - │ │ - Samples │ │ - infoMetric │ │ - │ │ - InfoMeta │ │ Metadata │ │ - │ └─────────────┘ │ - Correlation │ │ - │ │ Index │ │ - │ └─────────────────┘ │ + │ - Info metric metadata │ + │ - Series data │ + │ - Correlation index │ └─────────────────────────────────────────┘ ``` diff --git a/proposals/0071-Entity/03-sdk.md b/proposals/0071-Entity/03-sdk.md index 5eff2836..9e3341a4 100644 --- a/proposals/0071-Entity/03-sdk.md +++ b/proposals/0071-Entity/03-sdk.md @@ -212,40 +212,12 @@ The `EntityRegistry.Gather()` method is the central coordination point. It accep ### How Gather Works -```go -func (er *EntityRegistry) Gather(gatherers ...Gatherer) (*dto.MetricPayload, error) { - // 1. Gather metrics from all provided gatherers - var allMetrics []*dto.MetricFamily - referencedRefs := make(map[uint64]struct{}) - - for _, g := range gatherers { - mfs, err := g.Gather() - if err != nil { - return nil, err - } - allMetrics = append(allMetrics, mfs...) - - // Track which entity refs are actually used by metrics - for _, mf := range mfs { - for _, ref := range mf.GetEntityRefs() { - referencedRefs[ref] = struct{}{} - } - } - } - - // 2. Only include entities that are referenced by at least one metric - // Orphan entities (not referenced by any metric) are excluded - entityFamilies := er.collectReferencedEntities(referencedRefs) - - // 3. Return complete payload - // - All metrics are included (with or without entity refs) - // - Only referenced entities are included - return &dto.MetricPayload{ - EntityFamily: entityFamilies, - MetricFamily: allMetrics, - }, nil -} -``` +The `Gather()` method coordinates metric and entity collection: + +1. **Collect metrics** from all provided gatherers +2. **Track entity references** — identify which entity refs are used by the gathered metrics +3. **Filter entities** — include only entities that are actually referenced by at least one metric +4. **Return payload** — combine entity families and metric families into a single `MetricPayload` This filtering ensures that: - **Metrics without entities** are still exposed @@ -254,25 +226,11 @@ This filtering ensures that: ### HTTP Handler Updates -The promhttp package needs a handler that works with `EntityRegistry.Gather()`: +The promhttp package provides `HandlerFor()` that accepts an `EntityRegistry` and metric gatherers, returning an HTTP handler that: -```go -// HandlerFor creates an HTTP handler that exposes entities and metrics together -func HandlerFor(er *EntityRegistry, gatherers []Gatherer, opts HandlerOpts) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - payload, err := er.Gather(gatherers...) - if err != nil { - // error handling... - } - - contentType := expfmt.NegotiateIncludingOpenMetrics(r.Header) - w.Header().Set("Content-Type", string(contentType)) - - enc := expfmt.NewPayloadEncoder(w, contentType) - enc.EncodePayload(payload) - }) -} -``` +1. Calls `EntityRegistry.Gather()` with the provided gatherers +2. Negotiates content type (text or protobuf) +3. Encodes the combined `MetricPayload` to the response ### Usage Example diff --git a/proposals/0071-Entity/04-service-discovery.md b/proposals/0071-Entity/04-service-discovery.md index d59a32f4..31442e44 100644 --- a/proposals/0071-Entity/04-service-discovery.md +++ b/proposals/0071-Entity/04-service-discovery.md @@ -171,26 +171,12 @@ See [01-context.md](./01-context.md#collection-architectures-direct-scraping-vs- ## Configuration -### ScrapeConfig Extension +### New Configuration Options -The `ScrapeConfig` struct in `config/config.go` is extended: - -```go -type ScrapeConfig struct { - // ... existing fields ... - - // EntityFromSD controls SD-derived entity generation. - // When true, Prometheus generates entities from __meta_* labels - // according to the built-in mappings for each SD type. - // Default: false (for backward compatibility) - EntityFromSD bool `yaml:"entity_from_sd,omitempty"` - - // EntityLimit is the maximum number of distinct entities per target. - // A single target may correlate with multiple entities (e.g., pod + node). - // 0 means no limit. - EntityLimit int `yaml:"entity_limit,omitempty"` -} -``` +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `entity_from_sd` | bool | `false` | When true, generates entities from `__meta_*` labels using built-in mappings | +| `entity_limit` | int | `0` | Maximum distinct entities per target (0 = no limit) | ### Configuration Examples @@ -385,121 +371,19 @@ The implementation will be straightforward once a convention is chosen—the tec --- -## Implementation Details +## Implementation Overview -### Entity Generation in the Scrape Pipeline - -Entity generation happens during target creation, before `__meta_*` labels are discarded: - -```go -// In scrape/target.go - modified PopulateLabels -func PopulateLabels(lb *labels.Builder, cfg *config.ScrapeConfig, - tLabels, tgLabels model.LabelSet) (labels.Labels, []*Entity, error) { - PopulateDiscoveredLabels(lb, cfg, tLabels, tgLabels) - - // NEW: Generate entities from __meta_* labels BEFORE relabeling - var entities []*Entity - if cfg.EntityFromSD { - entities = generateEntitiesFromMeta(lb, cfg) - } - - // Apply relabeling (existing behavior) - keep := relabel.ProcessBuilder(lb, cfg.RelabelConfigs...) - if !keep { - return labels.EmptyLabels(), nil, nil - } - - // ... rest of existing validation ... - - // Delete __meta_* labels (existing behavior) - lb.Range(func(l labels.Label) { - if strings.HasPrefix(l.Name, model.MetaLabelPrefix) { - lb.Del(l.Name) - } - }) - - // ... rest of existing code ... - - return res, entities, nil -} +### Where Entity Generation Happens -// generateEntitiesFromMeta extracts entities based on SD-specific mappings -func generateEntitiesFromMeta(lb *labels.Builder, cfg *config.ScrapeConfig) []*Entity { - var entities []*Entity - - // Detect SD type from __meta_* prefix - // Kubernetes: __meta_kubernetes_* - // EC2: __meta_ec2_* - // etc. - - if hasKubernetesLabels(lb) { - if entity := generateK8sPodEntity(lb); entity != nil { - entities = append(entities, entity) - } - if entity := generateK8sNodeEntity(lb); entity != nil { - entities = append(entities, entity) - } - // ... other K8s entity types - } - - if hasEC2Labels(lb) { - if entity := generateHostEntityFromEC2(lb); entity != nil { - entities = append(entities, entity) - } - } - - // ... other SD types - - return entities -} -``` +Entity generation occurs during target creation in `PopulateLabels()`, **before** `__meta_*` labels are discarded. This timing is critical—once relabeling deletes the meta labels, the raw SD metadata is lost. -### Target Structure Extension +When `entity_from_sd: true`: -The `Target` struct is extended to hold generated entities: - -```go -// In scrape/target.go -type Target struct { - labels labels.Labels - scrapeConfig *config.ScrapeConfig - tLabels model.LabelSet - tgLabels model.LabelSet - - // NEW: Entities generated from SD metadata - sdEntities []*Entity - - // ... existing fields ... -} -``` - -### Entity Transmission to Storage - -When a target is scraped, its SD-derived entities are appended alongside metrics: - -```go -// In scrape/scrape.go - within scrapeLoop.append() -func (sl *scrapeLoop) append(app storage.Appender, b []byte, - contentType string, ts time.Time) (...) { - defTime := timestamp.FromTime(ts) - - // NEW: Append SD-derived entities for this target - if sl.sdEntities != nil { - for _, entity := range sl.sdEntities { - if _, err := app.AppendEntity( - entity.Type, - entity.IdentifyingLabels, - entity.DescriptiveLabels, - defTime, - ); err != nil { - sl.l.Debug("Error appending SD entity", "type", entity.Type, "err", err) - } - } - } - - // ... existing metric parsing and appending ... -} -``` +1. **Detect SD type** — Examine `__meta_*` label prefixes to determine which SD mechanism provided the target +2. **Apply built-in mappings** — Use the standard mappings for that SD type to extract entity attributes +3. **Classify labels** — Separate identifying labels (for identity) from descriptive labels (for context) +4. **Create entities** — Build entity structures with type, identifying labels, and descriptive labels +5. **Associate with target** — Store the generated entities alongside the target for transmission during scrape ### Data Flow Diagram @@ -686,52 +570,13 @@ When a target is re-discovered (on each SD refresh) and `entity_from_sd: true`: When a target disappears from SD: 1. **Immediate behavior**: The target's scrape loop is stopped -2. **Entity marking**: The SD-derived entity associated with that target receives an `endTime` timestamp -3. **Grace period**: Entities remain queryable for historical analysis - -**Implementation**: - -```go -// In scrape/scrape.go - when target is removed -func (sp *scrapePool) sync(targets []*Target) { - // ... existing target diff logic ... - - // For removed targets, mark their entities as potentially stale - for fingerprint, loop := range sp.loops { - if _, ok := uniqueLoops[fingerprint]; !ok { - // Target removed - if loop.sdEntities != nil { - for _, entity := range loop.sdEntities { - // Don't immediately mark dead - other targets might use same entity - sp.entityRefCounts[entity.Hash()]-- - if sp.entityRefCounts[entity.Hash()] == 0 { - // No more targets reference this entity - app.MarkEntityDead(entity.Ref, timestamp.FromTime(time.Now())) - } - } - } - loop.stop() - } - } -} -``` +2. **Reference counting**: The scrape pool tracks how many targets reference each entity +3. **Entity marking**: When the last target referencing an entity disappears, the entity's `endTime` is set +4. **Grace period**: Entities remain queryable for historical analysis until retention removes them ### Entity Deduplication -Multiple targets may correlate with the same entity (e.g., multiple containers in a pod). The entity is only created once: - -```go -// Entity identity is determined by type + identifying labels -func entityHash(entityType string, identifyingLabels labels.Labels) uint64 { - h := fnv.New64a() - h.Write([]byte(entityType)) - identifyingLabels.Range(func(l labels.Label) { - h.Write([]byte(l.Name)) - h.Write([]byte(l.Value)) - }) - return h.Sum64() -} -``` +Multiple targets may correlate with the same entity (e.g., multiple containers in a pod). Entity identity is determined by type + identifying labels—if two targets generate entities with the same identity, only one entity is stored. When the same entity is discovered from multiple targets: - First discovery creates the entity diff --git a/proposals/0071-Entity/05-storage.md b/proposals/0071-Entity/05-storage.md index b98e2df5..97b5c234 100644 --- a/proposals/0071-Entity/05-storage.md +++ b/proposals/0071-Entity/05-storage.md @@ -1,7 +1,5 @@ # Entity Storage -> **Recommended Approach**: This document describes the correlation-based storage design, which we recommend for initial implementation due to its incremental nature and backward compatibility. An alternative design that fundamentally changes how series identity works is described in [05b-storage-entity-native.md](05b-storage-entity-native.md). - ## Abstract This document specifies how Prometheus stores entities reliably and efficiently. Entities represent the things that produce telemetry (pods, nodes, services) and need different storage semantics than traditional time series: they have immutable identifying labels, mutable descriptive labels that change over time, and lifecycle boundaries (creation and deletion). This document covers the in-memory structures, Write-Ahead Log integration, block persistence, and the correlation index that links entities to their associated metrics. @@ -63,7 +61,7 @@ Each entity in memory is represented by the following structure: ```go type memEntity struct { - // Immutable after creation - no lock needed for these fields + // Immutable after creation ref EntityRef // Unique identifier (uint64, auto-incrementing) entityType string // e.g., "k8s.pod", "service", "k8s.node" identifyingLabels labels.Labels // Immutable labels that define identity @@ -72,7 +70,7 @@ type memEntity struct { startTime int64 // When this entity incarnation was created endTime int64 // When deleted (0 if still alive) - // Mutable - requires lock + // Mutable sync.Mutex descriptiveSnapshots []labelSnapshot // Historical descriptive labels lastSeen int64 // Last scrape timestamp (for staleness checking) @@ -127,14 +125,6 @@ descriptiveSnapshots: [ ] ``` -**Why snapshots instead of an event log?** - -An event log (storing only deltas) would save storage space but impose query-time costs. To answer "what were the descriptive labels at time T?", a query would need to: -1. Find all change events before T -2. Replay them to reconstruct the state - -With snapshots, the query simply finds the latest snapshot where `snapshot.timestamp <= T`. - ### Entity Lifecycle Each entity has explicit lifecycle boundaries: @@ -186,102 +176,27 @@ Entity A and Entity B have the same identifying labels but different EntityRefs #### Entity Storage in Head -The Head block is extended with entity storage: - -```go -type Head struct { - // ... existing fields ... - - // Entity storage - entities *stripeEntities // All entities by ref or identifying attrs hash - entityPostings *EntityMemPostings // Inverted index for entity labels - - // Correlation index - seriesToEntities map[HeadSeriesRef][]EntityRef - entitiesToSeries map[EntityRef][]HeadSeriesRef - correlationMtx sync.RWMutex - - lastEntityID atomic.Uint64 // For generating EntityRefs -} -``` - -#### stripeEntities - -Similar to `stripeSeries`, provides sharded concurrent access to entities: - -```go -type stripeEntities struct { - size int - series []map[EntityRef]*memEntity - hashes []map[uint64][]*memEntity // hash(identifyingAttrs) -> entities - locks []sync.RWMutex -} - -// Get entity by ref -func (s *stripeEntities) getByRef(ref EntityRef) *memEntity - -// Get entity by identifying labels (may return multiple for historical) -func (s *stripeEntities) getByIdentifyingLabels(hash uint64, lbls labels.Labels) []*memEntity - -func (s *stripeEntities) getAliveByIdentifyingLabels(hash uint64, lbls labels.Labels) *memEntity -``` - -#### EntityMemPostings +The Head block is extended with: -An inverted index mapping label name/value pairs to entity references: +| Component | Purpose | +|-----------|---------| +| **Entity storage** | Sharded map (like `stripeSeries`) storing `memEntity` by ref or identifying labels hash | +| **Entity postings** | Inverted index mapping `(label_name, label_value)` → entity refs | +| **Correlation index** | Bidirectional maps: `series_ref ↔ entity_refs` | -```go -type EntityMemPostings struct { - mtx sync.RWMutex - m map[string]map[string][]EntityRef // label name -> label value -> entity refs -} - -// Example contents: -// "k8s.namespace.name" -> "production" -> [EntityRef(1), EntityRef(5), EntityRef(12)] -// "k8s.node.name" -> "worker-1" -> [EntityRef(1), EntityRef(3)] -``` - -This enables efficient lookups like "find all entities in namespace X" or "find all entities on node Y". +The entity storage and postings follow the same sharding patterns as the existing series storage to support concurrent access. #### Correlation Index -The correlation index maintains the many-to-many relationship between series and entities: +The correlation index maintains the many-to-many relationship between series and entities as two bidirectional maps: +- **Series → Entities**: "which entities does this series correlate with?" +- **Entities → Series**: "which series are associated with this entity?" -```go -// Series -> Entities: "which entities does this series correlate with?" -seriesToEntities map[HeadSeriesRef][]EntityRef +**Building correlations at ingestion time:** -// Entities -> Series: "which series are associated with this entity?" -entitiesToSeries map[EntityRef][]HeadSeriesRef -``` - -**Building the correlation at ingestion time:** +When a **new series** is created, Prometheus checks each registered entity type. If the series labels contain all of an entity type's identifying labels, it looks up the corresponding entity and adds the correlation. -When a new series is created: -``` -series.labels = {__name__="container_cpu", k8s.namespace.name="prod", k8s.pod.uid="abc", k8s.node.uid="xyz"} - -For each registered entity type: - k8s.pod: requires {k8s.namespace.name, k8s.pod.uid} - → series has both → find entity with these identifying attrs - → if found and alive: add to correlation index - - k8s.node: requires {k8s.node.uid} - → series has this → find entity with this identifying attr - → if found and alive: add to correlation index - -Result: seriesToEntities[series.ref] = [podEntityRef, nodeEntityRef] -``` - -When a new entity is created: -``` -entity.identifyingAttrs = {k8s.namespace.name="prod", k8s.pod.uid="abc"} - -Find all series whose labels contain ALL of entity's identifying attrs: - → Use postings index: intersect(postings["k8s.namespace.name"]["prod"], - postings["k8s.pod.uid"]["abc"]) - → For each matching series: add to correlation index -``` +When a **new entity** is created, Prometheus uses the postings index to find all series whose labels contain all of the entity's identifying labels, then adds correlations for each match. **Correlation and Entity Lifecycle** @@ -352,56 +267,6 @@ Entity records are written to WAL in these situations: Writing full records (not deltas) simplifies replay and allows any single record to fully describe entity state at that point. -#### WAL Replay Behavior - -On startup, entity records are replayed to reconstruct the Head's entity state: - -```go -func (h *Head) replayEntityRecord(rec RefEntity) error { - existing := h.entities.getByRef(rec.Ref) - - if existing == nil { - // New entity - create it - entity := &memEntity{ - ref: rec.Ref, - entityType: rec.EntityType, - identifyingLabels: rec.IdentifyingLabels, - startTime: rec.StartTime, - endTime: rec.EndTime, - } - if len(rec.DescriptiveLabels) > 0 { - entity.descriptiveSnapshots = []labelSnapshot{ - {timestamp: rec.Timestamp, labels: rec.DescriptiveLabels}, - } - } - h.entities.set(entity) - } else { - // Update existing entity - existing.Lock() - existing.endTime = rec.EndTime - if len(rec.DescriptiveLabels) > 0 { - // Check if labels changed from last snapshot - if shouldAddSnapshot(existing, rec.DescriptiveLabels) { - existing.descriptiveSnapshots = append( - existing.descriptiveSnapshots, - labelSnapshot{timestamp: rec.Timestamp, labels: rec.DescriptiveLabels}, - ) - } - } - existing.Unlock() - } - - // Update lastEntityID if needed - if uint64(rec.Ref) > h.lastEntityID.Load() { - h.lastEntityID.Store(uint64(rec.Ref)) - } - - return nil -} -``` - -The correlation index is rebuilt after all WAL records are replayed, by iterating all entities and series and computing correlations. - ### Block Persistence When the Head is compacted into a persistent block, entities must also be persisted. @@ -453,20 +318,6 @@ The entity index file structure: └─────────────────────────────────────────────────────────────────────┘ ``` -#### Compaction Behavior - -During compaction: - -1. **Entity Selection**: Include entities whose lifecycle overlaps with the block's time range - ``` - Include entity if: entity.startTime < block.maxTime AND - (entity.endTime == 0 OR entity.endTime > block.minTime) - ``` - -2. **Snapshot Filtering**: Only include descriptive snapshots within the block's time range - -3. **Deduplication**: If compacting multiple blocks, entities with the same EntityRef are merged, keeping all unique snapshots - #### Entity Retention Entities follow the same retention policy as series data. Prometheus deletes blocks based on `RetentionDuration` (time-based) or `MaxBytes` (size-based). When blocks are deleted, entities are handled as follows: @@ -493,70 +344,6 @@ When Block 1 and Block 2 are deleted due to retention: This ensures historical queries can always resolve entity correlations for the data that remains. -#### Head Entity Garbage Collection - -The Head block periodically runs garbage collection to remove entities that are no longer needed in memory. This mirrors how series GC works in `Head.gc()`. - -**GC Eligibility**: An entity in the Head is eligible for garbage collection when: -1. The entity is dead (`endTime != 0`), AND -2. The entity's entire lifecycle is before `Head.MinTime()` (fully compacted to blocks) - -```go -func (h *Head) gcEntities() map[EntityRef]struct{} { - mint := h.MinTime() - deleted := make(map[EntityRef]struct{}) - - h.entities.iter(func(entity *memEntity) { - // Only consider dead entities - if entity.endTime == 0 { - return // Still alive, keep in Head - } - - // If the entity's entire lifecycle is before Head's minTime, - // it has been fully compacted to blocks and can be removed - if entity.endTime < mint { - deleted[entity.ref] = struct{}{} - } - }) - - // Remove from entity storage - for ref := range deleted { - entity := h.entities.getByRef(ref) - h.entities.delete(ref) - h.entityPostings.Delete(ref, entity.identifyingLabels) - } - - // Clean up correlation index - h.correlationMtx.Lock() - for ref := range deleted { - // Remove entity from all series correlations - for _, seriesRef := range h.entitiesToSeries[ref] { - h.seriesToEntities[seriesRef] = removeEntityRef( - h.seriesToEntities[seriesRef], ref) - } - delete(h.entitiesToSeries, ref) - } - h.correlationMtx.Unlock() - - return deleted -} -``` - -**Integration with Head.gc()**: Entity GC runs alongside series GC during `truncateMemory()`: - -```go -func (h *Head) truncateSeriesAndChunkDiskMapper(caller string) error { - // ... existing series GC ... - actualInOrderMint, minOOOTime, minMmapFile := h.gc() - - // Entity GC - deletedEntities := h.gcEntities() - h.metrics.entitiesRemoved.Add(float64(len(deletedEntities))) - - // ... rest of truncation ... -} -``` - ## Ingestion Flow ### Extended Appender Interface @@ -578,178 +365,16 @@ type Appender interface { } ``` -### headAppender Implementation - -```go -func (a *headAppender) AppendEntity( - entityType string, - identifyingAttrs labels.Labels, - descriptiveAttrs labels.Labels, - timestamp int64, -) (EntityRef, error) { - - // Validate inputs - if entityType == "" { - return 0, fmt.Errorf("entity type cannot be empty") - } - if len(identifyingLabels) == 0 { - return 0, fmt.Errorf("identifying labels cannot be empty") - } - - // Sort labels for consistent hashing - sort.Sort(identifyingLabels) - sort.Sort(descriptiveLabels) - - hash := identifyingLabels.Hash() - - // Check for existing alive entity - entity := a.head.entities.getAliveByIdentifyingLabels(hash, identifyingLabels) - - if entity == nil { - // Create new entity - ref := EntityRef(a.head.lastEntityID.Inc()) - entity = &memEntity{ - ref: ref, - entityType: entityType, - identifyingLabels: identifyingLabels, - startTime: timestamp, - endTime: 0, - lastSeen: timestamp, - } - - if len(descriptiveLabels) > 0 { - entity.descriptiveSnapshots = []labelSnapshot{ - {timestamp: timestamp, labels: descriptiveLabels}, - } - } - - // Stage for commit - a.pendingEntities = append(a.pendingEntities, entity) - a.pendingEntityRecords = append(a.pendingEntityRecords, RefEntity{ - Ref: ref, - EntityType: entityType, - IdentifyingLabels: identifyingLabels, - DescriptiveLabels: descriptiveLabels, - StartTime: timestamp, - EndTime: 0, - Timestamp: timestamp, - }) - - return ref, nil - } - - // Update existing entity - entity.Lock() - entity.lastSeen = timestamp - - // Check if descriptive labels changed - needsSnapshot := false - if len(entity.descriptiveSnapshots) == 0 { - needsSnapshot = len(descriptiveLabels) > 0 - } else { - lastSnapshot := entity.descriptiveSnapshots[len(entity.descriptiveSnapshots)-1] - needsSnapshot = !labels.Equal(lastSnapshot.labels, descriptiveLabels) - } - - if needsSnapshot { - entity.descriptiveSnapshots = append(entity.descriptiveSnapshots, labelSnapshot{ - timestamp: timestamp, - labels: descriptiveLabels, - }) - - // Stage WAL record for changed labels - a.pendingEntityRecords = append(a.pendingEntityRecords, RefEntity{ - Ref: entity.ref, - EntityType: entity.entityType, - IdentifyingLabels: entity.identifyingLabels, - DescriptiveLabels: descriptiveLabels, - StartTime: entity.startTime, - EndTime: 0, - Timestamp: timestamp, - }) - } - - entity.Unlock() - return entity.ref, nil -} -``` - -### Commit and Rollback +### AppendEntity Behavior -**Commit** persists all pending entities to WAL and updates indexes: +When `AppendEntity` is called: -```go -func (a *headAppender) Commit() error { - // ... existing commit logic for samples ... - - // Write entity records to WAL - if len(a.pendingEntityRecords) > 0 { - if err := a.logEntities(); err != nil { - return err - } - } - - // Add new entities to Head - for _, entity := range a.pendingEntities { - a.head.entities.set(entity) - a.head.entityPostings.Add(entity.ref, entity.identifyingLabels) - - // Build correlations with existing series - a.head.buildEntityCorrelations(entity) - } - - // Clear pending state - a.pendingEntities = a.pendingEntities[:0] - a.pendingEntityRecords = a.pendingEntityRecords[:0] - - return nil -} -``` - -**Rollback** discards all pending changes: - -```go -func (a *headAppender) Rollback() error { - // ... existing rollback logic ... - - // Simply discard pending entities - they were never added to Head - a.pendingEntities = a.pendingEntities[:0] - a.pendingEntityRecords = a.pendingEntityRecords[:0] - - return nil -} -``` - -### Correlation Index Updates - -When building correlations for a new entity: - -```go -func (h *Head) buildEntityCorrelations(entity *memEntity) { - // Find all series that have ALL of the entity's identifying labels - var postingsLists []Postings - - entity.identifyingLabels.Range(func(l labels.Label) { - postingsLists = append(postingsLists, h.postings.Get(l.Name, l.Value)) - }) - - // Intersect all postings lists - intersection := Intersect(postingsLists...) - - h.correlationMtx.Lock() - defer h.correlationMtx.Unlock() - - for intersection.Next() { - seriesRef := intersection.At() - - // Add bidirectional correlation - h.seriesToEntities[seriesRef] = append(h.seriesToEntities[seriesRef], entity.ref) - h.entitiesToSeries[entity.ref] = append(h.entitiesToSeries[entity.ref], seriesRef) - } -} -``` +1. **Validate** — Entity type and identifying labels must be non-empty +2. **Lookup** — Search for an existing alive entity with the same identifying labels +3. **If not found** — Create a new entity with a fresh EntityRef, set `startTime` to now, stage for WAL write +4. **If found** — Update `lastSeen` timestamp; if descriptive labels changed, append a new snapshot and stage a WAL record -When a new series is created, correlations are built similarly by finding all alive entities whose identifying labels are a subset of the series labels. +New entities and WAL records are staged (not committed) until `Commit()` is called, following the same transactional pattern as sample appends. ## Query Support @@ -779,122 +404,8 @@ type EntityQuerier interface { ### Time-Range Filtering Queries specify a time range `[mint, maxt]`. Entity results are filtered by lifecycle: - -```go -func (e *memEntity) isAliveAt(t int64) bool { - return e.startTime <= t && (e.endTime == 0 || e.endTime > t) -} - -func (e *memEntity) overlapsRange(mint, maxt int64) bool { - return e.startTime < maxt && (e.endTime == 0 || e.endTime > mint) -} -``` - -### Descriptive Label Lookup - -To get descriptive labels at a specific timestamp: - -```go -func (e *memEntity) descriptiveLabelsAt(t int64) labels.Labels { - if !e.isAliveAt(t) { - return labels.EmptyLabels() - } - - snapshots := e.descriptiveSnapshots - if len(snapshots) == 0 { - return labels.EmptyLabels() - } - - // Binary search: find the first snapshot where timestamp > t - // Then the snapshot we want is at index i-1 - i := sort.Search(len(snapshots), func(i int) bool { - return snapshots[i].timestamp > t - }) - - if i == 0 { - // All snapshots are after time t - return labels.EmptyLabels() - } - - return snapshots[i-1].labels -} -``` - -## Remote Write Considerations - -Entities need to be transmitted over Prometheus remote write protocol. This requires extending the protobuf definitions: - -```protobuf -message EntityWriteRequest { - repeated Entity entities = 1; -} - -message Entity { - string entity_type = 1; - repeated Label identifying_labels = 2; - repeated Label descriptive_labels = 3; - int64 start_time_ms = 4; - int64 end_time_ms = 5; // 0 if alive - int64 timestamp_ms = 6; // When this state was observed -} -``` - -Key considerations for remote write: - -1. **Incremental Updates**: Only send entity records when state changes (new entity, attrs changed, entity died) -2. **Receiver Reconciliation**: Receivers must handle out-of-order entity records and merge appropriately -3. **Correlation Rebuild**: Receivers rebuild correlation indexes locally based on their series data - -Detailed remote write protocol changes are specified in a separate document. - -## Trade-offs and Design Decisions - -### Separate Entity Storage vs Embedding in Series - -**Decision**: Separate storage structure for entities - -**Rationale**: -- Entities have different access patterns (lookup by identifying labels vs. time-range queries) -- Many-to-many relationship with series doesn't fit the one-to-one series model -- Entity lifecycle (explicit start/end) differs from series staleness -- Descriptive labels are string-valued, not numeric samples - -**Trade-off**: Additional complexity in storage layer, but cleaner semantics and better query performance. - -### Snapshots vs Event Log for Descriptive Labels - -**Decision**: Store complete snapshots at each change point - -**Rationale**: -- Point-in-time queries are common ("what was this pod's node at time T?") -- Snapshots enable O(log n) lookup via binary search -- Event log would require O(n) replay to reconstruct state -- Descriptive labels change infrequently, limiting snapshot count - -**Trade-off**: Higher storage per change, but faster queries and simpler implementation. - -### Correlation at Ingestion Time vs Query Time - -**Decision**: Build correlation index at ingestion time - -**Rationale**: -- Queries should be fast; correlation lookup is O(1) with pre-built index -- Ingestion can afford extra work; it's already doing label processing -- Correlation relationships are stable (based on immutable identifying labels) - -**Trade-off**: Ingestion overhead for maintaining correlation index, but significantly faster queries. - -### Single WAL Record Type vs Multiple - -**Decision**: Single comprehensive entity record type - -**Rationale**: -- Simplifies WAL encoding/decoding logic -- Any single record fully describes entity state (no partial records) -- Replay is straightforward—each record is self-contained -- Matches pattern used for series (full labels in each Series record) - -**Trade-off**: Slightly larger WAL records, but simpler and more robust. +- **isAliveAt(t)**: True if `startTime <= t` and (`endTime == 0` or `endTime > t`) +- **overlapsRange(mint, maxt)**: True if the entity's lifecycle overlaps the query range ## Open Questions / Future Work @@ -954,45 +465,8 @@ These topics need benchmarking with realistic workloads before finalizing the im --- -## TODO: Columnar Storage Strategies - -This section outlines potential optimizations for entity label storage that warrant further exploration: - -### Background - -Descriptive labels are fundamentally different from time series samples: -- They are **string-valued**, not numeric -- They change **infrequently** (entity metadata doesn't update every scrape) -- They are often **queried together** (users typically want all labels of an entity, not just one) -- They benefit from **compression** due to repetitive patterns (many pods have similar labels) - -These characteristics suggest that columnar storage techniques, commonly used in analytical databases, might offer significant benefits. - -### Areas to Explore - -- **Column-oriented label storage**: Instead of storing all labels for a snapshot together (row-oriented), store each label name as a column with its values across entities. This could improve compression and enable efficient filtering by specific labels. - -- **Dictionary encoding**: Entity labels often have low cardinality (e.g., `k8s.pod.status.phase` has only a few possible values). Dictionary encoding could dramatically reduce storage for descriptive labels. - -- **Run-length encoding for temporal data**: When descriptive labels don't change across many snapshots, run-length encoding could eliminate redundant storage. - -- **Label projection pushdown**: When queries only need specific entity labels (e.g., `sum by (k8s.node.name)`), the storage layer could avoid reading unnecessary labels. - -- **Separate label storage files**: Similar to how chunks are stored separately from the index, entity labels could have dedicated storage with format optimized for their access patterns. - -### Trade-offs to Consider - -- Implementation complexity vs. storage/query benefits -- Read vs. write optimization (columnar is typically better for reads) -- Memory overhead of maintaining multiple storage formats -- Compatibility with existing TSDB compaction and retention logic - -This is a potential future optimization and not required for the initial implementation. - ---- - ## What's Next -- [Querying](05-querying.md): How PromQL is extended to query entities and correlations -- [Web UI and APIs](06-web-ui-and-apis.md): HTTP API endpoints and UI for entity exploration +- [Querying](06-querying.md): How PromQL is extended to query entities and correlations +- [Web UI and APIs](07-web-ui-and-apis.md): HTTP API endpoints and UI for entity exploration diff --git a/proposals/0071-Entity/05b-storage-entity-native.md b/proposals/0071-Entity/05b-storage-entity-native.md deleted file mode 100644 index c7404108..00000000 --- a/proposals/0071-Entity/05b-storage-entity-native.md +++ /dev/null @@ -1,934 +0,0 @@ -# Storage Design: Entity-Native Model - -> **Alternative Approach**: This document describes an alternative storage design where series identity is based only on metric labels, with samples grouped into "streams" by entity. While this approach offers stronger alignment with OpenTelemetry's data model and addresses cardinality at a fundamental level, it requires significant changes to Prometheus's core architecture. We recommend the correlation-based approach described in [05-storage.md](05-storage.md) for initial implementation, as it can be built incrementally on the existing TSDB without breaking backward compatibility. This entity-native design remains valuable as a potential future evolution once entities prove their value in production. - -## Executive Summary - -This document proposes a fundamental redesign of Prometheus's storage model to natively support Entities as first-class concepts, separate from metric identity. The key insight is that **metric identity** (what is being measured) should be separate from **entity identity** (what is being measured about). - -### Core Idea - -``` -Series: - labels: {__name__="http_requests_total", method="GET", status="200"} # metric labels only - data: [ - { - entityRefs: [podRef1, nodeRef1, serviceRef1], - samples: [{t: 1000, v: 100}, {t: 1015, v: 120}] - }, - { - entityRefs: [podRef2, nodeRef3], - samples: [{t: 1000, v: 1020}, {t: 1015, v: 1203}] - } - ] -``` - -This model separates: -- **What** is being measured → Series labels (metric name + metric-specific labels) -- **About what** it's being measured → Entity references (linking to entity storage) - ---- - -## Part 1: Current TSDB Model (Reference) - -Before diving into the new model, let's understand the current Prometheus TSDB architecture. - -### Current Series Identity - -In the current model, a series is uniquely identified by its **complete label set**: - -```go -type memSeries struct { - ref chunks.HeadSeriesRef // Unique identifier (auto-incrementing) - lset labels.Labels // Complete label set (includes ALL labels) - headChunks *memChunk // In-memory samples - mmappedChunks []*mmappedChunk // Memory-mapped chunks on disk - // ... -} -``` - -**Example:** These are THREE different series in current Prometheus: -``` -http_requests_total{method="GET", status="200", pod="nginx-abc"} # Series 1 -http_requests_total{method="GET", status="200", pod="nginx-def"} # Series 2 -http_requests_total{method="GET", status="200", pod="nginx-xyz"} # Series 3 -``` - -### Current Flow - -``` -Scrape → Labels → Hash(Labels) → getOrCreate(hash, labels) → memSeries → Append Sample -``` - -The hash of ALL labels determines series identity: - -```go -func (a *appender) getOrCreate(l labels.Labels) (series *memSeries, created bool) { - hash := l.Hash() // Hash of ALL labels - - series = a.series.GetByHash(hash, l) - if series != nil { - return series, false - } - - ref := chunks.HeadSeriesRef(a.nextRef.Inc()) - series = &memSeries{ref: ref, lset: l} - a.series.Set(hash, series) - return series, true -} -``` - -### Current Index Structure - -The postings index maps `label_name=label_value` → list of series refs: - -``` -Postings Index: - method="GET" → [1, 2, 3, 5, 8, ...] - status="200" → [1, 3, 5, 7, 9, ...] - pod="nginx-abc" → [1, 4, 7, ...] - pod="nginx-def" → [2, 5, 8, ...] -``` - -Query `http_requests_total{method="GET", status="200"}` intersects posting lists. - ---- - -## Part 2: Entity-Native Storage Model - -### Core Concepts - -#### 1. Metric Labels vs Entity Labels - -**Metric Labels** describe the measurement itself: -- `method="GET"` - HTTP method being measured -- `status="200"` - Response status being counted -- `le="0.5"` - Histogram bucket boundary -- `quantile="0.99"` - Summary quantile - -**Entity Labels** describe what the measurement is about: -- `k8s.pod.uid="abc-123"` - Which pod -- `k8s.node.name="worker-1"` - Which node -- `service.name="api-gateway"` - Which service - -#### 2. New Series Definition - -```go -// New: Series identity = metric name + metric labels only -type memSeries struct { - ref SeriesRef // Unique identifier - metricName string // e.g., "http_requests_total" - labels labels.Labels // Metric-specific labels only (method, status, etc.) - - // Multiple data streams, one per entity combination - streams []*dataStream // Samples grouped by entity -} - -// A stream of samples from a specific entity combination -type dataStream struct { - entityRefs []EntityRef // Which entities this stream is from - headChunk *memChunk // Current in-memory chunk - mmappedChunks []*mmappedChunk // Historical chunks - - // Staleness tracking per stream - lastSeen int64 // Last sample timestamp -} -``` - -#### 3. Entity Storage (Separate) - -```go -type memEntity struct { - ref EntityRef // Unique identifier (auto-incrementing) - entityType string // e.g., "k8s.pod", "k8s.node", "service" - identifyingLabels labels.Labels // Immutable: what makes this entity unique - - // Mutable descriptive labels with history - sync.Mutex - descriptiveSnapshots []labelSnapshot - - // Lifecycle - startTime int64 // When this entity incarnation started - endTime int64 // 0 if still alive -} - -type labelSnapshot struct { - timestamp int64 - labels labels.Labels -} -``` - -### Visual Representation - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ SERIES STORAGE │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ Series 1: http_requests_total{method="GET", status="200"} │ -│ ┌─────────────────────────────────────────────────────────────────────────┐│ -│ │ Stream A: entityRefs=[pod:abc, node:worker-1, svc:api] ││ -│ │ Chunks: [(t=1000,v=100), (t=1015,v=120), ...] ││ -│ ├─────────────────────────────────────────────────────────────────────────┤│ -│ │ Stream B: entityRefs=[pod:def, node:worker-2] ││ -│ │ Chunks: [(t=1000,v=1020), (t=1015,v=1203), ...] ││ -│ ├─────────────────────────────────────────────────────────────────────────┤│ -│ │ Stream C: entityRefs=[pod:xyz, node:worker-1, svc:api] ││ -│ │ Chunks: [(t=1020,v=5), (t=1035,v=15), ...] ← Pod rescheduled here ││ -│ └─────────────────────────────────────────────────────────────────────────┘│ -├─────────────────────────────────────────────────────────────────────────────┤ -│ Series 2: http_requests_total{method="POST", status="201"} │ -│ ┌─────────────────────────────────────────────────────────────────────────┐│ -│ │ Stream A: entityRefs=[pod:abc, node:worker-1, svc:api] ││ -│ │ Chunks: [(t=1000,v=50), (t=1015,v=55), ...] ││ -│ └─────────────────────────────────────────────────────────────────────────┘│ -└─────────────────────────────────────────────────────────────────────────────┘ - -┌─────────────────────────────────────────────────────────────────────────────┐ -│ ENTITY STORAGE │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ Entity: k8s.pod (ref=pod:abc) │ -│ Identifying: {k8s.pod.uid="abc-123"} │ -│ Descriptive @ t=1000: {k8s.pod.name="nginx-abc", version="1.0"} │ -│ Descriptive @ t=2000: {k8s.pod.name="nginx-abc", version="1.1"} │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ Entity: k8s.node (ref=node:worker-1) │ -│ Identifying: {k8s.node.uid="node-uid-001"} │ -│ Descriptive @ t=0: {k8s.node.name="worker-1", region="us-east-1"} │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ Entity: service (ref=svc:api) │ -│ Identifying: {service.name="api-gateway", service.namespace="prod"} │ -│ Descriptive @ t=1000: {service.version="2.0", deployment="blue"} │ -│ Descriptive @ t=3000: {service.version="2.1", deployment="green"} │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## Part 3: In-Memory Structures - -### 3.1 Series Storage - -```go -type Head struct { - // Series storage - sharded for concurrent access - series *stripeSeries - - // Entity storage - separate sharded structure - entities *stripeEntities - - // Index structures - metricPostings *MetricPostings // metric labels → series refs - entityPostings *EntityPostings // entity refs → (series ref, stream index) - - // ... existing fields (WAL, chunks, etc.) -} - -// stripeSeries holds series by SeriesRef and by metric label hash -type stripeSeries struct { - size int - series []map[SeriesRef]*memSeries // Sharded by ref - hashes []seriesHashmap // Sharded by metric label hash - locks []stripeLock -} - -// seriesHashmap - only uses metric labels for lookup -type seriesHashmap struct { - unique map[uint64]*memSeries - conflicts map[uint64][]*memSeries -} -``` - -### 3.2 Series Lookup - -```go -// Key change: Series lookup only uses metric labels -func (a *appender) getOrCreateSeries(metricLabels labels.Labels) (*memSeries, bool) { - hash := metricLabels.Hash() // Hash of METRIC labels only - - series := a.series.GetByHash(hash, metricLabels) - if series != nil { - return series, false - } - - ref := SeriesRef(a.nextSeriesRef.Inc()) - series = &memSeries{ - ref: ref, - metricName: metricLabels.Get(labels.MetricName), - labels: metricLabels.WithoutEmpty(), - streams: make([]*dataStream, 0), - } - a.series.Set(hash, series) - return series, true -} -``` - -### 3.3 Stream Management - -```go -// Find or create a data stream for the given entity combination -func (s *memSeries) getOrCreateStream(entityRefs []EntityRef) (*dataStream, bool) { - s.Lock() - defer s.Unlock() - - // Look for existing stream with same entity combination - for _, stream := range s.streams { - if entityRefsEqual(stream.entityRefs, entityRefs) { - return stream, false - } - } - - // Create new stream - stream := &dataStream{ - entityRefs: entityRefs, - headChunk: nil, - lastSeen: 0, - } - s.streams = append(s.streams, stream) - return stream, true -} - -func entityRefsEqual(a, b []EntityRef) bool { - if len(a) != len(b) { - return false - } - // Sort and compare - entity refs are unordered - sortedA := sortEntityRefs(a) - sortedB := sortEntityRefs(b) - for i := range sortedA { - if sortedA[i] != sortedB[i] { - return false - } - } - return true -} -``` - -### 3.4 Entity Storage - -```go -// stripeEntities holds entities by EntityRef and by identifying label hash -type stripeEntities struct { - size int - entities []map[EntityRef]*memEntity // Sharded by ref - hashes []entityHashmap // Sharded by (type + identifying labels) hash - locks []stripeLock -} - -func (a *appender) getOrCreateEntity( - entityType string, - identifyingLabels labels.Labels, - descriptiveLabels labels.Labels, - timestamp int64, -) (*memEntity, bool) { - // Hash of type + identifying labels - hash := hashEntityIdentity(entityType, identifyingLabels) - - entity := a.entities.GetByHash(hash, entityType, identifyingLabels) - if entity != nil { - // Update descriptive labels if changed - entity.updateDescriptive(descriptiveLabels, timestamp) - return entity, false - } - - ref := EntityRef(a.nextEntityRef.Inc()) - entity = &memEntity{ - ref: ref, - entityType: entityType, - identifyingLabels: identifyingLabels, - startTime: timestamp, - descriptiveSnapshots: []labelSnapshot{{ - timestamp: timestamp, - labels: descriptiveLabels, - }}, - } - a.entities.Set(hash, entity) - return entity, true -} -``` - ---- - -## Part 4: Index Structures - -### 4.1 Metric Postings Index - -Maps metric labels to series refs (similar to current postings, but only for metric labels): - -```go -type MetricPostings struct { - mtx sync.RWMutex - // label name → label value → series refs - m map[string]map[string][]SeriesRef -} - -// Add a series to the postings index -func (p *MetricPostings) Add(ref SeriesRef, lset labels.Labels) { - p.mtx.Lock() - defer p.mtx.Unlock() - - lset.Range(func(l labels.Label) { - if p.m[l.Name] == nil { - p.m[l.Name] = make(map[string][]SeriesRef) - } - p.m[l.Name][l.Value] = append(p.m[l.Name][l.Value], ref) - }) -} - -// Get series refs for a label pair -func (p *MetricPostings) Get(name, value string) []SeriesRef { - p.mtx.RLock() - defer p.mtx.RUnlock() - - if p.m[name] == nil { - return nil - } - return p.m[name][value] -} -``` - -### 4.2 Entity Postings Index - -Maps entity labels to (series, stream) pairs: - -```go -type EntityPostings struct { - mtx sync.RWMutex - - // entityRef → list of (seriesRef, streamIndex) - byEntity map[EntityRef][]streamLocation - - // For reverse lookup: entity label → entity refs - byLabel map[string]map[string][]EntityRef -} - -type streamLocation struct { - seriesRef SeriesRef - streamIndex int -} - -// Register that a stream uses an entity -func (p *EntityPostings) AddStreamEntity( - seriesRef SeriesRef, - streamIndex int, - entityRef EntityRef, -) { - p.mtx.Lock() - defer p.mtx.Unlock() - - loc := streamLocation{seriesRef: seriesRef, streamIndex: streamIndex} - p.byEntity[entityRef] = append(p.byEntity[entityRef], loc) -} - -// Find all streams that use a specific entity -func (p *EntityPostings) GetStreamsByEntity(entityRef EntityRef) []streamLocation { - p.mtx.RLock() - defer p.mtx.RUnlock() - - return p.byEntity[entityRef] -} -``` - -### 4.3 Combined Query Flow - -```go -// Query: http_requests_total{method="GET", k8s.pod.name="nginx-abc"} -func (q *querier) Select(matchers ...*labels.Matcher) SeriesSet { - var metricMatchers, entityMatchers []*labels.Matcher - - for _, m := range matchers { - if isEntityLabel(m.Name) { - entityMatchers = append(entityMatchers, m) - } else { - metricMatchers = append(metricMatchers, m) - } - } - - // Step 1: Find series by metric labels - seriesRefs := q.metricPostings.PostingsForMatchers(metricMatchers...) - - // Step 2: If entity matchers, filter streams - if len(entityMatchers) > 0 { - // Find entities that match - entityRefs := q.findMatchingEntities(entityMatchers) - - // Find streams that use these entities - return q.filterStreamsByEntities(seriesRefs, entityRefs) - } - - // Return all streams from matching series - return q.allStreamsFromSeries(seriesRefs) -} -``` - ---- - -## Part 5: Ingestion Flow - -### 5.1 Scrape Processing - -```go -func (a *appender) Append( - metricLabels labels.Labels, // Only metric-specific labels - entityRefs []EntityRef, // Pre-resolved entity references - timestamp int64, - value float64, -) error { - // Step 1: Get or create series (by metric labels only) - series, seriesCreated := a.getOrCreateSeries(metricLabels) - - // Step 2: Get or create stream (by entity combination) - stream, streamCreated := series.getOrCreateStream(entityRefs) - - // Step 3: Append sample to stream - if err := stream.append(timestamp, value); err != nil { - return err - } - - // Step 4: Update entity postings if new stream - if streamCreated { - streamIdx := len(series.streams) - 1 - for _, entityRef := range entityRefs { - a.entityPostings.AddStreamEntity(series.ref, streamIdx, entityRef) - } - } - - // Record for WAL - a.pendingSamples = append(a.pendingSamples, pendingSample{ - seriesRef: series.ref, - streamIndex: len(series.streams) - 1, - timestamp: timestamp, - value: value, - }) - - return nil -} -``` - -### 5.2 Entity Resolution During Scrape - -```go -// During scrape, labels are split into metric vs entity -func (sl *scrapeLoop) processMetrics( - metrics []parsedMetric, - entities []parsedEntity, -) error { - app := sl.appender() - - // First, resolve all entities from this scrape - entityRefMap := make(map[string]EntityRef) - for _, e := range entities { - entity, _ := app.getOrCreateEntity( - e.Type, - e.IdentifyingLabels, - e.DescriptiveLabels, - sl.timestamp, - ) - entityRefMap[entityKey(e.Type, e.IdentifyingLabels)] = entity.ref - } - - // Then, process metrics with entity references - for _, m := range metrics { - metricLabels, entityTypes := splitLabels(m.Labels) - - // Resolve entity refs for this metric - var entityRefs []EntityRef - for _, et := range entityTypes { - key := entityKeyFromMetric(et, m.Labels) - if ref, ok := entityRefMap[key]; ok { - entityRefs = append(entityRefs, ref) - } - } - - if err := app.Append(metricLabels, entityRefs, m.Timestamp, m.Value); err != nil { - return err - } - } - - return app.Commit() -} -``` - ---- - -## Part 6: WAL Format - -### 6.1 New Record Types - -```go -const ( - // Existing types - RecordSeries Type = 1 - RecordSamples Type = 2 - RecordTombstones Type = 3 - RecordExemplars Type = 4 - RecordMetadata Type = 6 - - // New types for entity-native model - RecordEntity Type = 20 // Entity definition - RecordEntityUpdate Type = 21 // Descriptive label update - RecordStream Type = 22 // New stream in a series - RecordStreamSamples Type = 23 // Samples for a specific stream -) -``` - -### 6.2 Entity Record - -``` -┌────────────────────────────────────────────────────────────────┐ -│ type = 20 <1b> │ -├────────────────────────────────────────────────────────────────┤ -│ ┌────────────────────────────────────────────────────────────┐ │ -│ │ entityRef <8b> │ │ -│ ├────────────────────────────────────────────────────────────┤ │ -│ │ len(entityType) │ │ -│ │ entityType │ │ -│ ├────────────────────────────────────────────────────────────┤ │ -│ │ n = len(identifyingLabels) │ │ -│ │ identifyingLabels │ │ -│ ├────────────────────────────────────────────────────────────┤ │ -│ │ m = len(descriptiveLabels) │ │ -│ │ descriptiveLabels │ │ -│ ├────────────────────────────────────────────────────────────┤ │ -│ │ startTime <8b> │ │ -│ └────────────────────────────────────────────────────────────┘ │ -│ . . . │ -└────────────────────────────────────────────────────────────────┘ -``` - -### 6.3 Series Record - -``` -┌────────────────────────────────────────────────────────────────┐ -│ type = 1 <1b> │ -├────────────────────────────────────────────────────────────────┤ -│ ┌────────────────────────────────────────────────────────────┐ │ -│ │ seriesRef <8b> │ │ -│ ├────────────────────────────────────────────────────────────┤ │ -│ │ n = len(metricLabels) │ │ -│ │ metricLabels │ │ -│ └────────────────────────────────────────────────────────────┘ │ -│ . . . │ -└────────────────────────────────────────────────────────────────┘ -``` - -### 6.4 Stream Record - -``` -┌────────────────────────────────────────────────────────────────┐ -│ type = 22 <1b> │ -├────────────────────────────────────────────────────────────────┤ -│ ┌────────────────────────────────────────────────────────────┐ │ -│ │ seriesRef <8b> │ │ -│ │ streamIndex │ │ -│ ├────────────────────────────────────────────────────────────┤ │ -│ │ n = len(entityRefs) │ │ -│ │ entityRef_0 <8b> │ │ -│ │ ... │ │ -│ │ entityRef_n <8b> │ │ -│ └────────────────────────────────────────────────────────────┘ │ -│ . . . │ -└────────────────────────────────────────────────────────────────┘ -``` - -### 6.5 Stream Samples Record - -``` -┌────────────────────────────────────────────────────────────────┐ -│ type = 23 <1b> │ -├────────────────────────────────────────────────────────────────┤ -│ ┌────────────────────────────────────────────────────────────┐ │ -│ │ seriesRef <8b> │ │ -│ │ streamIndex │ │ -│ │ baseTimestamp <8b> │ │ -│ ├────────────────────────────────────────────────────────────┤ │ -│ │ timestamp_delta │ │ -│ │ value <8b> │ │ -│ │ ... │ │ -│ └────────────────────────────────────────────────────────────┘ │ -└────────────────────────────────────────────────────────────────┘ -``` - ---- - -## Part 7: Block Format - -### 7.1 Block Directory Structure - -``` -/ -├── meta.json -├── index -├── chunks/ -│ ├── 000001 -│ ├── 000002 -│ └── ... -├── entities/ # NEW: Entity storage -│ ├── index # Entity index -│ └── snapshots/ # Descriptive label snapshots -│ ├── 000001 -│ └── ... -└── tombstones -``` - -### 7.2 Modified Series Index Format - -``` -Series Entry: -┌──────────────────────────────────────────────────────────────────────────┐ -│ len │ -├──────────────────────────────────────────────────────────────────────────┤ -│ labels count │ -│ ┌──────────────────────────────────────────────────────────────────────┐ │ -│ │ ref(metric_label_name) │ │ -│ │ ref(metric_label_value) │ │ -│ │ ... │ │ -│ └──────────────────────────────────────────────────────────────────────┘ │ -├──────────────────────────────────────────────────────────────────────────┤ -│ streams count │ -│ ┌──────────────────────────────────────────────────────────────────────┐ │ -│ │ Stream 0: │ │ -│ │ entity_refs count │ │ -│ │ entityRef_0 <8b> │ │ -│ │ ... │ │ -│ │ chunks count │ │ -│ │ chunk entries... │ │ -│ ├──────────────────────────────────────────────────────────────────────┤ │ -│ │ Stream 1: │ │ -│ │ ... │ │ -│ └──────────────────────────────────────────────────────────────────────┘ │ -├──────────────────────────────────────────────────────────────────────────┤ -│ CRC32 <4b> │ -└──────────────────────────────────────────────────────────────────────────┘ -``` - -### 7.3 Entity Index Format - -``` -┌────────────────────────────┬─────────────────────┐ -│ magic(0xENT1D700) <4b> │ version(1) <1 byte> │ -├────────────────────────────┴─────────────────────┤ -│ ┌──────────────────────────────────────────────┐ │ -│ │ Symbol Table │ │ -│ ├──────────────────────────────────────────────┤ │ -│ │ Entity Types │ │ -│ ├──────────────────────────────────────────────┤ │ -│ │ Entities │ │ -│ ├──────────────────────────────────────────────┤ │ -│ │ Entity Label Postings │ │ -│ ├──────────────────────────────────────────────┤ │ -│ │ Postings Offset Table │ │ -│ ├──────────────────────────────────────────────┤ │ -│ │ TOC │ │ -│ └──────────────────────────────────────────────┘ │ -└──────────────────────────────────────────────────┘ - -Entity Entry: -┌──────────────────────────────────────────────────────────────────────────┐ -│ entityRef <8b> │ -├──────────────────────────────────────────────────────────────────────────┤ -│ ref(entityType) │ -├──────────────────────────────────────────────────────────────────────────┤ -│ identifyingLabels count │ -│ ┌──────────────────────────────────────────────────────────────────────┐ │ -│ │ ref(label_name) │ │ -│ │ ref(label_value) │ │ -│ │ ... │ │ -│ └──────────────────────────────────────────────────────────────────────┘ │ -├──────────────────────────────────────────────────────────────────────────┤ -│ startTime │ -│ endTime (0 if still alive at block max time) │ -├──────────────────────────────────────────────────────────────────────────┤ -│ snapshot_file_ref (reference to descriptive snapshots) │ -├──────────────────────────────────────────────────────────────────────────┤ -│ CRC32 <4b> │ -└──────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## Part 8: Query Execution - -### 8.1 Query Result Model - -```go -// A query result is now a series with potentially multiple streams -type SeriesResult struct { - Labels labels.Labels // Metric labels - Streams []StreamResult // One per entity combination -} - -type StreamResult struct { - EntityRefs []EntityRef // Which entities this stream is from - Samples []Sample // The actual samples - - // Resolved entity labels (computed at query time) - entityLabels labels.Labels -} -``` - -### 8.2 Entity Label Resolution - -```go -func (q *querier) resolveEntityLabels( - entityRefs []EntityRef, - timestamp int64, -) labels.Labels { - builder := labels.NewBuilder(nil) - - for _, ref := range entityRefs { - entity := q.entities.GetByRef(ref) - if entity == nil { - continue - } - - // Add identifying labels - entity.identifyingLabels.Range(func(l labels.Label) { - builder.Set(l.Name, l.Value) - }) - - // Add descriptive labels at the given timestamp - descriptive := entity.DescriptiveLabelsAt(timestamp) - descriptive.Range(func(l labels.Label) { - builder.Set(l.Name, l.Value) - }) - } - - return builder.Labels() -} -``` - -### 8.3 PromQL Integration - -```go -// When PromQL asks for a vector at time T: -func (q *querier) Select(ctx context.Context, matchers ...*labels.Matcher) storage.SeriesSet { - metricMatchers, entityMatchers := splitMatchers(matchers) - - // Find matching series by metric labels - seriesRefs := q.metricPostings.PostingsForMatchers(ctx, metricMatchers...) - - // Build result set - var results []storage.Series - - for seriesRefs.Next() { - series := q.series.GetByRef(seriesRefs.At()) - - for streamIdx, stream := range series.streams { - // Check if stream's entities match entity matchers - if len(entityMatchers) > 0 { - entityLabels := q.resolveEntityLabels(stream.entityRefs, q.maxTime) - if !matchAll(entityLabels, entityMatchers) { - continue - } - } - - // Create a "virtual series" for this stream - results = append(results, &virtualSeries{ - metricLabels: series.labels, - entityRefs: stream.entityRefs, - chunks: stream.chunks, - querier: q, - }) - } - } - - return newSeriesSet(results) -} - -// virtualSeries represents a single stream as a series -type virtualSeries struct { - metricLabels labels.Labels - entityRefs []EntityRef - chunks []chunks.Meta - querier *querier -} - -func (s *virtualSeries) Labels() labels.Labels { - // Merge metric labels with entity labels - builder := labels.NewBuilder(s.metricLabels) - - entityLabels := s.querier.resolveEntityLabels(s.entityRefs, s.querier.maxTime) - entityLabels.Range(func(l labels.Label) { - builder.Set(l.Name, l.Value) - }) - - return builder.Labels() -} -``` - ---- - -## Part 9: Migration and Compatibility - -### 9.1 Feature Flag - -```yaml -# prometheus.yml -storage: - tsdb: - entity_native_storage: true # Enable new storage model -``` - -### 9.2 Backward Compatibility Mode - -When `entity_native_storage: false` (default): -- Behave exactly like current Prometheus -- All labels treated as metric labels -- Single stream per series - -When `entity_native_storage: true`: -- Entity labels are separated based on configuration/conventions -- Multiple streams per series possible -- Entity storage enabled - -### 9.3 Migration Strategy - -1. **Phase 1: Dual Write** - - New data written in new format - - Old blocks remain readable - - Query merges old and new formats - -2. **Phase 2: Background Conversion** - - Old blocks gradually converted during compaction - - No service interruption - -3. **Phase 3: Full Migration** - - All data in new format - - Old format support can be deprecated - ---- - -## Part 10: Trade-offs and Considerations - -### Benefits - -| Aspect | Improvement | -|--------|-------------| -| **Cardinality** | Series count = metric × metric_label_values (not × entities) | -| **Entity Changes** | Pod restart = new stream, not new series | -| **Storage Efficiency** | Entity labels stored once, not per-series | -| **Query Flexibility** | Natural entity-aware queries | -| **OTel Alignment** | Matches OTLP's resource/metric model | - -### Challenges - -| Aspect | Challenge | Mitigation | -|--------|-----------|------------| -| **Complexity** | Significant codebase changes | Phased rollout, feature flags | -| **Query Performance** | Entity label resolution overhead | Caching, pre-computation | -| **Index Size** | Additional entity postings | Efficient encoding, memory mapping | -| **Compatibility** | Breaking change for remote write | Version negotiation, adapters | - -### Open Questions - -1. **Stream Identity**: Should stream identity be based on sorted entity refs or preserve order? - -2. **Staleness**: Per-stream staleness vs per-series staleness? - -3. **Remote Write**: How to encode streams in the remote write protocol? - -4. **Recording Rules**: How do recording rule results handle entity association? - -5. **Exemplars**: Should exemplars be per-stream or per-series? - ---- diff --git a/proposals/0071-Entity/06-querying.md b/proposals/0071-Entity/06-querying.md index bb6cf148..3f01798f 100644 --- a/proposals/0071-Entity/06-querying.md +++ b/proposals/0071-Entity/06-querying.md @@ -92,42 +92,6 @@ container_cpu_usage_seconds_total{ } 1234.5 ``` -### Enrichment Algorithm - -```go -func (ev *evaluator) enrichSeries( - ctx context.Context, - series storage.Series, - timestamp int64, -) labels.Labels { - originalLabels := series.Labels() - - // 1. Find correlated entities via storage index - entityRefs := ev.entityQuerier.EntitiesForSeries(series.Ref()) - - if len(entityRefs) == 0 { - return originalLabels // No entities, return as-is - } - - // 2. Build enriched label set - builder := labels.NewBuilder(originalLabels) - - for _, entityRef := range entityRefs { - entity := ev.entityQuerier.GetEntity(entityRef) - - // Get descriptive labels at the sample timestamp - descriptiveLabels := entity.DescriptiveLabelsAt(timestamp) - - // Merge into result - descriptiveLabels.Range(func(l labels.Label) { - builder.Set(l.Name, l.Value) - }) - } - - return builder.Labels() -} -``` - --- ## Filtering by Entity Labels @@ -406,90 +370,6 @@ type Entity interface { } ``` -### Parser Changes - -New AST nodes for entity type filtering: - -```go -// EntityTypeFilter represents a pipe expression: vector | entity_type_expr -type EntityTypeFilter struct { - Expr Expr // Left side (vector expression) - EntityTypeExpr EntityTypeExpr // Right side (entity type boolean expression) - PosRange posrange.PositionRange -} - -func (*EntityTypeFilter) Type() ValueType { return ValueTypeVector } - -// EntityTypeExpr is an interface for entity type expressions -type EntityTypeExpr interface { - // Matches returns true if the given set of entity types satisfies this expression - Matches(entityTypes map[string]bool) bool -} - -// EntityTypeName represents a single entity type: k8s.pod -type EntityTypeName struct { - Name string // e.g., "k8s.pod", "service" - Negated bool // true for !k8s.pod -} - -// EntityTypeAnd represents: k8s.pod and k8s.node -type EntityTypeAnd struct { - Left, Right EntityTypeExpr -} - -// EntityTypeOr represents: k8s.pod or service -type EntityTypeOr struct { - Left, Right EntityTypeExpr -} -``` - -### Pipe Operator Evaluation - -```go -func (ev *evaluator) evalEntityTypeFilter( - ctx context.Context, - vector Vector, - typeExpr EntityTypeExpr, -) Vector { - var result Vector - - for _, sample := range vector { - // Get all entity types correlated with this series - seriesEntityRefs := ev.entityQuerier.EntitiesForSeries(sample.SeriesRef) - entityTypes := make(map[string]bool) - for _, ref := range seriesEntityRefs { - entity := ev.entityQuerier.GetEntity(ref) - entityTypes[entity.Type()] = true - } - - // Evaluate the entity type expression - if typeExpr.Matches(entityTypes) { - result = append(result, sample) - } - } - - return result -} - -// Example Matches implementations: - -func (e *EntityTypeName) Matches(types map[string]bool) bool { - has := types[e.Name] - if e.Negated { - return !has - } - return has -} - -func (e *EntityTypeAnd) Matches(types map[string]bool) bool { - return e.Left.Matches(types) && e.Right.Matches(types) -} - -func (e *EntityTypeOr) Matches(types map[string]bool) bool { - return e.Left.Matches(types) || e.Right.Matches(types) -} -``` - ### Query Execution Flow ``` diff --git a/proposals/0071-Entity/08-alerting.md b/proposals/0071-Entity/08-alerting.md index 679ff6c4..84e2c8eb 100644 --- a/proposals/0071-Entity/08-alerting.md +++ b/proposals/0071-Entity/08-alerting.md @@ -217,146 +217,24 @@ The rest of this document assumes Option B as the target design, with notes on O ## Prometheus Implementation -### Tracking Explicit Labels +### How Alert Identity Is Computed -The alerting rule must track which labels were explicitly used in the expression. During rule creation, we parse the expression AST and extract label names from all matchers: +When an alerting rule is created, Prometheus parses the expression AST to extract which labels are explicitly used in matchers. This set of "explicit labels" is stored with the rule. -```go -type AlertingRule struct { - name string - vector parser.Expr - holdDuration time.Duration - labels labels.Labels - annotations labels.Labels - - // Labels explicitly referenced in matchers within the expression - explicitLabels map[string]struct{} - - // Reference to entity store for identifying/descriptive label lookup - entityStore storage.EntityQuerier -} +During alert evaluation, for each result from the query engine: -func NewAlertingRule(name string, expr parser.Expr, ...) *AlertingRule { - rule := &AlertingRule{ - name: name, - vector: expr, - // ... - } - rule.explicitLabels = extractExplicitLabels(expr) - return rule -} +1. **Classify each label** — Determine if it's an identity label or not: + - Metric name → always identity + - Entity identifying labels → always identity + - Labels explicitly used in the expression → identity (user signaled intent) + - Original metric labels (not from entity enrichment) → identity + - Enriched descriptive labels → NOT identity -func extractExplicitLabels(expr parser.Expr) map[string]struct{} { - explicit := make(map[string]struct{}) - - parser.Inspect(expr, func(node parser.Node, _ []parser.Node) error { - if vs, ok := node.(*parser.VectorSelector); ok { - for _, matcher := range vs.LabelMatchers { - if matcher.Name != labels.MetricName { - explicit[matcher.Name] = struct{}{} - } - } - } - return nil - }) - - return explicit -} -``` - -### Computing Identity Labels +2. **Compute identity labels** — Subset of all labels that constitute identity -When evaluating an alert, we separate identity labels from the full enriched label set. The query engine returns enriched results as described in [06-querying.md](./06-querying.md), and we filter them: - -```go -func (r *AlertingRule) computeIdentityLabels(allLabels labels.Labels) labels.Labels { - builder := labels.NewBuilder(nil) - - for _, lbl := range allLabels { - if r.isIdentityLabel(lbl.Name) { - builder.Set(lbl.Name, lbl.Value) - } - } - - return builder.Labels() -} +3. **Track state by identity** — Use `IdentityLabels.Fingerprint()` for the `active` map and `for` clause timing -func (r *AlertingRule) isIdentityLabel(name string) bool { - // Metric name is always part of identity - if name == labels.MetricName { - return true - } - - // Entity identifying labels are always part of identity - if r.entityStore != nil && r.entityStore.IsIdentifyingLabel(name) { - return true - } - - // Descriptive labels that were explicitly filtered are part of identity - if _, explicit := r.explicitLabels[name]; explicit { - return true - } - - // If it's NOT a known entity label, it's an original metric label → identity - if r.entityStore == nil { - return true - } - if !r.entityStore.IsDescriptiveLabel(name) { - return true - } - - // Enriched descriptive label → NOT part of identity - return false -} -``` - -### Alert Evaluation Flow - -The `Eval` method uses identity labels for internal state while tracking full labels for sending: - -```go -func (r *AlertingRule) Eval(ctx context.Context, ts time.Time, ...) (Vector, error) { - // Query engine returns enriched results (see 06-querying.md) - res, err := r.vector.Eval(ctx, ts, ...) - if err != nil { - return nil, err - } - - for _, sample := range res { - // Compute identity labels (subset used for fingerprinting) - identityLabels := r.computeIdentityLabels(sample.Metric) - - // Full labels include everything (for sending to Alertmanager) - fullLabels := sample.Metric - - // Add rule-defined labels to both - for _, l := range r.labels { - identityLabels = append(identityLabels, l) - fullLabels = append(fullLabels, l) - } - - // Look up or create alert using IDENTITY labels for fingerprint - fp := identityLabels.Fingerprint() - alert := r.active[fp] - if alert == nil { - alert = &Alert{ - IdentityLabels: identityLabels, - Labels: fullLabels, - Annotations: r.annotations, - ActiveAt: ts, - } - r.active[fp] = alert - } else { - // Alert exists—update full labels (descriptive may have changed) - alert.Labels = fullLabels - } - - alert.Value = sample.V - } - - // ... rest of evaluation (state transitions, for clause, etc.) -} -``` +4. **Send full labels** — When sending to Alertmanager, include all labels (identity + enriched) ### The Alert Struct