From 5a8157f7010a1455d903b54359bcffe39b60e8ea Mon Sep 17 00:00:00 2001 From: Nicolas Takashi Date: Sat, 27 Jun 2026 21:08:34 +0100 Subject: [PATCH 1/3] proposal: Prometheus internal telemetry as an OTel semantic convention registry Signed-off-by: Nicolas Takashi --- proposals/0000-semconv-internal-telemetry.md | 206 +++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 proposals/0000-semconv-internal-telemetry.md diff --git a/proposals/0000-semconv-internal-telemetry.md b/proposals/0000-semconv-internal-telemetry.md new file mode 100644 index 0000000..35e6479 --- /dev/null +++ b/proposals/0000-semconv-internal-telemetry.md @@ -0,0 +1,206 @@ +# Prometheus Internal Telemetry as an OTel Semantic Convention Registry + +* **Owners:** + * Nicolas Takashi [@nicolastakashi](https://github.com/nicolastakashi) [nicolas.takashi@coralogix.com](mailto:nicolas.takashi@coralogix.com) + * Arthur Silva Sens [@ArthurSens](https://github.com/ArthurSens) [arthursens2005@gmail.com](mailto:arthursens2005@gmail.com) + +* **Implementation Status:** `Partially implemented` + +* **Related Issues and PRs:** + * [WIP: Prometheus semconvs](https://github.com/prometheus/prometheus/pull/17868) + * [Weaver: Implement `weaver registry infer` command](https://github.com/open-telemetry/weaver/pull/1138) + +* **Other docs or links:** + * [Dev Summit notes: internal telemetry consensus](https://docs.google.com/document/d/1uurQCi5iVufhYHGlBZ8mJMK_freDFKPG0iYBQqJ9fvA/edit?tab=t.0#bookmark=id.ojugisgspwvq) + * [OpenTelemetry Weaver](https://github.com/open-telemetry/weaver) + * [OTel Semantic Convention specification](https://opentelemetry.io/docs/specs/semconv/) + +> TL;DR: We propose defining all metrics exported by the Prometheus binary as a formal [OTel semantic convention registry](https://opentelemetry.io/docs/specs/semconv/). A machine-readable schema per package serves as the single source of truth, enabling auto-generated instrumentation code, always-up-to-date metric documentation, contract testing against live instances, and a foundation for safe metric evolution across the Prometheus ecosystem. + +## Why + +Prometheus defines its own internal metrics as scattered `prometheus/client_golang` constructor calls across dozens of files. There is no machine-readable description of what Prometheus emits. + +This creates the following concrete problems: + +* Dashboard and alert authors who depend on these metrics have no canonical reference. They reverse-engineer what Prometheus emits by reading Go source or running a live instance. +* There is no contract distinguishing stable public API metrics from internal implementation details. Metrics can be renamed, removed, or semantically changed without versioned signals. +* Documentation, if it exists, is written separately from the code and drifts. There is no mechanism to keep it in sync. +* There is no regression safety. Nothing prevents a code change from silently altering which metrics Prometheus emits or what labels they carry. +* As Prometheus increasingly interoperates with OTel (OTLP ingestion, remote write, collectors), its own telemetry remains outside the OTel schema world and is invisible to tools that understand semantic conventions. + +### Pitfalls of the current solution + +* Metric help strings are inconsistent: some describe counter semantics, others describe the event being counted. +* Histogram bucket configurations are chosen ad hoc with no enforcement. +* Units are absent from most metric definitions. +* No lifecycle model exists: no way to mark a metric as experimental, stable, or deprecated in a way that tooling understands. +* Ecosystem consumers (Thanos, Mimir, Grafana dashboards, alerting rules) have no authoritative reference for what a running Prometheus exposes. + +## Goals + +* [Required] Define Prometheus' internal telemetry as a formal OTel semantic convention registry (`registry.yaml` per package), making it the single machine-readable source of truth for every metric Prometheus exposes. +* [Required] Generate instrumentation code from the registry, eliminating hand-written metric definitions in Go. +* [Required] Generate metric documentation from the registry that cannot drift from the implementation. +* [Nice to have] Enable contract testing via `weaver registry live-check`: validate that a running Prometheus instance emits exactly what the registry declares. +* [Required] Establish a metric lifecycle model using OTel stability levels (`development`, `stable`, `deprecated`) as first-class schema information. +* [Nice to have] Lay the foundation for multi-language instrumentation code generation and ecosystem tooling (dashboards, alerting rules) derived from the same registry. + +### Audience + +Prometheus maintainers and contributors. Operators and SREs who build dashboards and alerts on top of Prometheus' internal metrics. OTel ecosystem tools that consume or validate Prometheus telemetry. + +## Non-Goals + +* Changing existing metric names, label names, or semantics. This is a schema-first refactor of how metrics are defined, not what they measure. +* Adopting the OTel SDK for instrumentation. `prometheus/client_golang` remains the instrumentation layer. +* Migrating exporters or other ecosystem projects (a natural follow-on, out of scope here). +* Publishing the registry as part of OTel upstream semantic conventions (possible long term, not required now). + +## How + +### The registry + +Each package that exposes internal metrics has a `registry.yaml` file that fully describes every metric it owns. The exact layout (one file per package vs. a single file for the whole project) is an open question discussed below. The example below uses a per-package layout: + +```yaml +- id: metric.prometheus_tsdb_compaction_duration_seconds + type: metric + stability: stable + brief: Duration of compaction runs. + metric_name: prometheus_tsdb_compaction_duration_seconds + instrument: histogram + unit: s + annotations: + prometheus: + histogram_type: mixed_histogram + exponential_buckets: {start: 1, factor: 2, count: 14} + bucket_factor: 1.1 + max_bucket_number: 100 + min_reset_duration: "1h" +``` + +This file is the contract. Go code, documentation, and contract tests are all derived from it. The `annotations.prometheus` block carries Prometheus-specific details invisible to OTel (histogram variant, bucket configuration, callback-based gauges, labels fixed at construction time). + +The repository structure is: + +``` +pkg/semconv/ + registry.yaml ← source of truth, hand-authored + metrics.go ← generated, DO NOT EDIT + README.md ← generated, DO NOT EDIT +``` + +The Weaver templates and Rego policies used during generation are not committed to this repository. Where they live is an open question discussed below; the generation step resolves them at build time. + +### Instrumentation code generation + +[OTel Weaver](https://github.com/open-telemetry/weaver) renders `registry.yaml` files into typed Go code via Jinja2 templates. A Makefile target regenerates all `semconv/metrics.go` files across the repository. CI enforces that generated files stay in sync with their registries. + +A metric without labels generates a plain embedded type: + +```go +type PrometheusTSDBCompactionDurationSeconds struct { prometheus.Histogram } + +func NewPrometheusTSDBCompactionDurationSeconds() PrometheusTSDBCompactionDurationSeconds { ... } +``` + +A metric with dynamic labels generates a typed `.With()` method that accepts a sealed per-metric interface, catching incorrect label usage at compile time: + +```go +func (m PrometheusTargetScrapeDurationSeconds) With( + interval IntervalAttr, + extra ...PrometheusTargetScrapeDurationSecondsAttr, +) prometheus.Observer { ... } +``` + +A metric with labels fixed at construction time (`const_labels`) accepts those as typed constructor parameters: + +```go +func NewPrometheusSDDiscoveredTargets(name NameAttr) PrometheusSDDiscoveredTargets { ... } +``` + +A callback-based gauge (`GaugeFunc`) uses `only_opts: true` in the registry, which generates only an `Opts()` function: + +```go +func PrometheusTSDBHeadSeriesOpts() prometheus.GaugeOpts { ... } +// Caller wires in the callback: +prometheus.NewGaugeFunc(semconv.PrometheusTSDBHeadSeriesOpts(), func() float64 { ... }) +``` + +Package code then imports the generated types directly: + +```go +import semconv "github.com/prometheus/prometheus/tsdb/semconv" + +duration: semconv.NewPrometheusTSDBCompactionDurationSeconds(), +``` + +### Documentation generation + +The same Weaver invocation that produces `metrics.go` also produces a `README.md` per package: a complete structured reference of every metric, including name, type, unit, label semantics, stability level, and examples. Because both files are rendered from the same `registry.yaml`, documentation cannot drift from the code. + +### Contract testing + +`weaver registry live-check` validates live OTLP telemetry against a registry. By routing a running Prometheus' metrics through an OTel collector (Prometheus receiver → OTLP exporter) and into `live-check`, we can assert that what Prometheus emits matches the declared schema in terms of metric names, label names, types, and units. + +The exact approach to contract testing is an open question discussed below. + +### Metric lifecycle and evolution + +OTel stability levels are first-class fields in `registry.yaml`: + +* `development`: internal or experimental, may change without notice. +* `stable`: public API, changes require a deprecation cycle. +* `deprecated`: kept for backward compatibility, will be removed in a future major version. + +Weaver's code generator can use these levels to emit deprecation warnings in generated code and to omit deprecated metrics from new generation targets. This gives Prometheus a formalized metric evolution model: a stable metric cannot be removed without a schema change that goes through proposal review, and the schema change is auditable in git history. + +### Rego validation policies + +A set of [OPA Rego](https://www.openpolicyagent.org/docs/latest/policy-language/) policies validates each `registry.yaml` before code generation. Where those policies are hosted is part of the open question on template hosting below. + +* Every `histogram` instrument must declare `annotations.prometheus.histogram_type`. +* Valid values for `histogram_type` are `classic_histogram`, `native_histogram`, `mixed_histogram`, `summary`. +* Classic and mixed histograms must declare `buckets` or `exponential_buckets`. +* Native and mixed histograms must declare `bucket_factor`, `max_bucket_number`, `min_reset_duration`. + +Validation runs as part of the generation step and fails the build if any registry violates the policies. + +### Open questions + +* **`.With()` allocations on hot paths**: The generated `.With()` method allocates a `prometheus.Labels` map on each call. For high-frequency paths such as per-scrape counters or per-sample append metrics, this may be too expensive. A typed `WithX(value string)` fast path should be benchmarked before broad rollout. See the related [reviewer comment](https://github.com/prometheus/prometheus/pull/17868#discussion_r2716984753). + +* **Contract testing coverage**: Many Prometheus metrics are only emitted when specific server state is reached. Compaction metrics require a compaction to be triggered; out-of-order sample counters require out-of-order writes; per-alertmanager metrics require an alertmanager to be configured. A `live-check` run against a basic Prometheus instance will therefore not cover the full registry. Two approaches are possible: (1) build a comprehensive integration test harness that exercises all code paths before running `live-check`; (2) ensure Prometheus initializes all metric vectors at startup even if their values are zero, so the schema can be validated structurally without requiring the underlying events. The right approach needs to be decided. + +* **Registry layout**: The registry could live as one `registry.yaml` per package (co-located with the package it describes, ownership is clear, changes are scoped) or as a single `registry.yaml` at the repository root (one place to look, simpler Weaver invocation, but creates a merge bottleneck and couples all metrics to a single file). The per-package layout matches how Go code is already structured and was used in the proof-of-concept, but a single-file layout may be preferable for cross-cutting concerns like stability level audits or global name uniqueness checks. + +* **Template and policy hosting**: The Jinja2 templates and Rego policies that drive code generation need to live somewhere accessible at build time. Three options are under consideration: (1) in this repository under a `build/` directory, keeping everything self-contained but coupling the templates to the Prometheus server; (2) in `prometheus/client_golang`, making them reusable across the ecosystem; (3) bundled into the Weaver binary itself, which Weaver is actively developing ([weaver#1145](https://github.com/open-telemetry/weaver/pull/1145)) and would remove the hosting question entirely. This decision needs to be made before the migration can be considered stable. + +* **Generated file naming**: Generated files should use a `.gen.go` suffix (e.g. `metrics.gen.go`) to make their nature unambiguous to tooling and contributors. + +* **Package visibility**: Generated packages are currently exported. Making them `internal` would prevent accidental external imports but would complicate any cross-package metric sharing. + +## Alternatives + +### Hand-written schema with a custom linter + +A custom linter enforces naming conventions, unit presence, and histogram type rules on existing `prometheus.NewCounter(...)` definitions, without introducing Weaver or YAML. + +This solution is not chosen because a linter validates code but cannot generate documentation, drive contract tests, or express metric lifecycle. It solves only the consistency problem while leaving the schema-as-contract and ecosystem tooling problems completely unaddressed. It is also more engineering effort to build and maintain than adopting an existing tool. + +### Adopt OTel SDK for instrumentation + +Replace `prometheus/client_golang` with the OTel Go SDK and emit metrics via OTLP natively, using OTel's toolchain end-to-end. + +This solution is not chosen because Prometheus is the reference implementation of the Prometheus data model. Using a different SDK for its own instrumentation would be surprising to contributors and would add a heavyweight dependency. This proposal deliberately keeps `client_golang` as the instrumentation layer and uses Weaver only at the schema and generation layer. The two concerns are separable. + +### Publish registry as upstream OTel semantic conventions + +Contribute `prometheus_*` metric definitions directly to `open-telemetry/semantic-conventions`. + +This solution is not chosen because Prometheus' internal metrics describe Prometheus' own implementation, not a general convention for other software to follow. If the schema matures and becomes relevant for compatible implementations (e.g. Thanos, Mimir), contributing upstream is a natural follow-on. It is not a prerequisite. + +## Action Plan + +To be defined based on community feedback and resolution of the open questions above. From 293d5de9a5232c8c61db444eeb353322c86f4e31 Mon Sep 17 00:00:00 2001 From: Nicolas Takashi Date: Mon, 29 Jun 2026 10:45:50 +0100 Subject: [PATCH 2/3] docs(proposal): fix registry YAML format, example accuracy, gen file naming, and rename to PROM-86 Signed-off-by: Nicolas Takashi --- ....md => 0086-semconv-internal-telemetry.md} | 90 +++++++++++++------ 1 file changed, 64 insertions(+), 26 deletions(-) rename proposals/{0000-semconv-internal-telemetry.md => 0086-semconv-internal-telemetry.md} (79%) diff --git a/proposals/0000-semconv-internal-telemetry.md b/proposals/0086-semconv-internal-telemetry.md similarity index 79% rename from proposals/0000-semconv-internal-telemetry.md rename to proposals/0086-semconv-internal-telemetry.md index 35e6479..a21b963 100644 --- a/proposals/0000-semconv-internal-telemetry.md +++ b/proposals/0086-semconv-internal-telemetry.md @@ -64,20 +64,21 @@ Prometheus maintainers and contributors. Operators and SREs who build dashboards Each package that exposes internal metrics has a `registry.yaml` file that fully describes every metric it owns. The exact layout (one file per package vs. a single file for the whole project) is an open question discussed below. The example below uses a per-package layout: ```yaml -- id: metric.prometheus_tsdb_compaction_duration_seconds - type: metric - stability: stable - brief: Duration of compaction runs. - metric_name: prometheus_tsdb_compaction_duration_seconds - instrument: histogram - unit: s - annotations: - prometheus: - histogram_type: mixed_histogram - exponential_buckets: {start: 1, factor: 2, count: 14} - bucket_factor: 1.1 - max_bucket_number: 100 - min_reset_duration: "1h" +groups: + - id: metric.prometheus_tsdb_compaction_duration_seconds + type: metric + stability: stable + brief: Duration of compaction runs. + metric_name: prometheus_tsdb_compaction_duration_seconds + instrument: histogram + unit: s + annotations: + prometheus: + histogram_type: mixed_histogram + exponential_buckets: {start: 1, factor: 2, count: 14} + bucket_factor: 1.1 + max_bucket_number: 100 + min_reset_duration: "1h" ``` This file is the contract. Go code, documentation, and contract tests are all derived from it. The `annotations.prometheus` block carries Prometheus-specific details invisible to OTel (histogram variant, bucket configuration, callback-based gauges, labels fixed at construction time). @@ -86,16 +87,16 @@ The repository structure is: ``` pkg/semconv/ - registry.yaml ← source of truth, hand-authored - metrics.go ← generated, DO NOT EDIT - README.md ← generated, DO NOT EDIT + registry.yaml ← source of truth, hand-authored + metrics.gen.go ← generated, DO NOT EDIT + README.md ← generated, DO NOT EDIT ``` The Weaver templates and Rego policies used during generation are not committed to this repository. Where they live is an open question discussed below; the generation step resolves them at build time. ### Instrumentation code generation -[OTel Weaver](https://github.com/open-telemetry/weaver) renders `registry.yaml` files into typed Go code via Jinja2 templates. A Makefile target regenerates all `semconv/metrics.go` files across the repository. CI enforces that generated files stay in sync with their registries. +[OTel Weaver](https://github.com/open-telemetry/weaver) renders `registry.yaml` files into typed Go code via Jinja2 templates. A Makefile target regenerates all `semconv/metrics.gen.go` files across the repository. CI enforces that generated files stay in sync with their registries. A metric without labels generates a plain embedded type: @@ -108,9 +109,9 @@ func NewPrometheusTSDBCompactionDurationSeconds() PrometheusTSDBCompactionDurati A metric with dynamic labels generates a typed `.With()` method that accepts a sealed per-metric interface, catching incorrect label usage at compile time: ```go -func (m PrometheusTargetScrapeDurationSeconds) With( +func (m PrometheusTargetIntervalLengthSeconds) With( interval IntervalAttr, - extra ...PrometheusTargetScrapeDurationSecondsAttr, + extra ...PrometheusTargetIntervalLengthSecondsAttr, ) prometheus.Observer { ... } ``` @@ -120,14 +121,51 @@ A metric with labels fixed at construction time (`const_labels`) accepts those a func NewPrometheusSDDiscoveredTargets(name NameAttr) PrometheusSDDiscoveredTargets { ... } ``` -A callback-based gauge (`GaugeFunc`) uses `only_opts: true` in the registry, which generates only an `Opts()` function: +A callback-based gauge (`prometheus.GaugeFunc`) is the one case where generated code cannot own the whole metric. Its value comes from a closure that reads live server state at scrape time, and that closure can only be written by hand in the package — it references runtime objects the generator knows nothing about. For these metrics the registry sets `only_opts: true`: + +```yaml + - id: metric.prometheus_tsdb_head_series + type: metric + stability: development + brief: Total number of series in the head block. + metric_name: prometheus_tsdb_head_series + instrument: gauge + unit: "{series}" + annotations: + prometheus: + only_opts: true # implemented as a GaugeFunc +``` + +Weaver then generates only an `Opts()` accessor carrying the schema-owned name, help, and unit — not a full constructor: ```go -func PrometheusTSDBHeadSeriesOpts() prometheus.GaugeOpts { ... } -// Caller wires in the callback: -prometheus.NewGaugeFunc(semconv.PrometheusTSDBHeadSeriesOpts(), func() float64 { ... }) +func PrometheusTSDBHeadSeriesOpts() prometheus.GaugeOpts { + return prometheus.GaugeOpts{ + Name: "prometheus_tsdb_head_series", + Help: "Total number of series in the head block.", + } +} ``` +The package keeps ownership of the callback and wires it into `NewGaugeFunc`, passing the generated opts in place of the hand-written ones: + +```go +// Before: name and help are written inline, free to drift from any schema. +m.series = prometheus.NewGaugeFunc(prometheus.GaugeOpts{ + Name: "prometheus_tsdb_head_series", + Help: "Total number of series in the head block.", +}, func() float64 { + return float64(h.NumSeries()) +}) + +// After: name, help, and unit come from the registry; only the callback stays in code. +m.series = prometheus.NewGaugeFunc(semconv.PrometheusTSDBHeadSeriesOpts(), func() float64 { + return float64(h.NumSeries()) +}) +``` + +The metric's identity (name, help, unit, stability) is now under schema control, while the runtime value source — the part that genuinely depends on live state — stays in hand-written code. + Package code then imports the generated types directly: ```go @@ -138,7 +176,7 @@ duration: semconv.NewPrometheusTSDBCompactionDurationSeconds(), ### Documentation generation -The same Weaver invocation that produces `metrics.go` also produces a `README.md` per package: a complete structured reference of every metric, including name, type, unit, label semantics, stability level, and examples. Because both files are rendered from the same `registry.yaml`, documentation cannot drift from the code. +The same Weaver invocation that produces `metrics.gen.go` also produces a `README.md` per package: a complete structured reference of every metric, including name, type, unit, label semantics, stability level, and examples. Because both files are rendered from the same `registry.yaml`, documentation cannot drift from the code. ### Contract testing @@ -177,7 +215,7 @@ Validation runs as part of the generation step and fails the build if any regist * **Template and policy hosting**: The Jinja2 templates and Rego policies that drive code generation need to live somewhere accessible at build time. Three options are under consideration: (1) in this repository under a `build/` directory, keeping everything self-contained but coupling the templates to the Prometheus server; (2) in `prometheus/client_golang`, making them reusable across the ecosystem; (3) bundled into the Weaver binary itself, which Weaver is actively developing ([weaver#1145](https://github.com/open-telemetry/weaver/pull/1145)) and would remove the hosting question entirely. This decision needs to be made before the migration can be considered stable. -* **Generated file naming**: Generated files should use a `.gen.go` suffix (e.g. `metrics.gen.go`) to make their nature unambiguous to tooling and contributors. +* **Generated file naming**: This proposal adopts a `.gen.go` suffix (e.g. `metrics.gen.go`) to make the generated nature of these files unambiguous to tooling and contributors. This is open only to the extent that the community may prefer a different convention. * **Package visibility**: Generated packages are currently exported. Making them `internal` would prevent accidental external imports but would complicate any cross-package metric sharing. From 2dac91542918f42500bd052c406d2bb4671a907c Mon Sep 17 00:00:00 2001 From: Nicolas Takashi Date: Mon, 29 Jun 2026 13:03:29 +0100 Subject: [PATCH 3/3] docs(proposal): clarify GaugeFunc only_opts rationale without overclaiming Signed-off-by: Nicolas Takashi --- proposals/0086-semconv-internal-telemetry.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proposals/0086-semconv-internal-telemetry.md b/proposals/0086-semconv-internal-telemetry.md index a21b963..63a9b58 100644 --- a/proposals/0086-semconv-internal-telemetry.md +++ b/proposals/0086-semconv-internal-telemetry.md @@ -121,7 +121,7 @@ A metric with labels fixed at construction time (`const_labels`) accepts those a func NewPrometheusSDDiscoveredTargets(name NameAttr) PrometheusSDDiscoveredTargets { ... } ``` -A callback-based gauge (`prometheus.GaugeFunc`) is the one case where generated code cannot own the whole metric. Its value comes from a closure that reads live server state at scrape time, and that closure can only be written by hand in the package — it references runtime objects the generator knows nothing about. For these metrics the registry sets `only_opts: true`: +A callback-based gauge (`prometheus.GaugeFunc`) is special: its value is pulled from a closure at scrape time rather than `Set()` by the program, so the no-argument constructor that the other instruments get does not fit. For these metrics the registry sets `only_opts: true`: ```yaml - id: metric.prometheus_tsdb_head_series