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}