futu_auth/metrics.rs
1//! 全局 metrics registry —— 供 `/metrics` Prometheus 端点消费
2//!
3//! ## 设计
4//!
5//! - 全局 `OnceLock<Arc<Registry>>`,进程初始化时 `Registry::install()` 一次
6//! - [`audit::reject`] / [`audit::allow`] / [`audit::trade`] 在写日志的同时
7//! 增 counter
8//! - [`RuntimeCounters::check_and_commit`] 拒时也会通知 metrics(限额 hit)
9//! - `Registry::render_prometheus()` 输出 Prometheus text exposition 格式
10//!
11//! ## 为什么不用 `prometheus` crate
12//!
13//! 输出格式简单(几类 counter),手写避免再引一份依赖。真要升级到更复杂的
14//! metric(histogram / summary)再换。
15//!
16//! ## 维度
17//!
18//! 所有 counter 的 label:`iface`(grpc/rest/ws/mcp)+ `outcome`
19//! (allow/reject/success/failure/unknown)+ `key_id`(未配 key 时 `<none>`)。
20//! 维度超细会爆 cardinality,但 `key_id` 上限就是 keys.json 里的条数(几十级),
21//! 可接受。
22
23use std::sync::{Arc, OnceLock};
24
25use dashmap::DashMap;
26use parking_lot::RwLock;
27use sha2::{Digest, Sha256};
28
29/// 事件计数:(iface, endpoint_or_tool 这里不放 —— 放会爆维度, outcome, key_id) → count
30type EventKey = (String, String, String);
31
32/// 限额拒 counter:(iface, key_id, reason_category) → count
33///
34/// reason_category 只枚举大类:rate / daily / per_order / market / symbol /
35/// side / hours / other,字符串固定不爆维度
36type LimitRejectKey = (String, String, String);
37
38/// v1.4.90 P1-B: extension renderer hook —— 让其他 crate(如 futu-server)
39/// 把自己持有的 [`GatewayMetrics`]、push_health 等 atomic counter 渲染成
40/// Prometheus text 追加到 `/metrics`。
41///
42/// **架构**:`futu-auth` 不能依赖 `futu-server`(反向 dep cycle),所以反过来
43/// 由 futu-server / futu-gateway 在启动时通过 [`Registry::register_renderer`]
44/// 注册一个闭包;`Registry::render_prometheus` 末尾调用所有注册的 renderer
45/// 并把它们的输出拼接进总 body。
46///
47/// **要求**:
48/// - 闭包必须返回**完整**的 Prometheus text exposition 段(含 `# HELP` /
49/// `# TYPE` / 数据行 / 末尾 `\n`),输出会**原样**拼接
50/// - 闭包应该 idempotent / O(1)~O(N counters) ——`/metrics` 抓取频率 15s 起
51type ExtraRenderer = Arc<dyn Fn() -> String + Send + Sync>;
52
53#[derive(Default)]
54pub struct Registry {
55 /// auth / trade 事件:(iface, outcome, key_id) → count
56 events: DashMap<EventKey, u64>,
57 /// 限额拒:(iface, key_id, reason_cat) → count
58 limit_rejects: DashMap<LimitRejectKey, u64>,
59 /// WS 按 scope 过滤掉的推送:(required_scope, client_key_id) → count
60 ///
61 /// 例:`(trade, key_bot_a)` 多 = bot_a 只有 qot:read,但服务端在推 trade 事件
62 ws_filtered: DashMap<(String, String), u64>,
63 /// v1.4.90 P1-B: extension renderers —— 见 [`ExtraRenderer`] 文档
64 extra_renderers: RwLock<Vec<ExtraRenderer>>,
65}
66
67impl std::fmt::Debug for Registry {
68 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69 f.debug_struct("Registry")
70 .field("events", &self.events.len())
71 .field("limit_rejects", &self.limit_rejects.len())
72 .field("ws_filtered", &self.ws_filtered.len())
73 .field("extra_renderers", &self.extra_renderers.read().len())
74 .finish()
75 }
76}
77
78impl Registry {
79 /// 记录一次 auth/trade 事件
80 pub fn record_event(&self, iface: &str, outcome: &str, key_id: &str) {
81 let key = (iface.to_string(), outcome.to_string(), key_id.to_string());
82 *self.events.entry(key).or_insert(0) += 1;
83 }
84
85 /// 记录一次限额拒(reason_cat 见文档)
86 pub fn record_limit_reject(&self, iface: &str, key_id: &str, reason_cat: &str) {
87 let key = (
88 iface.to_string(),
89 key_id.to_string(),
90 reason_cat.to_string(),
91 );
92 *self.limit_rejects.entry(key).or_insert(0) += 1;
93 }
94
95 /// 记录 WS 因 scope 不足被过滤掉的一条推送
96 pub fn record_ws_filtered(&self, required_scope: &str, client_key_id: &str) {
97 let key = (required_scope.to_string(), client_key_id.to_string());
98 *self.ws_filtered.entry(key).or_insert(0) += 1;
99 }
100
101 /// v1.4.90 P1-B: 注册一个 Prometheus extension renderer —— 见
102 /// [`ExtraRenderer`] 文档.
103 ///
104 /// 调用方:futu-server / futu-gateway 启动时调一次, 把自己持有的
105 /// `GatewayMetrics` (per-cmd push counter / 24-hour breakdown / push_health
106 /// circuit breaker 等) 渲染成 Prometheus text 追加到 `/metrics`.
107 ///
108 /// 多次注册会保留所有 renderer; 顺序与注册顺序一致.
109 pub fn register_renderer<F>(&self, renderer: F)
110 where
111 F: Fn() -> String + Send + Sync + 'static,
112 {
113 self.extra_renderers.write().push(Arc::new(renderer));
114 }
115
116 /// Prometheus text exposition 格式输出.
117 ///
118 /// **v1.4.106 codex 0542 F1 [P2 SECURITY]**: `key_id` label 默认 redact 为
119 /// `kh_<8hex>` (短 SHA256). 设 `FUTU_METRICS_PUBLIC=1` env 回退明文.
120 /// 见 [`redact_key_id`] / `Scope::MetricsRead`.
121 #[must_use]
122 pub fn render_prometheus(&self) -> String {
123 let mut s = String::with_capacity(4096);
124
125 s.push_str("# HELP futu_auth_events_total Auth / trade events by iface, outcome, key_id (redacted unless FUTU_METRICS_PUBLIC=1)\n");
126 s.push_str("# TYPE futu_auth_events_total counter\n");
127 for kv in self.events.iter() {
128 let (iface, outcome, key_id) = kv.key();
129 let v = *kv.value();
130 s.push_str(&format!(
131 "futu_auth_events_total{{iface={},outcome={},key_id={}}} {}\n",
132 prom_label(iface),
133 prom_label(outcome),
134 prom_label(&redact_key_id(key_id)),
135 v
136 ));
137 }
138
139 s.push_str(
140 "# HELP futu_auth_limit_rejects_total Limit-check rejects by iface, key_id (redacted unless FUTU_METRICS_PUBLIC=1), reason\n",
141 );
142 s.push_str("# TYPE futu_auth_limit_rejects_total counter\n");
143 for kv in self.limit_rejects.iter() {
144 let (iface, key_id, reason) = kv.key();
145 let v = *kv.value();
146 s.push_str(&format!(
147 "futu_auth_limit_rejects_total{{iface={},key_id={},reason={}}} {}\n",
148 prom_label(iface),
149 prom_label(&redact_key_id(key_id)),
150 prom_label(reason),
151 v
152 ));
153 }
154
155 s.push_str(
156 "# HELP futu_ws_filtered_pushes_total Pushes filtered out by reason (PushDropReason 8 variants), client key_id redacted unless FUTU_METRICS_PUBLIC=1\n",
157 );
158 s.push_str("# TYPE futu_ws_filtered_pushes_total counter\n");
159 for kv in self.ws_filtered.iter() {
160 let (reason, key_id) = kv.key();
161 let v = *kv.value();
162 // v1.4.106 codex 0542 F4 [P3]: label name v1.4.105 → reason
163 // (was: required_scope). 与 PushDropReason::as_metric_label() 一致.
164 s.push_str(&format!(
165 "futu_ws_filtered_pushes_total{{reason={},key_id={}}} {}\n",
166 prom_label(reason),
167 prom_label(&redact_key_id(key_id)),
168 v
169 ));
170 }
171
172 // v1.4.90 P1-B: append extension renderer 输出 (per-cmd / per-hour
173 // counter from GatewayMetrics / push_health 等). 见 ExtraRenderer 文档.
174 //
175 // **v1.4.106 codex 0542 F3 [P3]**: 每个 chunk 过 [`extension_redaction_guard`]
176 // 校验 — 防外部 crate 注册的 renderer 不小心 emit 明文 key_id label
177 // (e.g. `key_id="bot-prod-1"` 而不是 `kh_<8hex>`). 命中 →
178 // all builds: log warning + drop chunk. `/metrics` must remain available
179 // even in debug/test daemons; the guard still prevents plaintext leakage.
180 let extras: Vec<ExtraRenderer> = self.extra_renderers.read().iter().cloned().collect();
181 for renderer in extras {
182 let chunk = renderer();
183 if chunk.is_empty() {
184 continue;
185 }
186 if let Err(violation) = extension_redaction_guard(&chunk) {
187 tracing::warn!(
188 target: "futu_audit",
189 violation = %violation,
190 chunk_preview = %chunk.chars().take(200).collect::<String>(),
191 "v1.4.106 codex 0542 F3 [P3] redaction_guard: extension renderer \
192 emitted plaintext-looking key_id label, dropping chunk. Use \
193 futu_auth::metrics::redact_key_id(raw_key_id) before formatting."
194 );
195 continue; // drop the offending chunk
196 }
197 if !s.ends_with('\n') {
198 s.push('\n');
199 }
200 s.push_str(&chunk);
201 }
202
203 s
204 }
205}
206
207/// **v1.4.106 codex 0542 F3 [P3]**: redaction guard for extension renderer chunks.
208///
209/// 扫描一段 prometheus text exposition, 查找**明文 key_id label** 嫌疑模式:
210///
211/// `key_id="<value>"` 其中 `<value>` 既不是 [`redact_key_id`] 输出 (`kh_<8hex>`)
212/// 也不是合法 sentinel (`<none>` / `<missing>` / `<invalid>`). 命中 → Err.
213///
214/// **不**扫其他 label (iface / outcome / reason 等), 只针对 `key_id=` 这条专门
215/// 的 PII 通道 (= F1 主要修复对象). 其它 label 没有 cardinality enumeration
216/// 风险 (是 fixed enum vocabulary).
217///
218/// 设计参考: regex 简单 byte-level 扫描足够 (extension renderer 输出量典型 <
219/// 100 KB / scrape interval, 不是热路径). 不引 regex crate.
220///
221/// 返 `Err` = 字符串里有疑似明文 key_id, 含 first violation 的 label value 用
222/// 作 panic / log message.
223pub(crate) fn extension_redaction_guard(chunk: &str) -> Result<(), String> {
224 // 扫描每行, 找 key_id="..." pattern. 简单 byte-level state machine.
225 for (lineno, line) in chunk.lines().enumerate() {
226 let mut search_from = 0;
227 while let Some(idx) = line[search_from..].find("key_id=\"") {
228 let start = search_from + idx + "key_id=\"".len();
229 // 找配对的非转义 "
230 let mut end = None;
231 let bytes = line.as_bytes();
232 let mut j = start;
233 while j < bytes.len() {
234 let c = bytes[j];
235 if c == b'\\' && j + 1 < bytes.len() {
236 j += 2;
237 continue;
238 }
239 if c == b'"' {
240 end = Some(j);
241 break;
242 }
243 j += 1;
244 }
245 let Some(end) = end else {
246 // 闭合 quote 找不到, 跳过这一行 (malformed exposition, 但不 trigger
247 // F3 — 不是 PII 风险, 是格式问题)
248 break;
249 };
250 let value = &line[start..end];
251 if !is_acceptable_key_id_label(value) {
252 return Err(format!(
253 "line {}: key_id={value:?} 不是 redact 形式 (kh_<8hex>) \
254 也不是合法 sentinel (<none>/<missing>/<invalid>)",
255 lineno + 1
256 ));
257 }
258 search_from = end + 1;
259 }
260 }
261 Ok(())
262}
263
264/// `value` 是否合法的 key_id label value (redact 形式 or sentinel).
265fn is_acceptable_key_id_label(value: &str) -> bool {
266 // sentinel
267 if value == "<none>" || value == "<missing>" || value == "<invalid>" {
268 return true;
269 }
270 // redact 形式: "kh_" + 8 hex (lower-case)
271 if let Some(rest) = value.strip_prefix("kh_")
272 && rest.len() == 8
273 && rest
274 .chars()
275 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
276 {
277 return true;
278 }
279 // FUTU_METRICS_PUBLIC=1 opt-out 路径: 明文 key_id 也合法 (运维明确选了不
280 // redact). 不做 env 检查 (F3 是 dev-time guard, 不应 hot path query env;
281 // production 关 redact 时 raw rendered output 已是明文, 不进 extension
282 // renderer 路径).
283 //
284 // **设计取舍**: 这里**仍然**判 false 让 opt-out 用户的 extension renderer
285 // 也走 redact_key_id. 这是 secure-by-default 的体现 — 即便用户 opt-out
286 // 主 surface, extension renderer 依然走 redact 路径, 减少 PII 多点泄漏面.
287 false
288}
289
290static GLOBAL: OnceLock<Arc<Registry>> = OnceLock::new();
291
292/// 安装全局 registry。只会生效第一次 install;测试场景可能多次调用,
293/// 后续 install 被忽略(保持 first-writer 语义)
294pub fn install(reg: Arc<Registry>) {
295 let _ = GLOBAL.set(reg);
296}
297
298/// 取全局 registry;未 install 时返回 None(调用方 no-op)
299pub fn global() -> Option<Arc<Registry>> {
300 GLOBAL.get().cloned()
301}
302
303/// **v1.4.106 codex 0542 F4 [P3]**: WS push drop 原因 closed-set enum (8 variants).
304///
305/// 把 4 surface (REST `/ws` / gRPC subscribe / raw TCP WS / MCP push) 散在
306/// 各自代码里的 `bump_ws_filtered("foo", ...)` 字符串 label 收口为闭合 enum,
307/// 防漂移. 任何**新增 drop 原因必须在 enum 加 variant**, 否则 caller 编译期
308/// 不通过 — 强制同步 dashboard label.
309///
310/// 与 v1.4.105 之前的 `event.event_type.as_str()` 自由字符串方案相比:
311///
312/// - **Closed set**: enum match 编译期穷举, 不会有 typo `trade_market` →
313/// `trade_markete` silent drop dashboard.
314/// - **统一命名**: 跨 surface 同 reason 必同一 label (REST `trade_market` ==
315/// gRPC `trade_market` == MCP `trade_market`, 不再各自 emit "trade" /
316/// "trade_acc_id" 混淆 dashboard aggregation).
317/// - **Future-safe**: 加新 reason 时 grep `PushDropReason::` 找全所有 caller,
318/// 不漏 surface.
319///
320/// **8 variants 设计依据** (v1.4.106 ship 时):
321///
322/// | variant | label | 触发场景 |
323/// |---|---|---|
324/// | `ScopeMissing` | `scope` | client 持 scope 不含 event 需要的 (e.g. event 是 trade push 但 client 仅 qot:read) |
325/// | `QuoteScope` | `quote` | quote push scope 不满足 (legacy event_type label 兼容) |
326/// | `TradeScope` | `trade` | trade push scope 不满足 (legacy 兼容) |
327/// | `BroadcastScope` | `broadcast` | broadcast push scope 不满足 (legacy 兼容) |
328/// | `TradeAccIdMismatch` | `trade_acc_id` | trade push event.acc_id ∉ key.allowed_acc_ids |
329/// | `TradeMarketMismatch` | `trade_market` | trade push event.trd_market ∉ key.allowed_markets |
330/// | `NotifyNotSubscribed` | `notify_unsub` | broadcast/notify push 但 client 未显式 sub (`IsConnSubRecvNotify`) |
331/// | `TradeDecodeFailed` | `trade_decode_failed` | trade push body decode 失败, 防御性 drop |
332///
333/// `as_metric_label()` 返定 `&'static str` 与历史 dashboard label byte-identical
334/// (向后兼容).
335#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
336#[non_exhaustive]
337pub enum PushDropReason {
338 /// 通用 scope 不满足 (新加 — 替代各 surface 自定义 event_type label).
339 ScopeMissing,
340 /// Quote push scope 不满足 (legacy event_type=quote label 兼容).
341 QuoteScope,
342 /// Trade push scope 不满足 (legacy event_type=trade label 兼容).
343 TradeScope,
344 /// Broadcast push scope 不满足 (legacy event_type=broadcast label 兼容).
345 BroadcastScope,
346 /// Layer 1 — trade push event.acc_id ∉ key.allowed_acc_ids.
347 TradeAccIdMismatch,
348 /// Layer 3 — trade push event.trd_market ∉ key.allowed_markets.
349 TradeMarketMismatch,
350 /// notify_subscribe gate (`IsConnSubRecvNotify`) 未启用.
351 NotifyNotSubscribed,
352 /// Trade push body decode 失败 — 防御性 drop, 防 backend wire 错误推不可解的 push.
353 TradeDecodeFailed,
354}
355
356impl PushDropReason {
357 /// Prometheus `reason=` label 字符串 (8 固定桶).
358 #[must_use]
359 pub fn as_metric_label(&self) -> &'static str {
360 match self {
361 PushDropReason::ScopeMissing => "scope",
362 PushDropReason::QuoteScope => "quote",
363 PushDropReason::TradeScope => "trade",
364 PushDropReason::BroadcastScope => "broadcast",
365 PushDropReason::TradeAccIdMismatch => "trade_acc_id",
366 PushDropReason::TradeMarketMismatch => "trade_market",
367 PushDropReason::NotifyNotSubscribed => "notify_unsub",
368 PushDropReason::TradeDecodeFailed => "trade_decode_failed",
369 }
370 }
371
372 /// **v1.4.106 codex 0542 F4 [P3]**: 从 v1.4.105 之前的 `event_type` 字符串
373 /// 兼容 helper. 让 surface migration 渐进 (旧 `event.event_type` 字符串
374 /// 走这个 fn 转 typed). 未识别字符串 → `ScopeMissing` (向后兼容默认).
375 ///
376 /// 用法: `PushDropReason::from_legacy_event_type(&event.event_type)`.
377 #[must_use]
378 pub fn from_legacy_event_type(s: &str) -> Self {
379 match s {
380 "quote" => PushDropReason::QuoteScope,
381 "trade" => PushDropReason::TradeScope,
382 "broadcast" => PushDropReason::BroadcastScope,
383 "notify" | "notify_unsub" => PushDropReason::NotifyNotSubscribed,
384 "trade_acc_id" => PushDropReason::TradeAccIdMismatch,
385 "trade_market" => PushDropReason::TradeMarketMismatch,
386 "trade_decode_failed" => PushDropReason::TradeDecodeFailed,
387 _ => PushDropReason::ScopeMissing,
388 }
389 }
390}
391
392/// 把限额 reject 的 reason 字串分类成固定小集合
393///
394/// 对应 `limits.rs::check_and_commit` 的拒绝文案前缀。新增类目时要同步这里,
395/// 否则会落到 `"other"` 桶,dashboard 看不出来。
396#[must_use]
397pub fn classify_limit_reason(reason: &str) -> &'static str {
398 let r = reason.to_ascii_lowercase();
399 if r.starts_with("rate limit") {
400 "rate"
401 } else if r.starts_with("daily value") {
402 "daily"
403 } else if r.starts_with("order value") || r.starts_with("per-order") {
404 "per_order"
405 } else if r.starts_with("market ") {
406 "market"
407 } else if r.starts_with("symbol ") {
408 "symbol"
409 } else if r.starts_with("trd_side") {
410 "side"
411 } else if r.starts_with("outside hours") || r.starts_with("invalid hours_window") {
412 "hours"
413 } else {
414 "other"
415 }
416}
417
418/// v1.4.106 codex 0542 F1 [P2 SECURITY]: redact key_id label 为 8-hex 短 SHA256.
419///
420/// 输入 key id 明文 (如 "bot_a" / "trader-prod-1") → 输出 `kh_<8hex>`
421/// (如 `kh_3a4f5b6c`). dashboard 看到 `kh_3a4f5b6c` 仍能区分 (counter 维度
422/// 差异化), 但**不暴露 key id 明文** — 反查需要离线 dictionary 攻击 (尝试
423/// SHA256 大量候选 id 直到 hash prefix 匹配).
424///
425/// **三个 sentinel 不 hash**, 直接透传 — 这些是 audit 内部 sentinel, 无 key id
426/// 可泄漏:
427/// - `<none>` (no api key configured / legacy mode)
428/// - `<missing>` (key 缺失)
429/// - `<invalid>` (key verify 失败 / unknown bearer)
430///
431/// **opt-out**: 设 `FUTU_METRICS_PUBLIC=1` env 时回退 v1.4.105 行为 (明文
432/// key_id) — 老用户 dashboard 依赖 key_id 明文时用. 默认 secure (redact).
433///
434/// 8 hex (32 bit) 在 ~10 keys 量级内 collision 极低 (<10^-7), dashboard 维度
435/// 差异化够用. 若 keys.json 上 100+ keys 仍想 redact 时单 metric 多个 hash
436/// 是可接受 cardinality (类似 user-agent label 多 variant).
437#[must_use]
438pub fn redact_key_id(raw: &str) -> String {
439 // sentinel 透传
440 if raw == "<none>" || raw == "<missing>" || raw == "<invalid>" {
441 return raw.to_string();
442 }
443 // opt-out env: 公开 dashboard / 老 prom 抓取保留明文行为
444 if std::env::var_os("FUTU_METRICS_PUBLIC").is_some() {
445 return raw.to_string();
446 }
447 let mut hasher = Sha256::new();
448 hasher.update(raw.as_bytes());
449 let digest = hasher.finalize();
450 let mut out = String::with_capacity(11); // "kh_" + 8 hex
451 out.push_str("kh_");
452 for byte in &digest[..4] {
453 let _ = std::fmt::Write::write_fmt(&mut out, format_args!("{byte:02x}"));
454 }
455 out
456}
457
458/// Prometheus label 值转义:按 exposition 格式要求,label 值必须在双引号里且
459/// 转义 `\"`、反斜杠、换行。暴露给 [`Registry::render_prometheus`] 用
460fn prom_label(v: &str) -> String {
461 let mut out = String::with_capacity(v.len() + 2);
462 out.push('"');
463 for c in v.chars() {
464 match c {
465 '\\' => out.push_str("\\\\"),
466 '"' => out.push_str("\\\""),
467 '\n' => out.push_str("\\n"),
468 _ => out.push(c),
469 }
470 }
471 out.push('"');
472 out
473}
474
475/// 在 [`audit::reject`] / [`audit::allow`] 里同步调用,保证日志和 metrics 一致
476///
477/// 单独写在这里(而不是 audit.rs)是为了让 `audit` 只关心 tracing,
478/// 这个模块专管数值聚合
479pub(crate) fn bump_auth_event(iface: &str, outcome: &str, key_id: &str) {
480 if let Some(r) = global() {
481 r.record_event(iface, outcome, key_id);
482 }
483}
484
485pub(crate) fn bump_limit_reject(iface: &str, key_id: &str, reason: &str) {
486 if let Some(r) = global() {
487 r.record_limit_reject(iface, key_id, classify_limit_reason(reason));
488 }
489}
490
491/// 便捷:记录 ws 因 scope 过滤掉的事件.
492///
493/// **v1.4.106 codex 0542 F4 [P3]**: 第一参数语义实际是 push **drop reason**
494/// (不是 required_scope). label 名 v1.4.106 起渲染时改为 `reason=` 但函数签名
495/// 保持兼容 (现存 caller 传 `event_type` / `"trade_market"` 等不变). 新代码
496/// 推荐走 [`bump_ws_filtered_typed`] 拿 closed-set [`PushDropReason`] 防漂移.
497pub fn bump_ws_filtered(reason: &str, client_key_id: &str) {
498 if let Some(r) = global() {
499 r.record_ws_filtered(reason, client_key_id);
500 }
501}
502
503/// **v1.4.106 codex 0542 F4 [P3]**: typed 版 [`bump_ws_filtered`] —— 推荐新代码用.
504///
505/// caller 传 `PushDropReason::*` enum, 编译期穷举确保新加 reason 时 grep 找到
506/// 所有 surface caller. dashboard label 跟 [`bump_ws_filtered`] 字符串路径
507/// byte-identical (走同 [`Registry::record_ws_filtered`]).
508pub fn bump_ws_filtered_typed(reason: PushDropReason, client_key_id: &str) {
509 bump_ws_filtered(reason.as_metric_label(), client_key_id);
510}
511
512/// v1.4.90 P1-B: 便捷把 renderer 注册到全局 registry (若已 install).
513///
514/// 未 install 时 no-op (测试 / 无 metrics 模式)。
515pub fn register_global_renderer<F>(renderer: F)
516where
517 F: Fn() -> String + Send + Sync + 'static,
518{
519 if let Some(r) = global() {
520 r.register_renderer(renderer);
521 }
522}
523
524#[cfg(test)]
525mod tests;