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}