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