castrum 0.9.3 → 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.
Files changed (184) hide show
  1. package/Cargo.lock +2259 -0
  2. package/Cargo.toml +138 -0
  3. package/README.md +6 -0
  4. package/build.rs +5 -0
  5. package/castrum.linux-x64-gnu.node +0 -0
  6. package/dist/index.d.ts +1348 -1295
  7. package/dist/index.js +4307 -3776
  8. package/docs/API.md +1 -1
  9. package/docs/ARCHITECTURE.md +3 -2
  10. package/docs/CASE_STUDY.md +2 -2
  11. package/docs/ENVIRONMENT.md +16 -0
  12. package/docs/FFI_BUN_GUIDE.md +89 -6
  13. package/docs/REPO_MAP.md +2 -2
  14. package/index.ts +19 -25
  15. package/package.json +9 -1
  16. package/rust/crypto/aead.rs +378 -0
  17. package/rust/crypto/argon2.rs +265 -0
  18. package/rust/crypto/base64.rs +420 -0
  19. package/rust/crypto/bcrypt.rs +89 -0
  20. package/rust/crypto/cookie_sign.rs +169 -0
  21. package/rust/crypto/csrf.rs +106 -0
  22. package/rust/crypto/ed25519.rs +215 -0
  23. package/rust/crypto/hashing.rs +140 -0
  24. package/rust/crypto/hmac_sha256.rs +88 -0
  25. package/rust/crypto/jwt/api.rs +286 -0
  26. package/rust/crypto/jwt/mod.rs +22 -0
  27. package/rust/crypto/jwt/tests.rs +312 -0
  28. package/rust/crypto/jwt/token.rs +360 -0
  29. package/rust/crypto/mod.rs +27 -0
  30. package/rust/crypto/pbkdf2.rs +96 -0
  31. package/rust/crypto/random_token.rs +85 -0
  32. package/rust/crypto/session.rs +222 -0
  33. package/rust/ffi/crypto.rs +708 -0
  34. package/rust/ffi/hashing.rs +285 -0
  35. package/rust/ffi/http.rs +752 -0
  36. package/rust/ffi/ingress.rs +303 -0
  37. package/rust/ffi/json.rs +517 -0
  38. package/rust/ffi/jwt.rs +419 -0
  39. package/rust/ffi/metrics.rs +373 -0
  40. package/rust/ffi/mod.rs +68 -0
  41. package/rust/ffi/payload.rs +275 -0
  42. package/rust/ffi/probe.rs +60 -0
  43. package/rust/ffi/rate_limit.rs +77 -0
  44. package/rust/ffi/route.rs +85 -0
  45. package/rust/ffi/tests.rs +2333 -0
  46. package/rust/ffi/util.rs +152 -0
  47. package/rust/ffi/validators.rs +123 -0
  48. package/rust/http/accept.rs +606 -0
  49. package/rust/http/cookie_parser.rs +140 -0
  50. package/rust/http/etag.rs +289 -0
  51. package/rust/http/form.rs +132 -0
  52. package/rust/http/headers.rs +267 -0
  53. package/rust/http/http_date.rs +286 -0
  54. package/rust/http/http_parser.rs +252 -0
  55. package/rust/http/media_type.rs +355 -0
  56. package/rust/http/method.rs +200 -0
  57. package/rust/http/mime_lookup.rs +130 -0
  58. package/rust/http/mod.rs +32 -0
  59. package/rust/http/multipart.rs +685 -0
  60. package/rust/http/query_parser.rs +180 -0
  61. package/rust/http/url_codec.rs +296 -0
  62. package/rust/http/url_join.rs +324 -0
  63. package/rust/ingress/api.rs +355 -0
  64. package/rust/ingress/cors.rs +403 -0
  65. package/rust/ingress/ingress_constants.rs +209 -0
  66. package/rust/ingress/ip_trust.rs +312 -0
  67. package/rust/ingress/mod.rs +48 -0
  68. package/rust/ingress/native_route.rs +779 -0
  69. package/rust/ingress/options.rs +176 -0
  70. package/rust/ingress/output.rs +406 -0
  71. package/rust/ingress/packed.rs +191 -0
  72. package/rust/ingress/pipeline.rs +534 -0
  73. package/rust/ingress/proxy.rs +138 -0
  74. package/rust/ingress/rate_limit.rs +538 -0
  75. package/rust/ingress/terminal.rs +114 -0
  76. package/rust/ingress/tests.rs +566 -0
  77. package/rust/ingress/time.rs +70 -0
  78. package/rust/json/fast_schema/capture.rs +336 -0
  79. package/rust/json/fast_schema/compile.rs +556 -0
  80. package/rust/json/fast_schema/cursor.rs +368 -0
  81. package/rust/json/fast_schema/email.rs +282 -0
  82. package/rust/json/fast_schema/errors.rs +247 -0
  83. package/rust/json/fast_schema/mod.rs +57 -0
  84. package/rust/json/fast_schema/tests.rs +1143 -0
  85. package/rust/json/fast_schema/types.rs +140 -0
  86. package/rust/json/fast_schema/validate.rs +931 -0
  87. package/rust/json/json_ops.rs +121 -0
  88. package/rust/json/json_schema.rs +837 -0
  89. package/rust/json/json_ser.rs +881 -0
  90. package/rust/json/mod.rs +19 -0
  91. package/rust/json/napi_marshal.rs +318 -0
  92. package/rust/json/patch/api.rs +60 -0
  93. package/rust/json/patch/engine.rs +153 -0
  94. package/rust/json/patch/mod.rs +47 -0
  95. package/rust/json/patch/ops.rs +270 -0
  96. package/rust/json/patch/pointer.rs +49 -0
  97. package/rust/json/patch/tests.rs +499 -0
  98. package/rust/lib.rs +72 -0
  99. package/rust/metrics/api.rs +148 -0
  100. package/rust/metrics/mod.rs +21 -0
  101. package/rust/metrics/registry.rs +961 -0
  102. package/rust/panic_safety.rs +40 -0
  103. package/rust/payload/compress.rs +512 -0
  104. package/rust/payload/mod.rs +13 -0
  105. package/rust/payload/sse.rs +214 -0
  106. package/rust/payload/template.rs +253 -0
  107. package/rust/payload/websocket.rs +69 -0
  108. package/rust/payload/ws_frames.rs +354 -0
  109. package/rust/proptest_suite.rs +139 -0
  110. package/rust/selection.rs +95 -0
  111. package/rust/src/bin/native-bench.rs +495 -0
  112. package/rust/test_support.rs +71 -0
  113. package/rust/util/batch/api.rs +266 -0
  114. package/rust/util/batch/core.rs +473 -0
  115. package/rust/util/batch/mod.rs +23 -0
  116. package/rust/util/batch/tests.rs +445 -0
  117. package/rust/util/batch_core.rs +184 -0
  118. package/rust/util/bytes.rs +407 -0
  119. package/rust/util/mod.rs +34 -0
  120. package/rust/util/packed.rs +473 -0
  121. package/rust/util/text.rs +130 -0
  122. package/rust/util/threadpool.rs +128 -0
  123. package/rust/util/validation.rs +419 -0
  124. package/rust-toolchain.toml +17 -0
  125. package/scripts/postinstall.mjs +199 -0
  126. package/src/ingress/constants.ts +1 -1
  127. package/src/ingress/context.ts +2 -2
  128. package/src/ingress/decode/baked-result.ts +13 -13
  129. package/src/ingress/decode/fast-result.ts +17 -17
  130. package/src/ingress/errors.ts +7 -7
  131. package/src/ingress/headers/baked-templates.ts +1 -1
  132. package/src/ingress/headers/fast-templates.ts +3 -3
  133. package/src/ingress/index.ts +1 -1
  134. package/src/ingress/native-route.ts +9 -5
  135. package/src/ingress/options.ts +1 -1
  136. package/src/ingress/packing/gather-raw-headers.ts +1 -1
  137. package/src/ingress/packing/route-wire.ts +1 -5
  138. package/src/ingress/response/error-bodies.ts +5 -5
  139. package/src/ingress/response/terminal.ts +1 -1
  140. package/src/ingress/routes/echo.ts +4 -4
  141. package/src/ingress/routes/fallback.ts +2 -2
  142. package/src/ingress/routes/head.ts +2 -2
  143. package/src/ingress/routes/native.ts +2 -1
  144. package/src/ingress/routes/options.ts +1 -1
  145. package/src/ingress/routes/read.ts +2 -2
  146. package/src/ingress/server.ts +10 -6
  147. package/src/ingress/status.ts +7 -7
  148. package/src/integration/batch.ts +1 -1
  149. package/src/integration/index.ts +9 -9
  150. package/src/integration/pipeline.ts +6 -6
  151. package/src/integration/websocket.ts +2 -2
  152. package/src/loader/index.ts +1 -5
  153. package/src/loader/ops.ts +1 -1
  154. package/src/native/ffi/build/codecs.ts +104 -8
  155. package/src/native/ffi/build/compress.ts +14 -10
  156. package/src/native/ffi/build/instances.ts +40 -3
  157. package/src/native/ffi/build/metrics.ts +224 -0
  158. package/src/native/ffi/build/parse.ts +59 -23
  159. package/src/native/ffi/build/util.ts +14 -1
  160. package/src/native/ffi/build.ts +2 -0
  161. package/src/native/ffi/selftest.ts +10 -3
  162. package/src/native/ffi/types.ts +111 -9
  163. package/src/native/ffi.ts +87 -1
  164. package/src/native/index.ts +1 -1
  165. package/src/native/loader.ts +2 -2
  166. package/src/native/types/instances.ts +34 -0
  167. package/src/native/types.ts +11 -0
  168. package/src/runtime/codec.ts +53 -9
  169. package/src/runtime/types.ts +7 -0
  170. package/src/rust-ffi/batch/build.ts +16 -9
  171. package/src/rust-ffi/index.ts +7 -6
  172. package/src/rust-ffi/options.ts +1 -3
  173. package/src/rust-ffi/proven.ts +3 -3
  174. package/src/rust-ffi/scalar/crypto.ts +1 -1
  175. package/src/rust-ffi/scalar/factories.ts +73 -0
  176. package/src/rust-ffi/scalar/hashing.ts +1 -1
  177. package/src/rust-ffi/scalar/http.ts +17 -11
  178. package/src/rust-ffi/scalar/index.ts +2 -0
  179. package/src/rust-ffi/scalar/interface.ts +20 -0
  180. package/src/rust-ffi/scalar/payload.ts +11 -3
  181. package/src/rust-ffi/scalar/validation.ts +121 -0
  182. package/src/selection.ts +1 -1
  183. package/src/shared/bytes.ts +5 -4
  184. 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(&reg);
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(&reg).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(&reg).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(&reg);
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(&reg);
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(&reg).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(&reg);
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(&reg);
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(&reg.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
+ }