Skip to main content

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;