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 audit 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
293enum InstallOutcome {
294    Installed,
295    AlreadyInstalled,
296}
297
298fn install_into(cell: &OnceLock<Arc<Registry>>, reg: Arc<Registry>) -> InstallOutcome {
299    match cell.set(reg) {
300        Ok(()) => InstallOutcome::Installed,
301        Err(_) => InstallOutcome::AlreadyInstalled,
302    }
303}
304
305/// 安装全局 registry。只会生效第一次 install;测试场景可能多次调用,
306/// 后续 install 被忽略(保持 first-writer 语义)
307pub fn install(reg: Arc<Registry>) {
308    if install_into(&GLOBAL, reg) == InstallOutcome::AlreadyInstalled {
309        tracing::debug!("metrics global registry already installed; keeping existing registry");
310    }
311}
312
313/// 取全局 registry;未 install 时返回 None(调用方 no-op)
314pub fn global() -> Option<Arc<Registry>> {
315    GLOBAL.get().cloned()
316}
317
318/// **v1.4.106 codex 0542 F4 [P3]**: WS push drop 原因 closed-set enum (8 variants).
319///
320/// 把 4 surface (REST `/ws` / gRPC subscribe / raw TCP WS / MCP push) 散在
321/// 各自代码里的 `bump_ws_filtered("foo", ...)` 字符串 label 收口为闭合 enum,
322/// 防漂移. 任何**新增 drop 原因必须在 enum 加 variant**, 否则 caller 编译期
323/// 不通过 — 强制同步 dashboard label.
324///
325/// 与 v1.4.105 之前的 `event.event_type.as_str()` 自由字符串方案相比:
326///
327/// - **Closed set**: enum match 编译期穷举, 不会有 typo `trade_market` →
328///   `trade_markete` silent drop dashboard.
329/// - **统一命名**: 跨 surface 同 reason 必同一 label (REST `trade_market` ==
330///   gRPC `trade_market` == MCP `trade_market`, 不再各自 emit "trade" /
331///   "trade_acc_id" 混淆 dashboard aggregation).
332/// - **Future-safe**: 加新 reason 时 grep `PushDropReason::` 找全所有 caller,
333///   不漏 surface.
334///
335/// **8 variants 设计依据** (v1.4.106 ship 时):
336///
337/// | variant | label | 触发场景 |
338/// |---|---|---|
339/// | `ScopeMissing` | `scope` | client 持 scope 不含 event 需要的 (e.g. event 是 trade push 但 client 仅 qot:read) |
340/// | `QuoteScope` | `quote` | quote push scope 不满足 (legacy event_type label 兼容) |
341/// | `TradeScope` | `trade` | trade push scope 不满足 (legacy 兼容) |
342/// | `BroadcastScope` | `broadcast` | broadcast push scope 不满足 (legacy 兼容) |
343/// | `TradeAccIdMismatch` | `trade_acc_id` | trade push event.acc_id ∉ key.allowed_acc_ids |
344/// | `TradeMarketMismatch` | `trade_market` | trade push event.trd_market ∉ key.allowed_markets |
345/// | `NotifyNotSubscribed` | `notify_unsub` | broadcast/notify push 但 client 未显式 sub (`IsConnSubRecvNotify`) |
346/// | `TradeDecodeFailed` | `trade_decode_failed` | trade push body decode 失败, 防御性 drop |
347///
348/// `as_metric_label()` 返定 `&'static str` 与历史 dashboard label byte-identical
349/// (向后兼容).
350#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
351#[non_exhaustive]
352pub enum PushDropReason {
353    /// 通用 scope 不满足 (新加 — 替代各 surface 自定义 event_type label).
354    ScopeMissing,
355    /// Quote push scope 不满足 (legacy event_type=quote label 兼容).
356    QuoteScope,
357    /// Trade push scope 不满足 (legacy event_type=trade label 兼容).
358    TradeScope,
359    /// Broadcast push scope 不满足 (legacy event_type=broadcast label 兼容).
360    BroadcastScope,
361    /// Layer 1 — trade push event.acc_id ∉ key.allowed_acc_ids.
362    TradeAccIdMismatch,
363    /// Layer 3 — trade push event.trd_market ∉ key.allowed_markets.
364    TradeMarketMismatch,
365    /// notify_subscribe gate (`IsConnSubRecvNotify`) 未启用.
366    NotifyNotSubscribed,
367    /// Trade push body decode 失败 — 防御性 drop, 防 backend wire 错误推不可解的 push.
368    TradeDecodeFailed,
369}
370
371impl PushDropReason {
372    /// Prometheus `reason=` label 字符串 (8 固定桶).
373    #[must_use]
374    pub fn as_metric_label(&self) -> &'static str {
375        match self {
376            PushDropReason::ScopeMissing => "scope",
377            PushDropReason::QuoteScope => "quote",
378            PushDropReason::TradeScope => "trade",
379            PushDropReason::BroadcastScope => "broadcast",
380            PushDropReason::TradeAccIdMismatch => "trade_acc_id",
381            PushDropReason::TradeMarketMismatch => "trade_market",
382            PushDropReason::NotifyNotSubscribed => "notify_unsub",
383            PushDropReason::TradeDecodeFailed => "trade_decode_failed",
384        }
385    }
386
387    /// **v1.4.106 codex 0542 F4 [P3]**: 从 v1.4.105 之前的 `event_type` 字符串
388    /// 兼容 helper. 让 surface migration 渐进 (旧 `event.event_type` 字符串
389    /// 走这个 fn 转 typed). 未识别字符串 → `ScopeMissing` (向后兼容默认).
390    ///
391    /// 用法: `PushDropReason::from_legacy_event_type(&event.event_type)`.
392    #[must_use]
393    pub fn from_legacy_event_type(s: &str) -> Self {
394        match s {
395            "quote" => PushDropReason::QuoteScope,
396            "trade" => PushDropReason::TradeScope,
397            "broadcast" => PushDropReason::BroadcastScope,
398            "notify" | "notify_unsub" => PushDropReason::NotifyNotSubscribed,
399            "trade_acc_id" => PushDropReason::TradeAccIdMismatch,
400            "trade_market" => PushDropReason::TradeMarketMismatch,
401            "trade_decode_failed" => PushDropReason::TradeDecodeFailed,
402            _ => PushDropReason::ScopeMissing,
403        }
404    }
405}
406
407/// 把限额 reject 的 reason 字串分类成固定小集合
408///
409/// 对应 `limits.rs::check_and_commit` 的拒绝文案前缀。新增类目时要同步这里,
410/// 否则会落到 `"other"` 桶,dashboard 看不出来。
411#[must_use]
412pub fn classify_limit_reason(reason: &str) -> &'static str {
413    let r = reason.to_ascii_lowercase();
414    if r.starts_with("rate limit") {
415        "rate"
416    } else if r.starts_with("daily value") {
417        "daily"
418    } else if r.starts_with("order value") || r.starts_with("per-order") {
419        "per_order"
420    } else if r.starts_with("market ") {
421        "market"
422    } else if r.starts_with("symbol ") {
423        "symbol"
424    } else if r.starts_with("trd_side") {
425        "side"
426    } else if r.starts_with("outside hours") || r.starts_with("invalid hours_window") {
427        "hours"
428    } else {
429        "other"
430    }
431}
432
433/// v1.4.106 codex 0542 F1 [P2 SECURITY]: redact key_id label 为 8-hex 短 SHA256.
434///
435/// 输入 key id 明文 (如 "bot_a" / "trader-prod-1") → 输出 `kh_<8hex>`
436/// (如 `kh_3a4f5b6c`). dashboard 看到 `kh_3a4f5b6c` 仍能区分 (counter 维度
437/// 差异化), 但**不暴露 key id 明文** — 反查需要离线 dictionary 攻击 (尝试
438/// SHA256 大量候选 id 直到 hash prefix 匹配).
439///
440/// **三个 sentinel 不 hash**, 直接透传 — 这些是 audit 内部 sentinel, 无 key id
441/// 可泄漏:
442/// - `<none>` (no api key configured / legacy mode)
443/// - `<missing>` (key 缺失)
444/// - `<invalid>` (key verify 失败 / unknown bearer)
445///
446/// **opt-out**: 设 `FUTU_METRICS_PUBLIC=1` env 时回退 v1.4.105 行为 (明文
447/// key_id) — 老用户 dashboard 依赖 key_id 明文时用. 默认 secure (redact).
448///
449/// 8 hex (32 bit) 在 ~10 keys 量级内 collision 极低 (<10^-7), dashboard 维度
450/// 差异化够用. 若 keys.json 上 100+ keys 仍想 redact 时单 metric 多个 hash
451/// 是可接受 cardinality (类似 user-agent label 多 variant).
452#[must_use]
453pub fn redact_key_id(raw: &str) -> String {
454    // sentinel 透传
455    if is_key_id_sentinel(raw) {
456        return raw.to_string();
457    }
458    // opt-out env: 公开 dashboard / 老 prom 抓取保留明文行为
459    if std::env::var_os("FUTU_METRICS_PUBLIC").is_some() {
460        return raw.to_string();
461    }
462    hash_key_id_label(raw)
463}
464
465/// Redact key ids for general logs.
466///
467/// Unlike [`redact_key_id`], this intentionally ignores `FUTU_METRICS_PUBLIC`.
468/// That opt-out exists only for Prometheus dashboard compatibility; ordinary
469/// logs should not reveal key ids after a failed machine-binding check.
470#[must_use]
471pub(crate) fn redact_key_id_for_logs(raw: &str) -> String {
472    if is_key_id_sentinel(raw) {
473        return raw.to_string();
474    }
475    hash_key_id_label(raw)
476}
477
478fn is_key_id_sentinel(raw: &str) -> bool {
479    raw == "<none>" || raw == "<missing>" || raw == "<invalid>"
480}
481
482fn hash_key_id_label(raw: &str) -> String {
483    let mut hasher = Sha256::new();
484    hasher.update(raw.as_bytes());
485    let digest = hasher.finalize();
486    let mut out = String::with_capacity(11); // "kh_" + 8 hex
487    out.push_str("kh_");
488    out.push_str(&hex::encode(&digest[..4]));
489    out
490}
491
492/// Prometheus label 值转义:按 exposition 格式要求,label 值必须在双引号里且
493/// 转义 `\"`、反斜杠、换行。暴露给 [`Registry::render_prometheus`] 用
494fn prom_label(v: &str) -> String {
495    let mut out = String::with_capacity(v.len() + 2);
496    out.push('"');
497    for c in v.chars() {
498        match c {
499            '\\' => out.push_str("\\\\"),
500            '"' => out.push_str("\\\""),
501            '\n' => out.push_str("\\n"),
502            _ => out.push(c),
503        }
504    }
505    out.push('"');
506    out
507}
508
509/// 在 [`audit::reject`] / [`audit::allow`] 里同步调用,保证日志和 metrics 一致
510///
511/// 单独写在这里(而不是 audit.rs)是为了让 `audit` 只关心 tracing,
512/// 这个模块专管数值聚合
513pub(crate) fn bump_auth_event(iface: &str, outcome: &str, key_id: &str) {
514    if let Some(r) = global() {
515        r.record_event(iface, outcome, key_id);
516    }
517}
518
519pub(crate) fn bump_limit_reject(iface: &str, key_id: &str, reason: &str) {
520    if let Some(r) = global() {
521        r.record_limit_reject(iface, key_id, classify_limit_reason(reason));
522    }
523}
524
525/// 便捷:记录 ws 因 scope 过滤掉的事件.
526///
527/// **v1.4.106 codex 0542 F4 [P3]**: 第一参数语义实际是 push **drop reason**
528/// (不是 required_scope). label 名 v1.4.106 起渲染时改为 `reason=` 但函数签名
529/// 保持兼容 (现存 caller 传 `event_type` / `"trade_market"` 等不变). 新代码
530/// 推荐走 [`bump_ws_filtered_typed`] 拿 closed-set [`PushDropReason`] 防漂移.
531pub fn bump_ws_filtered(reason: &str, client_key_id: &str) {
532    if let Some(r) = global() {
533        r.record_ws_filtered(reason, client_key_id);
534    }
535}
536
537/// **v1.4.106 codex 0542 F4 [P3]**: typed 版 [`bump_ws_filtered`] —— 推荐新代码用.
538///
539/// caller 传 `PushDropReason::*` enum, 编译期穷举确保新加 reason 时 grep 找到
540/// 所有 surface caller. dashboard label 跟 [`bump_ws_filtered`] 字符串路径
541/// byte-identical (走同 [`Registry::record_ws_filtered`]).
542pub fn bump_ws_filtered_typed(reason: PushDropReason, client_key_id: &str) {
543    bump_ws_filtered(reason.as_metric_label(), client_key_id);
544}
545
546/// v1.4.90 P1-B: 便捷把 renderer 注册到全局 registry (若已 install).
547///
548/// 未 install 时 no-op (测试 / 无 metrics 模式)。
549pub fn register_global_renderer<F>(renderer: F)
550where
551    F: Fn() -> String + Send + Sync + 'static,
552{
553    if let Some(r) = global() {
554        r.register_renderer(renderer);
555    }
556}
557
558#[cfg(test)]
559mod tests;