castrum 0.9.2 → 0.9.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +2259 -0
- package/Cargo.toml +138 -0
- package/README.md +6 -0
- package/build.rs +5 -0
- package/castrum.linux-x64-gnu.node +0 -0
- package/dist/index.d.ts +1348 -1295
- package/dist/index.js +4307 -3776
- package/docs/API.md +1 -1
- package/docs/ARCHITECTURE.md +3 -2
- package/docs/CASE_STUDY.md +2 -2
- package/docs/ENVIRONMENT.md +16 -0
- package/docs/FFI_BUN_GUIDE.md +89 -6
- package/docs/REPO_MAP.md +2 -2
- package/index.ts +19 -25
- package/package.json +9 -1
- package/rust/crypto/aead.rs +378 -0
- package/rust/crypto/argon2.rs +265 -0
- package/rust/crypto/base64.rs +420 -0
- package/rust/crypto/bcrypt.rs +89 -0
- package/rust/crypto/cookie_sign.rs +169 -0
- package/rust/crypto/csrf.rs +106 -0
- package/rust/crypto/ed25519.rs +215 -0
- package/rust/crypto/hashing.rs +140 -0
- package/rust/crypto/hmac_sha256.rs +88 -0
- package/rust/crypto/jwt/api.rs +286 -0
- package/rust/crypto/jwt/mod.rs +22 -0
- package/rust/crypto/jwt/tests.rs +312 -0
- package/rust/crypto/jwt/token.rs +360 -0
- package/rust/crypto/mod.rs +27 -0
- package/rust/crypto/pbkdf2.rs +96 -0
- package/rust/crypto/random_token.rs +85 -0
- package/rust/crypto/session.rs +222 -0
- package/rust/ffi/crypto.rs +708 -0
- package/rust/ffi/hashing.rs +285 -0
- package/rust/ffi/http.rs +752 -0
- package/rust/ffi/ingress.rs +303 -0
- package/rust/ffi/json.rs +517 -0
- package/rust/ffi/jwt.rs +419 -0
- package/rust/ffi/metrics.rs +373 -0
- package/rust/ffi/mod.rs +68 -0
- package/rust/ffi/payload.rs +275 -0
- package/rust/ffi/probe.rs +60 -0
- package/rust/ffi/rate_limit.rs +77 -0
- package/rust/ffi/route.rs +85 -0
- package/rust/ffi/tests.rs +2333 -0
- package/rust/ffi/util.rs +152 -0
- package/rust/ffi/validators.rs +123 -0
- package/rust/http/accept.rs +606 -0
- package/rust/http/cookie_parser.rs +140 -0
- package/rust/http/etag.rs +289 -0
- package/rust/http/form.rs +132 -0
- package/rust/http/headers.rs +267 -0
- package/rust/http/http_date.rs +286 -0
- package/rust/http/http_parser.rs +252 -0
- package/rust/http/media_type.rs +355 -0
- package/rust/http/method.rs +200 -0
- package/rust/http/mime_lookup.rs +130 -0
- package/rust/http/mod.rs +32 -0
- package/rust/http/multipart.rs +685 -0
- package/rust/http/query_parser.rs +180 -0
- package/rust/http/url_codec.rs +296 -0
- package/rust/http/url_join.rs +324 -0
- package/rust/ingress/api.rs +355 -0
- package/rust/ingress/cors.rs +403 -0
- package/rust/ingress/ingress_constants.rs +209 -0
- package/rust/ingress/ip_trust.rs +312 -0
- package/rust/ingress/mod.rs +48 -0
- package/rust/ingress/native_route.rs +779 -0
- package/rust/ingress/options.rs +176 -0
- package/rust/ingress/output.rs +406 -0
- package/rust/ingress/packed.rs +191 -0
- package/rust/ingress/pipeline.rs +534 -0
- package/rust/ingress/proxy.rs +138 -0
- package/rust/ingress/rate_limit.rs +538 -0
- package/rust/ingress/terminal.rs +114 -0
- package/rust/ingress/tests.rs +566 -0
- package/rust/ingress/time.rs +70 -0
- package/rust/json/fast_schema/capture.rs +336 -0
- package/rust/json/fast_schema/compile.rs +556 -0
- package/rust/json/fast_schema/cursor.rs +368 -0
- package/rust/json/fast_schema/email.rs +282 -0
- package/rust/json/fast_schema/errors.rs +247 -0
- package/rust/json/fast_schema/mod.rs +57 -0
- package/rust/json/fast_schema/tests.rs +1143 -0
- package/rust/json/fast_schema/types.rs +140 -0
- package/rust/json/fast_schema/validate.rs +931 -0
- package/rust/json/json_ops.rs +121 -0
- package/rust/json/json_schema.rs +837 -0
- package/rust/json/json_ser.rs +881 -0
- package/rust/json/mod.rs +19 -0
- package/rust/json/napi_marshal.rs +318 -0
- package/rust/json/patch/api.rs +60 -0
- package/rust/json/patch/engine.rs +153 -0
- package/rust/json/patch/mod.rs +47 -0
- package/rust/json/patch/ops.rs +270 -0
- package/rust/json/patch/pointer.rs +49 -0
- package/rust/json/patch/tests.rs +499 -0
- package/rust/lib.rs +72 -0
- package/rust/metrics/api.rs +148 -0
- package/rust/metrics/mod.rs +21 -0
- package/rust/metrics/registry.rs +961 -0
- package/rust/panic_safety.rs +40 -0
- package/rust/payload/compress.rs +512 -0
- package/rust/payload/mod.rs +13 -0
- package/rust/payload/sse.rs +214 -0
- package/rust/payload/template.rs +253 -0
- package/rust/payload/websocket.rs +69 -0
- package/rust/payload/ws_frames.rs +354 -0
- package/rust/proptest_suite.rs +139 -0
- package/rust/selection.rs +95 -0
- package/rust/src/bin/native-bench.rs +495 -0
- package/rust/test_support.rs +71 -0
- package/rust/util/batch/api.rs +266 -0
- package/rust/util/batch/core.rs +473 -0
- package/rust/util/batch/mod.rs +23 -0
- package/rust/util/batch/tests.rs +445 -0
- package/rust/util/batch_core.rs +184 -0
- package/rust/util/bytes.rs +407 -0
- package/rust/util/mod.rs +34 -0
- package/rust/util/packed.rs +473 -0
- package/rust/util/text.rs +130 -0
- package/rust/util/threadpool.rs +128 -0
- package/rust/util/validation.rs +419 -0
- package/rust-toolchain.toml +17 -0
- package/scripts/postinstall.mjs +199 -0
- package/src/ingress/constants.ts +1 -1
- package/src/ingress/context.ts +2 -2
- package/src/ingress/decode/baked-result.ts +13 -13
- package/src/ingress/decode/fast-result.ts +17 -17
- package/src/ingress/errors.ts +7 -7
- package/src/ingress/headers/baked-templates.ts +1 -1
- package/src/ingress/headers/fast-templates.ts +3 -3
- package/src/ingress/index.ts +1 -1
- package/src/ingress/native-route.ts +9 -5
- package/src/ingress/options.ts +1 -1
- package/src/ingress/packing/gather-raw-headers.ts +1 -1
- package/src/ingress/packing/route-wire.ts +1 -5
- package/src/ingress/response/error-bodies.ts +5 -5
- package/src/ingress/response/terminal.ts +1 -1
- package/src/ingress/routes/echo.ts +4 -4
- package/src/ingress/routes/fallback.ts +2 -2
- package/src/ingress/routes/head.ts +2 -2
- package/src/ingress/routes/native.ts +2 -1
- package/src/ingress/routes/options.ts +1 -1
- package/src/ingress/routes/read.ts +2 -2
- package/src/ingress/server.ts +10 -6
- package/src/ingress/status.ts +7 -7
- package/src/integration/batch.ts +1 -1
- package/src/integration/index.ts +9 -9
- package/src/integration/pipeline.ts +6 -6
- package/src/integration/websocket.ts +2 -2
- package/src/loader/index.ts +1 -5
- package/src/loader/ops.ts +1 -1
- package/src/native/ffi/build/codecs.ts +104 -8
- package/src/native/ffi/build/compress.ts +14 -10
- package/src/native/ffi/build/instances.ts +40 -3
- package/src/native/ffi/build/metrics.ts +224 -0
- package/src/native/ffi/build/parse.ts +59 -23
- package/src/native/ffi/build/util.ts +14 -1
- package/src/native/ffi/build.ts +2 -0
- package/src/native/ffi/selftest.ts +10 -3
- package/src/native/ffi/types.ts +111 -9
- package/src/native/ffi.ts +87 -1
- package/src/native/index.ts +1 -1
- package/src/native/loader.ts +2 -2
- package/src/native/types/instances.ts +34 -0
- package/src/native/types.ts +11 -0
- package/src/runtime/codec.ts +53 -9
- package/src/runtime/types.ts +7 -0
- package/src/rust-ffi/batch/build.ts +16 -9
- package/src/rust-ffi/index.ts +7 -6
- package/src/rust-ffi/options.ts +1 -3
- package/src/rust-ffi/proven.ts +3 -3
- package/src/rust-ffi/scalar/crypto.ts +1 -1
- package/src/rust-ffi/scalar/factories.ts +73 -0
- package/src/rust-ffi/scalar/hashing.ts +1 -1
- package/src/rust-ffi/scalar/http.ts +17 -11
- package/src/rust-ffi/scalar/index.ts +2 -0
- package/src/rust-ffi/scalar/interface.ts +20 -0
- package/src/rust-ffi/scalar/payload.ts +11 -3
- package/src/rust-ffi/scalar/validation.ts +121 -0
- package/src/selection.ts +1 -1
- package/src/shared/bytes.ts +5 -4
- package/src/shared/codec.ts +18 -3
|
@@ -0,0 +1,961 @@
|
|
|
1
|
+
//! Sharded metrics registry (counters / gauges / histograms) + Prometheus
|
|
2
|
+
//! text-format render. Pure core — no napi types.
|
|
3
|
+
//!
|
|
4
|
+
//! Hot path (`add`/`set`): validate → build the packed series key in a reused
|
|
5
|
+
//! buffer → one shard-mutex acquire → update. The key is
|
|
6
|
+
//! `family_u32_le \0 raw_label_bytes`, so the render pass can attribute each
|
|
7
|
+
//! entry to its family without re-parsing label structure; raw bytes are safe
|
|
8
|
+
//! as a grouping key because render-time escaping is injective.
|
|
9
|
+
|
|
10
|
+
use std::sync::{Arc, Mutex};
|
|
11
|
+
|
|
12
|
+
/// A declared metric family kind.
|
|
13
|
+
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
14
|
+
pub enum MetricsKind {
|
|
15
|
+
/// Monotonic counter (`add` only).
|
|
16
|
+
Counter,
|
|
17
|
+
/// Arbitrary gauge (`set` / `add`).
|
|
18
|
+
Gauge,
|
|
19
|
+
/// Cumulative histogram with fixed buckets (observe via `add`).
|
|
20
|
+
Histogram,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/// Hard cap on families per registry (a typo loop should fail fast, not OOM).
|
|
24
|
+
pub const METRICS_MAX_FAMILIES: usize = 4096;
|
|
25
|
+
/// Shard count for the per-label-set state (power of two; one mutex acquire
|
|
26
|
+
/// per hot-path call — same contention profile as the rate limiter's shards).
|
|
27
|
+
pub const METRICS_SHARDS: usize = 64;
|
|
28
|
+
/// Max labels per family.
|
|
29
|
+
pub const METRICS_MAX_LABELS: usize = 16;
|
|
30
|
+
/// Max histogram buckets per family (plus the implicit `+Inf` bucket).
|
|
31
|
+
pub const METRICS_MAX_BUCKETS: usize = 64;
|
|
32
|
+
/// Separator between packed label VALUES on the C-ABI hot path.
|
|
33
|
+
pub const METRICS_VALUE_SEP: u8 = 0x1f;
|
|
34
|
+
|
|
35
|
+
/// Default latency-style buckets when a histogram is declared without any.
|
|
36
|
+
pub const METRICS_DEFAULT_BUCKETS: &[f64] = &[
|
|
37
|
+
0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/// A declared family: validated name/kind plus the metadata needed to build
|
|
41
|
+
/// series keys and render. `Arc`s keep the per-record metadata snapshot cheap
|
|
42
|
+
/// (the families lock is held only to clone these handles).
|
|
43
|
+
struct Family {
|
|
44
|
+
name: Arc<str>,
|
|
45
|
+
kind: MetricsKind,
|
|
46
|
+
label_keys: Arc<[Arc<str>]>,
|
|
47
|
+
buckets: Arc<[f64]>,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/// Per-label-set state. Scalars are one f64 (Prometheus samples are doubles);
|
|
51
|
+
/// histograms accumulate per-bucket counts (cumulative upper bounds) + sum.
|
|
52
|
+
enum Value {
|
|
53
|
+
Scalar(f64),
|
|
54
|
+
Hist {
|
|
55
|
+
counts: Vec<u64>,
|
|
56
|
+
sum: f64,
|
|
57
|
+
count: u64,
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
impl Value {
|
|
62
|
+
fn new_for(kind: MetricsKind, buckets: &[f64]) -> Self {
|
|
63
|
+
match kind {
|
|
64
|
+
MetricsKind::Histogram => Value::Hist {
|
|
65
|
+
counts: vec![0; buckets.len()],
|
|
66
|
+
sum: 0.0,
|
|
67
|
+
count: 0,
|
|
68
|
+
},
|
|
69
|
+
_ => Value::Scalar(0.0),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// The registry. Cheap to construct; shareable across threads.
|
|
75
|
+
pub struct MetricsRegistry {
|
|
76
|
+
families: Mutex<Vec<Family>>,
|
|
77
|
+
shards: Vec<Mutex<std::collections::HashMap<Vec<u8>, Value>>>,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
#[inline]
|
|
81
|
+
fn valid_metric_ident(bytes: &[u8]) -> bool {
|
|
82
|
+
if bytes.is_empty() || bytes.len() > 256 {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
let first = bytes[0];
|
|
86
|
+
if !(first.is_ascii_alphabetic() || first == b'_' || first == b':') {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
bytes[1..]
|
|
90
|
+
.iter()
|
|
91
|
+
.all(|&c| c.is_ascii_alphanumeric() || c == b'_' || c == b':')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
#[inline]
|
|
95
|
+
fn finite(v: f64) -> bool {
|
|
96
|
+
v.is_finite()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/// Locate the bucket whose cumulative upper bound is the first `>= v`.
|
|
100
|
+
/// Returns `buckets.len()` for above-the-last-bucket values (+Inf bucket).
|
|
101
|
+
#[inline]
|
|
102
|
+
fn bucket_index(buckets: &[f64], v: f64) -> usize {
|
|
103
|
+
buckets.partition_point(|&b| b < v)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/// Snapshot of one family's declaration (cheap Arc clones; released before
|
|
107
|
+
/// the shard lock is taken).
|
|
108
|
+
type FamilySnapshot = (Arc<str>, MetricsKind, Arc<[Arc<str>]>, Arc<[f64]>);
|
|
109
|
+
|
|
110
|
+
impl MetricsRegistry {
|
|
111
|
+
/// Create an empty registry with [`METRICS_SHARDS`] shards.
|
|
112
|
+
pub fn new() -> Self {
|
|
113
|
+
let shards = (0..METRICS_SHARDS)
|
|
114
|
+
.map(|_| Mutex::new(std::collections::HashMap::new()))
|
|
115
|
+
.collect();
|
|
116
|
+
MetricsRegistry {
|
|
117
|
+
families: Mutex::new(Vec::new()),
|
|
118
|
+
shards,
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/// Declare a family and return its `u32` series id. Declaring the same
|
|
123
|
+
/// `(name, kind, label keys)` twice is idempotent (same id); a name reused
|
|
124
|
+
/// with a different shape is an error (Prometheus type-conflict class).
|
|
125
|
+
///
|
|
126
|
+
/// `buckets` is only read for histograms (empty → the default buckets).
|
|
127
|
+
pub fn declare(
|
|
128
|
+
&self,
|
|
129
|
+
kind: MetricsKind,
|
|
130
|
+
name: &str,
|
|
131
|
+
label_keys: &[&str],
|
|
132
|
+
buckets: &[f64],
|
|
133
|
+
) -> Result<u32, String> {
|
|
134
|
+
if !valid_metric_ident(name.as_bytes()) {
|
|
135
|
+
return Err(format!("invalid metric name: {name:?}"));
|
|
136
|
+
}
|
|
137
|
+
if label_keys.len() > METRICS_MAX_LABELS {
|
|
138
|
+
return Err(format!("too many labels ({})", label_keys.len()));
|
|
139
|
+
}
|
|
140
|
+
for k in label_keys {
|
|
141
|
+
if !valid_metric_ident(k.as_bytes()) {
|
|
142
|
+
return Err(format!("invalid label name: {k:?}"));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
let mut sorted_buckets: Vec<f64> = if kind == MetricsKind::Histogram && buckets.is_empty() {
|
|
146
|
+
METRICS_DEFAULT_BUCKETS.to_vec()
|
|
147
|
+
} else {
|
|
148
|
+
buckets.to_vec()
|
|
149
|
+
};
|
|
150
|
+
if kind == MetricsKind::Histogram {
|
|
151
|
+
if sorted_buckets.len() > METRICS_MAX_BUCKETS {
|
|
152
|
+
return Err(format!("too many buckets ({})", sorted_buckets.len()));
|
|
153
|
+
}
|
|
154
|
+
if sorted_buckets.iter().any(|b| !finite(*b) || *b <= 0.0) {
|
|
155
|
+
return Err("histogram buckets must be finite and > 0".to_string());
|
|
156
|
+
}
|
|
157
|
+
sorted_buckets.sort_by(|a, b| a.partial_cmp(b).expect("all finite"));
|
|
158
|
+
sorted_buckets.dedup();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let mut families = self.families.lock().expect("families lock");
|
|
162
|
+
for (idx, fam) in families.iter().enumerate() {
|
|
163
|
+
if &*fam.name == name {
|
|
164
|
+
let same_shape = fam.kind == kind
|
|
165
|
+
&& fam.label_keys.len() == label_keys.len()
|
|
166
|
+
&& fam
|
|
167
|
+
.label_keys
|
|
168
|
+
.iter()
|
|
169
|
+
.zip(label_keys.iter())
|
|
170
|
+
.all(|(a, b)| &**a == *b)
|
|
171
|
+
&& (kind != MetricsKind::Histogram
|
|
172
|
+
|| fam.buckets.as_ref() == sorted_buckets.as_slice());
|
|
173
|
+
return if same_shape {
|
|
174
|
+
Ok(idx as u32)
|
|
175
|
+
} else {
|
|
176
|
+
Err(format!(
|
|
177
|
+
"metric {name:?} re-declared with a different shape"
|
|
178
|
+
))
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
let idx = families.len();
|
|
183
|
+
if idx >= METRICS_MAX_FAMILIES {
|
|
184
|
+
return Err(format!(
|
|
185
|
+
"registry full (max {METRICS_MAX_FAMILIES} families)"
|
|
186
|
+
));
|
|
187
|
+
}
|
|
188
|
+
families.push(Family {
|
|
189
|
+
name: Arc::from(name),
|
|
190
|
+
kind,
|
|
191
|
+
label_keys: label_keys.iter().map(|k| Arc::from(*k)).collect(),
|
|
192
|
+
buckets: sorted_buckets.into(),
|
|
193
|
+
});
|
|
194
|
+
Ok(idx as u32)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/// Snapshot the family metadata (cheap Arc clones; releases the lock).
|
|
198
|
+
#[inline]
|
|
199
|
+
fn family(&self, series: u32) -> std::result::Result<FamilySnapshot, String> {
|
|
200
|
+
let families = self.families.lock().expect("families lock");
|
|
201
|
+
let fam = families
|
|
202
|
+
.get(series as usize)
|
|
203
|
+
.ok_or_else(|| format!("unknown series id {series}"))?;
|
|
204
|
+
Ok((
|
|
205
|
+
Arc::clone(&fam.name),
|
|
206
|
+
fam.kind,
|
|
207
|
+
Arc::clone(&fam.label_keys),
|
|
208
|
+
Arc::clone(&fam.buckets),
|
|
209
|
+
))
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/// Split the packed `\x1f`-separated label values and validate arity +
|
|
213
|
+
/// forbidden bytes (NUL would corrupt the map key; the separator itself
|
|
214
|
+
/// cannot be embedded in a single value by construction).
|
|
215
|
+
#[inline]
|
|
216
|
+
fn validate_values(packed: &[u8], n_labels: usize) -> Result<(), String> {
|
|
217
|
+
if packed.contains(&0) {
|
|
218
|
+
return Err("label values must not contain NUL".to_string());
|
|
219
|
+
}
|
|
220
|
+
let mut count = usize::from(!packed.is_empty());
|
|
221
|
+
for &b in packed {
|
|
222
|
+
if b == METRICS_VALUE_SEP {
|
|
223
|
+
count += 1;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// An empty packed buffer is valid ONLY for zero-label families; an
|
|
227
|
+
// empty value among several ("a=\x1fb") still counts via separators.
|
|
228
|
+
if n_labels == 0 && packed.is_empty() {
|
|
229
|
+
return Ok(());
|
|
230
|
+
}
|
|
231
|
+
if count != n_labels {
|
|
232
|
+
return Err(format!("expected {n_labels} label value(s), got {count}"));
|
|
233
|
+
}
|
|
234
|
+
Ok(())
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/// Build the sharded-map key into `out`: `family_u32_le \0 v1 \x1f v2 ...`.
|
|
238
|
+
#[inline]
|
|
239
|
+
fn build_key(out: &mut Vec<u8>, series: u32, packed_values: &[u8]) {
|
|
240
|
+
out.clear();
|
|
241
|
+
out.extend_from_slice(&series.to_le_bytes());
|
|
242
|
+
out.push(0);
|
|
243
|
+
out.extend_from_slice(packed_values);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
#[inline]
|
|
247
|
+
fn shard_for(key: &[u8]) -> usize {
|
|
248
|
+
// crate::crypto::hashing::fnv1a64 is the repo's standard non-crypto hash.
|
|
249
|
+
(crate::crypto::hashing::fnv1a64_bytes(key) as usize) & (METRICS_SHARDS - 1)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/// Counter/gauge `+= amount`, or histogram observe `amount`. Counters and
|
|
253
|
+
/// gauges accept any finite amount; histograms require `>= 0`
|
|
254
|
+
/// (Prometheus observes non-negative quantities).
|
|
255
|
+
pub fn add(&self, series: u32, packed_values: &[u8], amount: f64) -> Result<(), String> {
|
|
256
|
+
if !finite(amount) {
|
|
257
|
+
return Err("amount must be finite".to_string());
|
|
258
|
+
}
|
|
259
|
+
let (_name, kind, label_keys, buckets) = self.family(series)?;
|
|
260
|
+
Self::validate_values(packed_values, label_keys.len())?;
|
|
261
|
+
let mut key = Vec::with_capacity(5 + packed_values.len());
|
|
262
|
+
Self::build_key(&mut key, series, packed_values);
|
|
263
|
+
let shard = &self.shards[Self::shard_for(&key)];
|
|
264
|
+
let mut map = shard.lock().expect("shard lock");
|
|
265
|
+
let value = match map.get_mut(&key) {
|
|
266
|
+
Some(v) => v,
|
|
267
|
+
None => map
|
|
268
|
+
.entry(key.clone())
|
|
269
|
+
.or_insert_with(|| Value::new_for(kind, &buckets)),
|
|
270
|
+
};
|
|
271
|
+
match value {
|
|
272
|
+
Value::Scalar(s) => *s += amount,
|
|
273
|
+
Value::Hist { counts, sum, count } => {
|
|
274
|
+
if amount < 0.0 {
|
|
275
|
+
return Err("histogram observations must be >= 0".to_string());
|
|
276
|
+
}
|
|
277
|
+
let idx = bucket_index(&buckets, amount);
|
|
278
|
+
if let Some(c) = counts.get_mut(idx) {
|
|
279
|
+
*c += 1;
|
|
280
|
+
}
|
|
281
|
+
*sum += amount;
|
|
282
|
+
*count += 1;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
Ok(())
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/// Gauge assignment (`= value`); errors on counter/histogram families.
|
|
289
|
+
pub fn set(&self, series: u32, packed_values: &[u8], value: f64) -> Result<(), String> {
|
|
290
|
+
if !finite(value) {
|
|
291
|
+
return Err("value must be finite".to_string());
|
|
292
|
+
}
|
|
293
|
+
let (_name, kind, label_keys, _buckets) = self.family(series)?;
|
|
294
|
+
if kind != MetricsKind::Gauge {
|
|
295
|
+
return Err("set() is only valid on gauges".to_string());
|
|
296
|
+
}
|
|
297
|
+
Self::validate_values(packed_values, label_keys.len())?;
|
|
298
|
+
let mut key = Vec::with_capacity(5 + packed_values.len());
|
|
299
|
+
Self::build_key(&mut key, series, packed_values);
|
|
300
|
+
let shard = &self.shards[Self::shard_for(&key)];
|
|
301
|
+
let mut map = shard.lock().expect("shard lock");
|
|
302
|
+
let v = match map.get_mut(&key) {
|
|
303
|
+
Some(v) => v,
|
|
304
|
+
None => map.entry(key.clone()).or_insert(Value::Scalar(value)),
|
|
305
|
+
};
|
|
306
|
+
if let Value::Scalar(s) = v {
|
|
307
|
+
*s = value;
|
|
308
|
+
}
|
|
309
|
+
Ok(())
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/// Number of live series across all shards (tests / introspection).
|
|
313
|
+
pub fn series_count(&self) -> usize {
|
|
314
|
+
self.shards
|
|
315
|
+
.iter()
|
|
316
|
+
.map(|s| s.lock().expect("shard lock").len())
|
|
317
|
+
.sum()
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/// Render the Prometheus text format (deterministic: declaration order per
|
|
321
|
+
/// family, series sorted by raw label bytes). Output bytes are ASCII
|
|
322
|
+
/// scaffolding + raw label-value bytes; invalid UTF-8 in values becomes a
|
|
323
|
+
/// replacement char at the JS decode boundary.
|
|
324
|
+
pub fn render_into(&self, out: &mut Vec<u8>) {
|
|
325
|
+
struct Row {
|
|
326
|
+
family: u32,
|
|
327
|
+
labels: Vec<u8>,
|
|
328
|
+
value: Value,
|
|
329
|
+
}
|
|
330
|
+
let mut rows: Vec<Row> = Vec::new();
|
|
331
|
+
for shard in &self.shards {
|
|
332
|
+
let map = shard.lock().expect("shard lock");
|
|
333
|
+
for (k, v) in map.iter() {
|
|
334
|
+
rows.push(Row {
|
|
335
|
+
family: u32::from_le_bytes([k[0], k[1], k[2], k[3]]),
|
|
336
|
+
labels: k[5..].to_vec(),
|
|
337
|
+
value: match v {
|
|
338
|
+
Value::Scalar(s) => Value::Scalar(*s),
|
|
339
|
+
Value::Hist { counts, sum, count } => Value::Hist {
|
|
340
|
+
counts: counts.clone(),
|
|
341
|
+
sum: *sum,
|
|
342
|
+
count: *count,
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
rows.sort_by(|a, b| {
|
|
349
|
+
a.family
|
|
350
|
+
.cmp(&b.family)
|
|
351
|
+
.then_with(|| a.labels.cmp(&b.labels))
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
let families = self.families.lock().expect("families lock");
|
|
355
|
+
let mut row_iter = rows.into_iter().peekable();
|
|
356
|
+
for (idx, fam) in families.iter().enumerate() {
|
|
357
|
+
let kind_str = match fam.kind {
|
|
358
|
+
MetricsKind::Counter => "counter",
|
|
359
|
+
MetricsKind::Gauge => "gauge",
|
|
360
|
+
MetricsKind::Histogram => "histogram",
|
|
361
|
+
};
|
|
362
|
+
out.extend_from_slice(b"# TYPE ");
|
|
363
|
+
out.extend_from_slice(fam.name.as_bytes());
|
|
364
|
+
out.push(b' ');
|
|
365
|
+
out.extend_from_slice(kind_str.as_bytes());
|
|
366
|
+
out.push(b'\n');
|
|
367
|
+
|
|
368
|
+
while let Some(row) = row_iter.peek() {
|
|
369
|
+
if row.family != idx as u32 {
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
let row = row_iter.next().expect("peeked");
|
|
373
|
+
match row.value {
|
|
374
|
+
Value::Scalar(s) => {
|
|
375
|
+
out.extend_from_slice(fam.name.as_bytes());
|
|
376
|
+
write_labels(out, &fam.label_keys, &row.labels, None);
|
|
377
|
+
out.push(b' ');
|
|
378
|
+
write_f64(out, s);
|
|
379
|
+
out.push(b'\n');
|
|
380
|
+
}
|
|
381
|
+
Value::Hist { counts, sum, count } => {
|
|
382
|
+
let mut cumulative: u64 = 0;
|
|
383
|
+
for (i, &le) in fam.buckets.iter().enumerate() {
|
|
384
|
+
cumulative += counts.get(i).copied().unwrap_or(0);
|
|
385
|
+
out.extend_from_slice(fam.name.as_bytes());
|
|
386
|
+
out.extend_from_slice(b"_bucket");
|
|
387
|
+
let mut le_buf = Vec::new();
|
|
388
|
+
write_f64(&mut le_buf, le);
|
|
389
|
+
write_labels(out, &fam.label_keys, &row.labels, Some((b"le", &le_buf)));
|
|
390
|
+
out.push(b' ');
|
|
391
|
+
write_u64(out, cumulative);
|
|
392
|
+
out.push(b'\n');
|
|
393
|
+
}
|
|
394
|
+
out.extend_from_slice(fam.name.as_bytes());
|
|
395
|
+
out.extend_from_slice(b"_bucket");
|
|
396
|
+
write_labels(out, &fam.label_keys, &row.labels, Some((b"le", b"+Inf")));
|
|
397
|
+
out.push(b' ');
|
|
398
|
+
write_u64(out, count);
|
|
399
|
+
out.push(b'\n');
|
|
400
|
+
|
|
401
|
+
out.extend_from_slice(fam.name.as_bytes());
|
|
402
|
+
out.extend_from_slice(b"_sum");
|
|
403
|
+
write_labels(out, &fam.label_keys, &row.labels, None);
|
|
404
|
+
out.push(b' ');
|
|
405
|
+
write_f64(out, sum);
|
|
406
|
+
out.push(b'\n');
|
|
407
|
+
|
|
408
|
+
out.extend_from_slice(fam.name.as_bytes());
|
|
409
|
+
out.extend_from_slice(b"_count");
|
|
410
|
+
write_labels(out, &fam.label_keys, &row.labels, None);
|
|
411
|
+
out.push(b' ');
|
|
412
|
+
write_u64(out, count);
|
|
413
|
+
out.push(b'\n');
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/// Dump every live series in the packed snapshot format (v1):
|
|
421
|
+
///
|
|
422
|
+
/// `[u32 version=1][u32 familyCount]` then per family
|
|
423
|
+
/// `[u32 familyId][u8 kind][u32 nameLen][name][u32 keysLen][keys \x1f-joined]
|
|
424
|
+
/// [u32 nBuckets]{f64 le}` then `[u32 seriesCount]` and per series
|
|
425
|
+
/// `[u32 familyId][u32 valsLen][values \x1f-joined]` followed by
|
|
426
|
+
/// `f64 value` (counter/gauge) or `f64 sum + u64 count + u64[nBuckets]`
|
|
427
|
+
/// raw per-bucket counts (histogram). All integers/f64 little-endian.
|
|
428
|
+
///
|
|
429
|
+
/// This is the machine-readable read path (OTLP exporters, tests) — the
|
|
430
|
+
/// text render stays the Prometheus wire format. Families come first so a
|
|
431
|
+
/// decoder can resolve names/kind/bucket bounds while walking series;
|
|
432
|
+
/// series are grouped by family in declaration order, labels sorted by raw
|
|
433
|
+
/// bytes within a family (deterministic).
|
|
434
|
+
pub fn snapshot_into(&self, out: &mut Vec<u8>) {
|
|
435
|
+
// Cold path: clone rows out of the shards (values are small), sort,
|
|
436
|
+
// emit. Deterministic order mirrors render_into.
|
|
437
|
+
let mut rows: Vec<(u32, Vec<u8>, Value)> = Vec::new();
|
|
438
|
+
for shard in &self.shards {
|
|
439
|
+
let map = shard.lock().expect("shard lock");
|
|
440
|
+
for (k, v) in map.iter() {
|
|
441
|
+
rows.push((
|
|
442
|
+
u32::from_le_bytes([k[0], k[1], k[2], k[3]]),
|
|
443
|
+
k[5..].to_vec(),
|
|
444
|
+
match v {
|
|
445
|
+
Value::Scalar(s) => Value::Scalar(*s),
|
|
446
|
+
Value::Hist { counts, sum, count } => Value::Hist {
|
|
447
|
+
counts: counts.clone(),
|
|
448
|
+
sum: *sum,
|
|
449
|
+
count: *count,
|
|
450
|
+
},
|
|
451
|
+
},
|
|
452
|
+
));
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
rows.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
|
|
456
|
+
|
|
457
|
+
out.extend_from_slice(&1u32.to_le_bytes());
|
|
458
|
+
let families = self.families.lock().expect("families lock");
|
|
459
|
+
out.extend_from_slice(&(families.len() as u32).to_le_bytes());
|
|
460
|
+
for (id, fam) in families.iter().enumerate() {
|
|
461
|
+
out.extend_from_slice(&(id as u32).to_le_bytes());
|
|
462
|
+
out.push(match fam.kind {
|
|
463
|
+
MetricsKind::Counter => 0,
|
|
464
|
+
MetricsKind::Gauge => 1,
|
|
465
|
+
MetricsKind::Histogram => 2,
|
|
466
|
+
});
|
|
467
|
+
out.extend_from_slice(&(fam.name.len() as u32).to_le_bytes());
|
|
468
|
+
out.extend_from_slice(fam.name.as_bytes());
|
|
469
|
+
let keys_joined = fam
|
|
470
|
+
.label_keys
|
|
471
|
+
.iter()
|
|
472
|
+
.map(|k| k.as_bytes())
|
|
473
|
+
.collect::<Vec<_>>()
|
|
474
|
+
.join(&METRICS_VALUE_SEP);
|
|
475
|
+
out.extend_from_slice(&(keys_joined.len() as u32).to_le_bytes());
|
|
476
|
+
out.extend_from_slice(&keys_joined);
|
|
477
|
+
out.extend_from_slice(&(fam.buckets.len() as u32).to_le_bytes());
|
|
478
|
+
for &le in fam.buckets.iter() {
|
|
479
|
+
out.extend_from_slice(&le.to_le_bytes());
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
out.extend_from_slice(&(rows.len() as u32).to_le_bytes());
|
|
483
|
+
for (family, labels, value) in &rows {
|
|
484
|
+
out.extend_from_slice(&family.to_le_bytes());
|
|
485
|
+
out.extend_from_slice(&(labels.len() as u32).to_le_bytes());
|
|
486
|
+
out.extend_from_slice(labels);
|
|
487
|
+
match value {
|
|
488
|
+
Value::Scalar(v) => out.extend_from_slice(&v.to_le_bytes()),
|
|
489
|
+
Value::Hist { counts, sum, count } => {
|
|
490
|
+
out.extend_from_slice(&sum.to_le_bytes());
|
|
491
|
+
out.extend_from_slice(&count.to_le_bytes());
|
|
492
|
+
for c in counts {
|
|
493
|
+
out.extend_from_slice(&c.to_le_bytes());
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/// Record N events in ONE lock-per-series pass. Packed layout:
|
|
501
|
+
/// `[u32 n]{[u32 series][u32 valsLen][vals][f64 amount]}`. Errors on any
|
|
502
|
+
/// unknown series / arity mismatch / invalid amount (all-or-nothing is NOT
|
|
503
|
+
/// guaranteed — entries before a failure stay applied).
|
|
504
|
+
pub fn record_batch(&self, packed: &[u8]) -> Result<(), String> {
|
|
505
|
+
if packed.len() < 4 {
|
|
506
|
+
return Err("batch too small".to_string());
|
|
507
|
+
}
|
|
508
|
+
let n = u32::from_le_bytes([packed[0], packed[1], packed[2], packed[3]]) as usize;
|
|
509
|
+
let mut pos = 4usize;
|
|
510
|
+
for _ in 0..n {
|
|
511
|
+
if pos + 8 > packed.len() {
|
|
512
|
+
return Err("truncated batch entry".to_string());
|
|
513
|
+
}
|
|
514
|
+
let series = u32::from_le_bytes([
|
|
515
|
+
packed[pos],
|
|
516
|
+
packed[pos + 1],
|
|
517
|
+
packed[pos + 2],
|
|
518
|
+
packed[pos + 3],
|
|
519
|
+
]);
|
|
520
|
+
pos += 4;
|
|
521
|
+
let vlen = u32::from_le_bytes([
|
|
522
|
+
packed[pos],
|
|
523
|
+
packed[pos + 1],
|
|
524
|
+
packed[pos + 2],
|
|
525
|
+
packed[pos + 3],
|
|
526
|
+
]) as usize;
|
|
527
|
+
pos += 4;
|
|
528
|
+
if pos + vlen + 8 > packed.len() {
|
|
529
|
+
return Err("truncated batch values".to_string());
|
|
530
|
+
}
|
|
531
|
+
let vals = &packed[pos..pos + vlen];
|
|
532
|
+
pos += vlen;
|
|
533
|
+
let mut amt_b = [0u8; 8];
|
|
534
|
+
amt_b.copy_from_slice(&packed[pos..pos + 8]);
|
|
535
|
+
pos += 8;
|
|
536
|
+
let amount = f64::from_le_bytes(amt_b);
|
|
537
|
+
self.add(series, vals, amount)?;
|
|
538
|
+
}
|
|
539
|
+
Ok(())
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/// Convenience allocating wrapper over [`Self::snapshot_into`].
|
|
543
|
+
pub fn snapshot(&self) -> Vec<u8> {
|
|
544
|
+
let mut out = Vec::with_capacity(256);
|
|
545
|
+
self.snapshot_into(&mut out);
|
|
546
|
+
out
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/// Convenience allocating wrapper over [`Self::render_into`].
|
|
550
|
+
pub fn render(&self) -> Vec<u8> {
|
|
551
|
+
let mut out = Vec::with_capacity(256);
|
|
552
|
+
self.render_into(&mut out);
|
|
553
|
+
out
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
impl Default for MetricsRegistry {
|
|
558
|
+
fn default() -> Self {
|
|
559
|
+
Self::new()
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/// Write `{k="v",...}` (values escaped) from the family's label keys + the
|
|
564
|
+
/// raw packed values; nothing when there are no labels. `extra` appends one
|
|
565
|
+
/// more literal pair after them (the histogram `le` bound).
|
|
566
|
+
fn write_labels(
|
|
567
|
+
out: &mut Vec<u8>,
|
|
568
|
+
keys: &[Arc<str>],
|
|
569
|
+
raw_values: &[u8],
|
|
570
|
+
extra: Option<(&[u8], &[u8])>,
|
|
571
|
+
) {
|
|
572
|
+
if keys.is_empty() && extra.is_none() {
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
out.push(b'{');
|
|
576
|
+
let mut wrote = false;
|
|
577
|
+
let mut start = 0usize;
|
|
578
|
+
let mut key_idx = 0usize;
|
|
579
|
+
let emit_pair = |out: &mut Vec<u8>, key: &[u8], val: &[u8], wrote: &mut bool| {
|
|
580
|
+
if *wrote {
|
|
581
|
+
out.push(b',');
|
|
582
|
+
}
|
|
583
|
+
out.extend_from_slice(key);
|
|
584
|
+
out.extend_from_slice(b"=\"");
|
|
585
|
+
write_escaped(out, val);
|
|
586
|
+
out.push(b'"');
|
|
587
|
+
*wrote = true;
|
|
588
|
+
};
|
|
589
|
+
while start < raw_values.len() {
|
|
590
|
+
let end = raw_values[start..]
|
|
591
|
+
.iter()
|
|
592
|
+
.position(|&b| b == METRICS_VALUE_SEP)
|
|
593
|
+
.map_or(raw_values.len(), |p| start + p);
|
|
594
|
+
let key: &[u8] = keys.get(key_idx).map_or(b"l", |k| k.as_bytes());
|
|
595
|
+
emit_pair(out, key, &raw_values[start..end], &mut wrote);
|
|
596
|
+
key_idx += 1;
|
|
597
|
+
start = end + 1;
|
|
598
|
+
}
|
|
599
|
+
if let Some((k, v)) = extra {
|
|
600
|
+
emit_pair(out, k, v, &mut wrote);
|
|
601
|
+
}
|
|
602
|
+
out.push(b'}');
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/// Prometheus label-value escaping: `\` → `\\`, `"` → `\"`, newline → `\n`.
|
|
606
|
+
/// Injective, so raw-byte grouping keys never collide after escaping.
|
|
607
|
+
fn write_escaped(out: &mut Vec<u8>, val: &[u8]) {
|
|
608
|
+
for &c in val {
|
|
609
|
+
match c {
|
|
610
|
+
b'\\' => out.extend_from_slice(b"\\\\"),
|
|
611
|
+
b'"' => out.extend_from_slice(b"\\\""),
|
|
612
|
+
b'\n' => out.extend_from_slice(b"\\n"),
|
|
613
|
+
_ => out.push(c),
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/// Format a float Prometheus-style: integral values without a decimal point,
|
|
619
|
+
/// everything else via Rust's shortest round-trip display.
|
|
620
|
+
fn write_f64(out: &mut Vec<u8>, v: f64) {
|
|
621
|
+
if v.fract() == 0.0 && v.abs() < 9.0e15 {
|
|
622
|
+
let s = (v as i64).to_string();
|
|
623
|
+
out.extend_from_slice(s.as_bytes());
|
|
624
|
+
} else {
|
|
625
|
+
let s = format!("{v}");
|
|
626
|
+
out.extend_from_slice(s.as_bytes());
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
fn write_u64(out: &mut Vec<u8>, v: u64) {
|
|
631
|
+
let s = v.to_string();
|
|
632
|
+
out.extend_from_slice(s.as_bytes());
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
#[cfg(test)]
|
|
636
|
+
mod tests {
|
|
637
|
+
use super::*;
|
|
638
|
+
use std::sync::Arc;
|
|
639
|
+
use std::thread;
|
|
640
|
+
|
|
641
|
+
fn text(reg: &MetricsRegistry) -> String {
|
|
642
|
+
String::from_utf8(reg.render()).expect("render scaffolding is ASCII")
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
#[test]
|
|
646
|
+
fn counters_accumulate_and_render() {
|
|
647
|
+
let reg = MetricsRegistry::new();
|
|
648
|
+
let c = reg
|
|
649
|
+
.declare(
|
|
650
|
+
MetricsKind::Counter,
|
|
651
|
+
"http_requests_total",
|
|
652
|
+
&["route", "status"],
|
|
653
|
+
&[],
|
|
654
|
+
)
|
|
655
|
+
.expect("declare");
|
|
656
|
+
assert_eq!(c, 0);
|
|
657
|
+
reg.add(c, b"/a\x1f200", 1.0).expect("ok");
|
|
658
|
+
reg.add(c, b"/a\x1f200", 2.0).expect("ok");
|
|
659
|
+
reg.add(c, b"/b\x1f500", 1.0).expect("ok");
|
|
660
|
+
let t = text(®);
|
|
661
|
+
assert!(t.contains("# TYPE http_requests_total counter"));
|
|
662
|
+
assert!(t.contains("http_requests_total{route=\"/a\",status=\"200\"} 3\n"));
|
|
663
|
+
assert!(t.contains("http_requests_total{route=\"/b\",status=\"500\"} 1\n"));
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
#[test]
|
|
667
|
+
fn counter_without_labels_renders_bare() {
|
|
668
|
+
let reg = MetricsRegistry::new();
|
|
669
|
+
let c = reg
|
|
670
|
+
.declare(MetricsKind::Counter, "uptime", &[], &[])
|
|
671
|
+
.expect("declare");
|
|
672
|
+
reg.add(c, b"", 5.0).expect("ok");
|
|
673
|
+
assert!(text(®).contains("\nuptime 5\n"));
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
#[test]
|
|
677
|
+
fn gauge_set_overrides_add() {
|
|
678
|
+
let reg = MetricsRegistry::new();
|
|
679
|
+
let g = reg
|
|
680
|
+
.declare(MetricsKind::Gauge, "queue_depth", &["q"], &[])
|
|
681
|
+
.expect("declare");
|
|
682
|
+
reg.add(g, b"jobs", 10.0).expect("ok");
|
|
683
|
+
reg.set(g, b"jobs", 4.5).expect("ok");
|
|
684
|
+
assert!(text(®).contains("queue_depth{q=\"jobs\"} 4.5\n"));
|
|
685
|
+
assert!(reg.set(g, b"jobs", f64::NAN).is_err());
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
#[test]
|
|
689
|
+
fn set_rejected_on_counters() {
|
|
690
|
+
let reg = MetricsRegistry::new();
|
|
691
|
+
let c = reg
|
|
692
|
+
.declare(MetricsKind::Counter, "hits", &[], &[])
|
|
693
|
+
.expect("declare");
|
|
694
|
+
assert!(reg.set(c, b"", 1.0).is_err());
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
#[test]
|
|
698
|
+
fn histogram_buckets_cumulative_with_sum_and_count() {
|
|
699
|
+
let reg = MetricsRegistry::new();
|
|
700
|
+
let h = reg
|
|
701
|
+
.declare(
|
|
702
|
+
MetricsKind::Histogram,
|
|
703
|
+
"latency_seconds",
|
|
704
|
+
&["op"],
|
|
705
|
+
&[0.1, 0.5],
|
|
706
|
+
)
|
|
707
|
+
.expect("declare");
|
|
708
|
+
for v in [0.05_f64, 0.2, 0.7] {
|
|
709
|
+
reg.add(h, b"get", v).expect("observe");
|
|
710
|
+
}
|
|
711
|
+
let t = text(®);
|
|
712
|
+
assert!(t.contains("# TYPE latency_seconds histogram"));
|
|
713
|
+
assert!(t.contains("latency_seconds_bucket{op=\"get\",le=\"0.1\"} 1\n"));
|
|
714
|
+
assert!(t.contains("latency_seconds_bucket{op=\"get\",le=\"0.5\"} 2\n"));
|
|
715
|
+
assert!(t.contains("latency_seconds_bucket{op=\"get\",le=\"+Inf\"} 3\n"));
|
|
716
|
+
assert!(t.contains("latency_seconds_sum{op=\"get\"} 0.95\n"));
|
|
717
|
+
assert!(t.contains("latency_seconds_count{op=\"get\"} 3\n"));
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
#[test]
|
|
721
|
+
fn histogram_default_buckets_when_empty() {
|
|
722
|
+
let reg = MetricsRegistry::new();
|
|
723
|
+
let h = reg
|
|
724
|
+
.declare(MetricsKind::Histogram, "dur", &[], &[])
|
|
725
|
+
.expect("declare");
|
|
726
|
+
reg.add(h, b"", 100.0).expect("observe"); // above every default bucket
|
|
727
|
+
let t = text(®);
|
|
728
|
+
assert!(t.contains("dur_bucket{le=\"+Inf\"} 1\n"));
|
|
729
|
+
assert!(!t.contains("dur_bucket{le=\"10\"} 1\n"));
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
#[test]
|
|
733
|
+
fn negative_observation_rejected() {
|
|
734
|
+
let reg = MetricsRegistry::new();
|
|
735
|
+
let h = reg
|
|
736
|
+
.declare(MetricsKind::Histogram, "h", &[], &[1.0])
|
|
737
|
+
.expect("declare");
|
|
738
|
+
assert!(reg.add(h, b"", -1.0).is_err());
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
#[test]
|
|
742
|
+
fn escaping_is_applied_at_render() {
|
|
743
|
+
let reg = MetricsRegistry::new();
|
|
744
|
+
let c = reg
|
|
745
|
+
.declare(MetricsKind::Counter, "esc", &["path"], &[])
|
|
746
|
+
.expect("declare");
|
|
747
|
+
reg.add(c, b"/a\"b\\c\nd", 1.0).expect("ok");
|
|
748
|
+
assert!(text(®).contains("esc{path=\"/a\\\"b\\\\c\\nd\"} 1\n"));
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
#[test]
|
|
752
|
+
fn arity_and_byte_validation() {
|
|
753
|
+
let reg = MetricsRegistry::new();
|
|
754
|
+
let c = reg
|
|
755
|
+
.declare(MetricsKind::Counter, "m", &["a"], &[])
|
|
756
|
+
.expect("declare");
|
|
757
|
+
assert!(reg.add(c, b"", 1.0).is_err()); // missing value
|
|
758
|
+
assert!(reg.add(c, b"x\x1fy", 1.0).is_err()); // too many
|
|
759
|
+
assert!(reg.add(c, b"a\x1f\x00", 1.0).is_err()); // NUL rejected
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
#[test]
|
|
763
|
+
fn declare_idempotent_but_shape_conflicts_error() {
|
|
764
|
+
let reg = MetricsRegistry::new();
|
|
765
|
+
let a = reg
|
|
766
|
+
.declare(MetricsKind::Counter, "m", &["a"], &[])
|
|
767
|
+
.expect("first");
|
|
768
|
+
let b = reg
|
|
769
|
+
.declare(MetricsKind::Counter, "m", &["a"], &[])
|
|
770
|
+
.expect("idempotent");
|
|
771
|
+
assert_eq!(a, b);
|
|
772
|
+
assert!(reg.declare(MetricsKind::Gauge, "m", &["a"], &[]).is_err());
|
|
773
|
+
assert!(reg
|
|
774
|
+
.declare(MetricsKind::Counter, "m", &["a", "b"], &[])
|
|
775
|
+
.is_err());
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
#[test]
|
|
779
|
+
fn invalid_names_and_label_keys() {
|
|
780
|
+
let reg = MetricsRegistry::new();
|
|
781
|
+
assert!(reg.declare(MetricsKind::Counter, "9bad", &[], &[]).is_err());
|
|
782
|
+
assert!(reg.declare(MetricsKind::Counter, "", &[], &[]).is_err());
|
|
783
|
+
assert!(reg
|
|
784
|
+
.declare(MetricsKind::Counter, "has space", &[], &[])
|
|
785
|
+
.is_err());
|
|
786
|
+
assert!(reg
|
|
787
|
+
.declare(MetricsKind::Counter, "ok", &["bad-key"], &[])
|
|
788
|
+
.is_err());
|
|
789
|
+
// colons/underscores are legal metric-namespace characters
|
|
790
|
+
assert!(reg
|
|
791
|
+
.declare(MetricsKind::Counter, "_ok:name_", &[], &[])
|
|
792
|
+
.is_ok());
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
#[test]
|
|
796
|
+
fn unknown_series_and_non_finite() {
|
|
797
|
+
let reg = MetricsRegistry::new();
|
|
798
|
+
assert!(reg.add(42, b"", 1.0).is_err());
|
|
799
|
+
let c = reg
|
|
800
|
+
.declare(MetricsKind::Counter, "c", &[], &[])
|
|
801
|
+
.expect("declare");
|
|
802
|
+
assert!(reg.add(c, b"", f64::INFINITY).is_err());
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
#[test]
|
|
806
|
+
fn render_is_deterministic_across_shards() {
|
|
807
|
+
let reg = MetricsRegistry::new();
|
|
808
|
+
let c = reg
|
|
809
|
+
.declare(MetricsKind::Counter, "det", &["k"], &[])
|
|
810
|
+
.expect("declare");
|
|
811
|
+
for i in 0..300u32 {
|
|
812
|
+
reg.add(c, format!("k{i}").as_bytes(), 1.0).expect("ok");
|
|
813
|
+
}
|
|
814
|
+
let first = reg.render();
|
|
815
|
+
let second = reg.render();
|
|
816
|
+
assert_eq!(first, second);
|
|
817
|
+
assert_eq!(reg.series_count(), 300);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
#[test]
|
|
821
|
+
fn concurrent_records_do_not_deadlock_or_lose_writes() {
|
|
822
|
+
let reg = Arc::new(MetricsRegistry::new());
|
|
823
|
+
let c = reg
|
|
824
|
+
.declare(MetricsKind::Counter, "conc", &["w"], &[])
|
|
825
|
+
.expect("declare");
|
|
826
|
+
let handles: Vec<_> = (0..8)
|
|
827
|
+
.map(|w| {
|
|
828
|
+
let reg = Arc::clone(®);
|
|
829
|
+
thread::spawn(move || {
|
|
830
|
+
for _ in 0..1000 {
|
|
831
|
+
reg.add(c, format!("w{w}").as_bytes(), 1.0).expect("ok");
|
|
832
|
+
}
|
|
833
|
+
})
|
|
834
|
+
})
|
|
835
|
+
.collect();
|
|
836
|
+
for h in handles {
|
|
837
|
+
h.join().expect("join");
|
|
838
|
+
}
|
|
839
|
+
let t = text(®);
|
|
840
|
+
for w in 0..8 {
|
|
841
|
+
assert!(t.contains(&format!("conc{{w=\"w{w}\"}} 1000\n")));
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
#[cfg(test)]
|
|
847
|
+
mod snapshot_tests {
|
|
848
|
+
use super::*;
|
|
849
|
+
|
|
850
|
+
#[test]
|
|
851
|
+
fn snapshot_round_trip_shape() {
|
|
852
|
+
let reg = MetricsRegistry::new();
|
|
853
|
+
let c = reg
|
|
854
|
+
.declare(
|
|
855
|
+
MetricsKind::Counter,
|
|
856
|
+
"snap_total",
|
|
857
|
+
&["route", "status"],
|
|
858
|
+
&[],
|
|
859
|
+
)
|
|
860
|
+
.expect("declare");
|
|
861
|
+
reg.add(c, b"/a\x1f200", 2.0).expect("ok");
|
|
862
|
+
reg.add(c, b"/b\x1f500", 1.0).expect("ok");
|
|
863
|
+
let g = reg
|
|
864
|
+
.declare(MetricsKind::Gauge, "snap_depth", &[], &[])
|
|
865
|
+
.expect("g");
|
|
866
|
+
reg.set(g, b"", 3.5).expect("set");
|
|
867
|
+
let h = reg
|
|
868
|
+
.declare(MetricsKind::Histogram, "snap_lat", &["op"], &[0.1, 0.5])
|
|
869
|
+
.expect("hist");
|
|
870
|
+
reg.add(h, b"get", 0.2).expect("observe");
|
|
871
|
+
|
|
872
|
+
let bytes = reg.snapshot();
|
|
873
|
+
assert_eq!(&bytes[0..4], &1u32.to_le_bytes());
|
|
874
|
+
assert_eq!(&bytes[4..8], &3u32.to_le_bytes());
|
|
875
|
+
|
|
876
|
+
let mut off = 8usize;
|
|
877
|
+
let rd_u32 = |b: &[u8], o: usize| -> u32 {
|
|
878
|
+
u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
|
|
879
|
+
};
|
|
880
|
+
for (idx, expected_name) in ["snap_total", "snap_depth", "snap_lat"].iter().enumerate() {
|
|
881
|
+
let fam_id = rd_u32(&bytes, off);
|
|
882
|
+
assert_eq!(fam_id as usize, idx);
|
|
883
|
+
off += 4;
|
|
884
|
+
let kind = bytes[off];
|
|
885
|
+
off += 1;
|
|
886
|
+
let nlen = rd_u32(&bytes, off) as usize;
|
|
887
|
+
off += 4;
|
|
888
|
+
assert_eq!(&bytes[off..off + nlen], expected_name.as_bytes());
|
|
889
|
+
off += nlen;
|
|
890
|
+
let klen = rd_u32(&bytes, off) as usize;
|
|
891
|
+
off += 4;
|
|
892
|
+
off += klen;
|
|
893
|
+
let nb = rd_u32(&bytes, off) as usize;
|
|
894
|
+
off += 4;
|
|
895
|
+
off += nb * 8;
|
|
896
|
+
if idx == 2 {
|
|
897
|
+
assert_eq!(kind, 2);
|
|
898
|
+
assert_eq!(nb, 2);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
let scount = rd_u32(&bytes, off) as usize;
|
|
902
|
+
off += 4;
|
|
903
|
+
assert_eq!(scount, 4);
|
|
904
|
+
{
|
|
905
|
+
let fam = rd_u32(&bytes, off);
|
|
906
|
+
assert_eq!(fam, 0);
|
|
907
|
+
off += 4;
|
|
908
|
+
let vlen = rd_u32(&bytes, off) as usize;
|
|
909
|
+
off += 4;
|
|
910
|
+
assert_eq!(&bytes[off..off + vlen], b"/a\x1f200");
|
|
911
|
+
off += vlen;
|
|
912
|
+
let mut v = [0u8; 8];
|
|
913
|
+
v.copy_from_slice(&bytes[off..off + 8]);
|
|
914
|
+
assert_eq!(f64::from_le_bytes(v), 2.0);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
#[cfg(test)]
|
|
920
|
+
mod batch_tests {
|
|
921
|
+
use super::*;
|
|
922
|
+
|
|
923
|
+
#[test]
|
|
924
|
+
fn record_batch_applies_all_entries() {
|
|
925
|
+
let reg = MetricsRegistry::new();
|
|
926
|
+
let c = reg
|
|
927
|
+
.declare(MetricsKind::Counter, "b_total", &["route", "status"], &[])
|
|
928
|
+
.expect("c");
|
|
929
|
+
// packed: 2 entries
|
|
930
|
+
let mut buf = Vec::new();
|
|
931
|
+
buf.extend_from_slice(&2u32.to_le_bytes());
|
|
932
|
+
for (route, status, amount) in [("/a", "200", 3.0f64), ("/b", "500", 1.0)] {
|
|
933
|
+
let mut vals = String::with_capacity(route.len() + status.len() + 1);
|
|
934
|
+
vals.push_str(route);
|
|
935
|
+
vals.push('\u{1f}');
|
|
936
|
+
vals.push_str(status);
|
|
937
|
+
buf.extend_from_slice(&(c.to_le_bytes()));
|
|
938
|
+
buf.extend_from_slice(&(vals.len() as u32).to_le_bytes());
|
|
939
|
+
buf.extend_from_slice(vals.as_bytes());
|
|
940
|
+
buf.extend_from_slice(&amount.to_le_bytes());
|
|
941
|
+
}
|
|
942
|
+
reg.record_batch(&buf).expect("batch ok");
|
|
943
|
+
let text = String::from_utf8_lossy(®.render()).into_owned();
|
|
944
|
+
// /a,200 → 3 ; /b,500 → 1 (separate series)
|
|
945
|
+
assert!(
|
|
946
|
+
text.contains("b_total{route=\"/a\",status=\"200\"} 3"),
|
|
947
|
+
"{text}"
|
|
948
|
+
);
|
|
949
|
+
assert!(
|
|
950
|
+
text.contains("b_total{route=\"/b\",status=\"500\"} 1"),
|
|
951
|
+
"{text}"
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
#[test]
|
|
956
|
+
fn record_batch_rejects_truncated() {
|
|
957
|
+
let reg = MetricsRegistry::new();
|
|
958
|
+
assert!(reg.record_batch(&[2, 0, 0, 0]).is_err());
|
|
959
|
+
assert!(reg.record_batch(&[]).is_err());
|
|
960
|
+
}
|
|
961
|
+
}
|