Skip to main content

spate_core/metrics/
meter.rs

1//! [`Meter`], the public instrumentation scope for connector- and
2//! user-owned metric families.
3//!
4//! A `Meter` is bound to one component's three standard labels
5//! (`pipeline`, `component`, `component_type`) and a **namespace**. It mints
6//! `Counter`/`Gauge`/`Histogram` handles that carry those labels
7//! automatically and whose names are auto-prefixed `spate_<namespace>_`, so a
8//! connector's or pipeline author's own series sit under the same `spate_`
9//! umbrella as the framework's and join cleanly in a query.
10//!
11//! You pass the metric's **local name** (`"schema_fetches_total"`, not
12//! `"spate_kafka_schema_fetches_total"`); the `Meter` adds the umbrella and
13//! namespace for you. Operators get one greppable `spate_` root, and there is
14//! no way to typo the prefix or collide with a framework metric.
15//!
16//! The framework's own stages don't use `Meter`; they resolve fixed handle
17//! structs ([`SourceMetrics`](super::SourceMetrics) and friends) directly.
18//! `Meter` is the seam for metrics the framework can't measure from the
19//! outside, such as a connector statistic or a pipeline author's business
20//! counter.
21//!
22//! # Namespaces
23//!
24//! - [`Meter::new`] uses the `custom` namespace → `spate_custom_*`. This is the
25//!   bucket for pipeline-author metrics.
26//! - [`Meter::with_namespace`] takes a segment a connector owns (`"kafka"` →
27//!   `spate_kafka_*`). A connector that can appear at both ends of a pipeline (a
28//!   Kafka source *and* a future Kafka sink) is separated by the runtime into
29//!   `spate_kafka_source_*` / `spate_kafka_sink_*` when it injects the
30//!   component's `Meter`. The role is derived from the source-vs-sink
31//!   position, not set by hand.
32//!
33//! The namespace is validated once, at construction. It must be a lowercase
34//! `[a-z][a-z0-9_]*` segment and must not be one of the framework's reserved
35//! stage roots (`source`, `sink`, …).
36//!
37//! # Where a `Meter` comes from
38//!
39//! - **Pipeline authors** get one from the chain factory's
40//!   [`ChainCtx::meter`](crate::pipeline::ChainCtx::meter) (the `custom`
41//!   namespace) and close the resolved handle over an operator closure
42//!   (`.inspect` / `.map`).
43//! - **Standalone** (a tool, a test, an example) constructs one with
44//!   [`Meter::new`] / [`Meter::with_namespace`].
45//!
46//! # Hot-path discipline
47//!
48//! Resolve every handle **once, at build time**, and touch only the resolved
49//! `Counter`/`Gauge`/`Histogram` on the per-record path. The framework's own
50//! handles follow the same rule, and [the metrics reference] states it for
51//! the taxonomy as a whole. The `Meter::counter` / `gauge` / `histogram`
52//! calls belong in construction, not the record loop.
53//!
54//! [the metrics reference]: https://spate.kainth.dev/docs/METRICS
55
56use super::labels::{ComponentLabels, NamespaceRejection, classify_namespace, validate_namespace};
57use metrics::{Counter, Gauge, Histogram, SharedString};
58
59/// Which end of the pipeline a component sits at, appended to its namespace so
60/// a connector that can be both a source and a sink keeps its families apart
61/// (`spate_kafka_source_*` vs `spate_kafka_sink_*`). The runtime derives it
62/// from the wiring position when it builds a component's
63/// [`Meter`](Meter::for_component), so connector code never names a role.
64#[derive(Clone, Copy, Debug, PartialEq, Eq)]
65pub(crate) enum MetricRole {
66    Source,
67    Sink,
68}
69
70impl MetricRole {
71    fn segment(self) -> &'static str {
72        match self {
73            MetricRole::Source => "source",
74            MetricRole::Sink => "sink",
75        }
76    }
77}
78
79/// An instrumentation scope: one component's three standard labels plus an
80/// `spate_<namespace>_` name prefix applied to every metric it mints.
81///
82/// Cheap to clone; the handles it mints are the `metrics` facade's own
83/// `Arc`-backed handles, safe to clone across pipeline threads. Every clone
84/// feeds the same series.
85///
86/// ```
87/// use spate_core::metrics::Meter;
88///
89/// // Build-time: resolve the handle once. Pass the LOCAL name — the Meter
90/// // prepends `spate_custom_`, so this registers `spate_custom_orders_enriched_total`.
91/// let meter = Meter::new("orders", "enrich", "map");
92/// let enriched = meter.counter("orders_enriched_total", &[("region", "eu".into())]);
93///
94/// // Hot path: touch only the handle, count per batch.
95/// enriched.increment(512);
96/// ```
97#[derive(Clone, Debug)]
98pub struct Meter {
99    labels: ComponentLabels,
100    /// The full `spate_<namespace>_` prefix prepended to every metric name.
101    prefix: String,
102}
103
104impl Meter {
105    /// A scope for **pipeline-author custom metrics**. Names land under the
106    /// `spate_custom_` bucket. `component` is the instance id (e.g. `enrich`);
107    /// `component_type` the implementation label. Reuse the `pipeline` /
108    /// `component` values the framework was given so your series join against
109    /// its.
110    pub fn new(
111        pipeline: impl Into<SharedString>,
112        component: impl Into<SharedString>,
113        component_type: impl Into<SharedString>,
114    ) -> Self {
115        Self::with_namespace(
116            super::names::CUSTOM_NAMESPACE,
117            pipeline,
118            component,
119            component_type,
120        )
121    }
122
123    /// A scope under a specific `spate_<namespace>_` bucket, for a connector
124    /// that owns a segment (e.g. `"kafka"` → `spate_kafka_*`).
125    ///
126    /// # Panics
127    ///
128    /// Panics if `namespace` is empty, is not a lowercase `[a-z][a-z0-9_]*`
129    /// segment, or is one of the framework's reserved stage roots (`source`,
130    /// `sink`, …). This is a construction-time wiring check.
131    pub fn with_namespace(
132        namespace: &str,
133        pipeline: impl Into<SharedString>,
134        component: impl Into<SharedString>,
135        component_type: impl Into<SharedString>,
136    ) -> Self {
137        validate_namespace(namespace);
138        Meter {
139            labels: ComponentLabels::new(pipeline, component, component_type),
140            prefix: format!("{}{namespace}_", super::names::PREFIX),
141        }
142    }
143
144    /// The scope the runtime hands a built-in component (a `Source` or a sink
145    /// `ShardWriter`). The namespace is the component's `component_type`, and
146    /// `role` (derived from the wiring position) is appended, giving
147    /// `spate_<component_type>_<role>_*`. The `component_type` is also the
148    /// standard `component_type` label, so a family joins the component's stage
149    /// metrics.
150    ///
151    /// Returns `None` (rather than panicking, unlike [`Meter::with_namespace`])
152    /// when `component_type` cannot scope a family:
153    ///
154    /// - a **reserved root** (a source's default `"source"`, a sink set to
155    ///   `"sink"`) or the **`custom`** author bucket (a sink's default
156    ///   `component_type`). A silent opt-out, so an undeclared component gets
157    ///   no custom `Meter`. `custom` is reserved for pipeline-author
158    ///   metrics ([`Meter::new`]); a component keeps its families out of that
159    ///   shared bucket by declaring a distinct `component_type`.
160    /// - a **malformed** string (not a `[a-z][a-z0-9_]*` segment). The same
161    ///   `None`, but logged at `warn`. A `component_type` that is a legal label
162    ///   yet an illegal metric-name segment (e.g. `"clickhouse-v2"`) is almost
163    ///   always a wiring mistake that would otherwise drop the component's
164    ///   families silently while its framework stage metrics keep emitting.
165    pub(crate) fn for_component(
166        component_type: &str,
167        role: MetricRole,
168        pipeline: impl Into<SharedString>,
169        component: impl Into<SharedString>,
170    ) -> Option<Meter> {
171        // The `custom` author bucket is off-limits to component scoping, so a
172        // component's families never share the `spate_custom_` space with a
173        // pipeline author's. A silent opt-out, like a reserved root.
174        if component_type == super::names::CUSTOM_NAMESPACE {
175            return None;
176        }
177        match classify_namespace(component_type) {
178            Ok(()) => Some(Meter {
179                labels: ComponentLabels::new(pipeline, component, component_type.to_string()),
180                prefix: format!(
181                    "{}{component_type}_{}_",
182                    super::names::PREFIX,
183                    role.segment()
184                ),
185            }),
186            // A reserved root is a legitimate default (a source's `"source"`).
187            // No `Meter`, and the component's stage metrics stay intact.
188            Err(NamespaceRejection::Reserved) => None,
189            // Empty/malformed is a wiring mistake. Surface it rather than drop
190            // the component's families silently.
191            Err(reason) => {
192                tracing::warn!(
193                    component_type,
194                    role = role.segment(),
195                    reason = reason.reason(),
196                    "component `component_type` is unusable as a metric namespace, \
197                     so this component's custom Meter families are disabled (its \
198                     framework stage metrics are unaffected); use a lowercase \
199                     [a-z][a-z0-9_]* segment to enable them"
200                );
201                None
202            }
203        }
204    }
205
206    /// Fully-qualify a local metric name under this scope's `spate_<namespace>_`
207    /// prefix. Panics if the caller already included an `spate_` prefix. Pass
208    /// the local name only (`"schema_fetches_total"`, not
209    /// `"spate_kafka_schema_fetches_total"`).
210    fn qualify(&self, name: &str) -> SharedString {
211        assert!(
212            !name.starts_with(super::names::PREFIX),
213            "pass the metric's local name without the `{}` prefix — the Meter \
214             adds `{}` for you (got `{name}`)",
215            super::names::PREFIX,
216            self.prefix
217        );
218        // A local name may not lead with a role segment (`source_`/`sink_`).
219        // The runtime injects those to separate a connector's source and sink
220        // families (`spate_<ns>_source_*` / `_sink_*`), so a hand-written
221        // `source_`/`sink_` name could otherwise alias a role-scoped family.
222        let first = name.split('_').next().unwrap_or_default();
223        assert!(
224            !super::names::ROLE_SEGMENTS.contains(&first),
225            "metric local name `{name}` must not begin with the role segment \
226             `{first}_`; the runtime reserves `source_`/`sink_` to scope a \
227             component's source vs sink families"
228        );
229        format!("{}{name}", self.prefix).into()
230    }
231
232    /// Resolve a counter under this scope. `name` is the **local** name
233    /// (auto-prefixed `spate_<namespace>_`); it carries the three standard
234    /// labels, then `extra`. Pass `&[]` for the standard labels alone.
235    /// Build-time only.
236    ///
237    /// Follow the taxonomy rules: `_total` suffix on counters, and push
238    /// per-instance identity (a topic, a shard) into `extra` labels rather
239    /// than the name, keeping the name low-cardinality.
240    pub fn counter(&self, name: &str, extra: &[(&'static str, SharedString)]) -> Counter {
241        self.labels.register_counter(self.qualify(name), extra)
242    }
243
244    /// Resolve a gauge under this scope (local name, auto-prefixed).
245    /// Build-time only.
246    pub fn gauge(&self, name: &str, extra: &[(&'static str, SharedString)]) -> Gauge {
247        self.labels.register_gauge(self.qualify(name), extra)
248    }
249
250    /// Resolve a histogram under this scope (local name, auto-prefixed).
251    /// Build-time only. Give it a unit suffix (`_seconds` / `_bytes` /
252    /// `_rows`).
253    pub fn histogram(&self, name: &str, extra: &[(&'static str, SharedString)]) -> Histogram {
254        self.labels.register_histogram(self.qualify(name), extra)
255    }
256
257    /// The underlying standard-label set, for building a framework stage
258    /// handle from the same scope (e.g.
259    /// [`DeserMetrics::new`](super::DeserMetrics::new)).
260    #[must_use]
261    pub fn labels(&self) -> &ComponentLabels {
262        &self.labels
263    }
264}