Skip to main content

futucli/cmd/daemon/
doctor.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::path::Path;
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use anyhow::{Context, Result};
6use futu_core::diagnostic_text::update_check_unavailable_action;
7use futu_qot::types::market_state_label;
8use serde::Serialize;
9use serde_json::{Value, json};
10
11use crate::cmd::static_diag::summarize_static_status;
12use crate::cmd::version_check::{self, UpdateDecision, UpdateLevel};
13use crate::output::OutputFormat;
14
15/// 聚合 REST 诊断端点,输出一份可贴给排障方的一键体检单。
16pub async fn run_doctor(
17    rest_url: Option<&str>,
18    api_key: Option<&str>,
19    symbol: Option<&str>,
20    bundle_dir: Option<&Path>,
21    check_update: bool,
22    update_url: Option<&str>,
23    update_timeout_ms: u64,
24    output: OutputFormat,
25) -> Result<()> {
26    let base = super::resolve_rest_url(rest_url);
27    let client = reqwest::Client::builder()
28        .timeout(std::time::Duration::from_secs(5))
29        .build()
30        .context("build reqwest client")?;
31    let mut endpoints = Vec::new();
32    endpoints.push(
33        fetch_doctor_endpoint(
34            &client,
35            &base,
36            "readyz",
37            reqwest::Method::GET,
38            "/readyz",
39            api_key,
40            None,
41        )
42        .await,
43    );
44    endpoints.push(
45        fetch_doctor_endpoint(
46            &client,
47            &base,
48            "admin-status",
49            reqwest::Method::GET,
50            "/api/admin/status",
51            api_key,
52            None,
53        )
54        .await,
55    );
56    endpoints.push(
57        fetch_doctor_endpoint(
58            &client,
59            &base,
60            "push-subscriber-info",
61            reqwest::Method::GET,
62            "/api/push-subscriber-info",
63            api_key,
64            None,
65        )
66        .await,
67    );
68    endpoints.push(
69        fetch_doctor_endpoint(
70            &client,
71            &base,
72            "sub-info",
73            reqwest::Method::GET,
74            "/api/sub-info",
75            api_key,
76            None,
77        )
78        .await,
79    );
80    endpoints.push(
81        fetch_doctor_endpoint(
82            &client,
83            &base,
84            "used-quota",
85            reqwest::Method::POST,
86            "/api/used-quota",
87            api_key,
88            Some(json!({})),
89        )
90        .await,
91    );
92    endpoints.push(
93        fetch_doctor_endpoint(
94            &client,
95            &base,
96            "history-kl-quota",
97            reqwest::Method::POST,
98            "/api/history-kl-quota",
99            api_key,
100            Some(json!({"c2s": {"b_get_detail": false}})),
101        )
102        .await,
103    );
104    endpoints.push(
105        fetch_doctor_endpoint(
106            &client,
107            &base,
108            "metrics",
109            reqwest::Method::GET,
110            "/metrics",
111            api_key,
112            None,
113        )
114        .await,
115    );
116    if let Some(symbol) = symbol {
117        endpoints.push(
118            fetch_doctor_endpoint(
119                &client,
120                &base,
121                "market-state",
122                reqwest::Method::POST,
123                "/api/market-state",
124                api_key,
125                Some(json!({"symbols": [symbol]})),
126            )
127            .await,
128        );
129        endpoints.push(
130            fetch_doctor_endpoint(
131                &client,
132                &base,
133                "quote-capability",
134                reqwest::Method::GET,
135                &format!("/api/quote-capability?symbol={symbol}"),
136                api_key,
137                None,
138            )
139            .await,
140        );
141    }
142
143    let mut findings = analyze_doctor(&endpoints, symbol);
144    if check_update {
145        findings.extend(fetch_update_check_finding(update_url, update_timeout_ms).await);
146    }
147    let report = DoctorReport {
148        rest_url: base,
149        symbol: symbol.map(str::to_string),
150        endpoints,
151        findings,
152    };
153    if let Some(dir) = bundle_dir {
154        write_doctor_bundle(&report, dir)?;
155        eprintln!("# doctor bundle written to {}", dir.display());
156    }
157    print_doctor_report(&report, output)
158}
159
160async fn fetch_update_check_finding(url: Option<&str>, timeout_ms: u64) -> Option<DoctorFinding> {
161    match version_check::check_for_update(env!("CARGO_PKG_VERSION"), url, timeout_ms).await {
162        Ok(decision) => update_check_finding_from_decision(&decision),
163        Err(err) => Some(DoctorFinding {
164            level: "INFO",
165            area: "update",
166            message: "version update check unavailable".to_string(),
167            action: format!(
168                "{err}; {}",
169                update_check_unavailable_action().text_with_code()
170            ),
171        }),
172    }
173}
174
175#[derive(Debug, Serialize)]
176pub(super) struct DoctorReport {
177    rest_url: String,
178    #[serde(skip_serializing_if = "Option::is_none")]
179    symbol: Option<String>,
180    endpoints: Vec<DoctorEndpoint>,
181    findings: Vec<DoctorFinding>,
182}
183
184#[derive(Debug, Serialize)]
185pub(super) struct DoctorEndpoint {
186    pub(super) name: String,
187    pub(super) method: String,
188    pub(super) path: String,
189    pub(super) ok: bool,
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub(super) http_status: Option<u16>,
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub(super) json: Option<Value>,
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub(super) text_preview: Option<String>,
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub(super) error: Option<String>,
198}
199
200#[derive(Debug, Serialize)]
201pub(super) struct DoctorFinding {
202    pub(super) level: &'static str,
203    pub(super) area: &'static str,
204    pub(super) message: String,
205    pub(super) action: String,
206}
207
208async fn fetch_doctor_endpoint(
209    client: &reqwest::Client,
210    base: &str,
211    name: &str,
212    method: reqwest::Method,
213    path: &str,
214    api_key: Option<&str>,
215    json_body: Option<Value>,
216) -> DoctorEndpoint {
217    let method_text = method.as_str().to_string();
218    match super::request_raw(client, base, method, path, api_key, json_body).await {
219        Ok((status, body)) => {
220            let json = serde_json::from_str::<Value>(&body).ok();
221            let text_preview = if path == "/metrics" {
222                Some(select_metrics_preview(&body))
223            } else if json.is_none() {
224                Some(preview_text(&body, 1200))
225            } else {
226                None
227            };
228            DoctorEndpoint {
229                name: name.to_string(),
230                method: method_text,
231                path: path.to_string(),
232                ok: (200..300).contains(&status),
233                http_status: Some(status),
234                json,
235                text_preview,
236                error: None,
237            }
238        }
239        Err(err) => DoctorEndpoint {
240            name: name.to_string(),
241            method: method_text,
242            path: path.to_string(),
243            ok: false,
244            http_status: None,
245            json: None,
246            text_preview: None,
247            error: Some(err.to_string()),
248        },
249    }
250}
251
252pub(super) fn analyze_doctor(
253    endpoints: &[DoctorEndpoint],
254    symbol: Option<&str>,
255) -> Vec<DoctorFinding> {
256    let mut findings = Vec::new();
257    for ep in endpoints {
258        if !ep.ok {
259            findings.push(DoctorFinding {
260                level: if matches!(
261                    ep.name.as_str(),
262                    "metrics" | "used-quota" | "history-kl-quota"
263                ) {
264                    "WARN"
265                } else {
266                    "ERROR"
267                },
268                area: "endpoint",
269                message: format!(
270                    "{} {} unavailable{}",
271                    ep.method,
272                    ep.path,
273                    ep.http_status
274                        .map(|s| format!(" (HTTP {s})"))
275                        .unwrap_or_default()
276                ),
277                action: ep
278                    .error
279                    .clone()
280                    .unwrap_or_else(|| "查看 daemon 日志与 API key scope".to_string()),
281            });
282        }
283    }
284
285    if endpoint(endpoints, "readyz").is_some_and(|ep| ep.ok) {
286        findings.push(DoctorFinding {
287            level: "OK",
288            area: "readyz",
289            message: "REST readiness endpoint is reachable".to_string(),
290            action: "gateway dispatch path is ready enough for REST diagnostics".to_string(),
291        });
292    }
293
294    let market_state = endpoint_json(endpoints, "market-state").and_then(first_market_state);
295    let market_active = market_state.map(is_market_state_active);
296    if let Some(state) = market_state {
297        findings.push(DoctorFinding {
298            level: "INFO",
299            area: "market-state",
300            message: format!(
301                "{} market_state={} ({})",
302                symbol.unwrap_or("<symbol>"),
303                market_state_label(state),
304                state
305            ),
306            action: "用该状态辅助判断订阅无新 push 是闭市正常还是开市断流".to_string(),
307        });
308    }
309
310    if let Some(push_json) = endpoint_json(endpoints, "push-subscriber-info")
311        && let Some(push_health) = push_json.get("push_health")
312    {
313        if push_health
314            .get("push_stream_healthy")
315            .and_then(Value::as_bool)
316            == Some(false)
317        {
318            findings.push(DoctorFinding {
319                level: "ERROR",
320                area: "push",
321                message: "push_stream_healthy=false".to_string(),
322                action:
323                    "先看 consecutive_parse_errors / circuit breaker;必要时抓 daemon debug log"
324                        .to_string(),
325            });
326        }
327        if push_health
328            .get("backend_connected")
329            .and_then(Value::as_bool)
330            == Some(false)
331        {
332            findings.push(DoctorFinding {
333                level: "ERROR",
334                area: "push",
335                message: "backend_connected=false".to_string(),
336                action: "request path TCP 未连接;先确认 broker runtime 重连状态".to_string(),
337            });
338        }
339        if push_health
340            .get("last_push_received_at_ms")
341            .and_then(Value::as_i64)
342            == Some(0)
343        {
344            findings.push(DoctorFinding {
345                level: "WARN",
346                area: "push",
347                message: "daemon has not observed any backend push yet".to_string(),
348                action: "确认已订阅行情或交易推送;仅启动未订阅时可正常为 0".to_string(),
349            });
350        }
351    }
352
353    if let Some(admin_json) = endpoint_json(endpoints, "admin-status")
354        && let Some(warning) = admin_json
355            .get("login")
356            .and_then(|login| login.get("credential"))
357            .and_then(|credential| credential.get("expiry_warning"))
358            .and_then(Value::as_str)
359    {
360        findings.push(DoctorFinding {
361            level: "WARN",
362            area: "auth",
363            message: warning.to_string(),
364            action: "优先执行 futucli daemon-reload;若已过期则用密码/SMS 重新认证".to_string(),
365        });
366    }
367
368    if let Some(admin_json) = endpoint_json(endpoints, "admin-status")
369        && admin_json
370            .get("cache")
371            .and_then(|cache| cache.get("static_data"))
372            .is_some()
373        && let Ok(report) = summarize_static_status(admin_json)
374        && (!report.ready
375            || report.stock_list_sync_failed_total > 0
376            || report.stock_list_sync_recoverable_retry_total > 0)
377    {
378        let level = if report.ready { "INFO" } else { "WARN" };
379        findings.push(DoctorFinding {
380            level,
381            area: "static-data",
382            message: format!(
383                "stock-list readiness={} ready={} reason={}",
384                report.stock_list_readiness, report.ready, report.reason
385            ),
386            action: format!("{}; inspect with futucli static-status", report.action),
387        });
388    }
389
390    if let Some(admin_json) = endpoint_json(endpoints, "admin-status")
391        && let Some(auth_refresh) = admin_json
392            .get("login")
393            .and_then(|login| login.get("auth_refresh"))
394    {
395        let proactive = auth_refresh
396            .get("client_sig_proactive_refresh_enabled")
397            .and_then(Value::as_bool)
398            .unwrap_or(false);
399        let reactive = auth_refresh
400            .get("client_sig_reactive_refresh_enabled")
401            .and_then(Value::as_bool)
402            .unwrap_or(false);
403        if proactive || reactive {
404            findings.push(DoctorFinding {
405                level: "INFO",
406                area: "auth",
407                message: format!(
408                    "client_sig refresh opt-in enabled: proactive={proactive}, reactive={reactive}"
409                ),
410                action: "确认这是有意开启的长跑恢复配置;默认关闭,建议先用非关键账户验证"
411                    .to_string(),
412            });
413        }
414    }
415
416    let subscribed_sub_types = endpoint_json(endpoints, "sub-info")
417        .map(collect_sub_types)
418        .unwrap_or_default();
419    let push_watermarks = endpoint_json(endpoints, "push-subscriber-info")
420        .and_then(|v| v.get("push_health"))
421        .map(collect_sub_type_watermarks)
422        .unwrap_or_default();
423    if subscribed_sub_types.is_empty() {
424        findings.push(DoctorFinding {
425            level: "INFO",
426            area: "subscription",
427            message: "no subscribed SubType found in /api/sub-info".to_string(),
428            action: "若期待行情 push,先执行 futucli sub 或 REST /api/subscribe".to_string(),
429        });
430    } else {
431        for sub_type in subscribed_sub_types {
432            if !push_watermarks.contains_key(&sub_type) {
433                findings.push(missing_sub_type_finding(sub_type, market_active, symbol));
434            }
435        }
436    }
437
438    if let Some(metrics) = endpoint(endpoints, "metrics")
439        && metrics.ok
440        && !metrics
441            .text_preview
442            .as_deref()
443            .unwrap_or_default()
444            .contains("futu_gateway_push_health_stream_healthy")
445    {
446        findings.push(DoctorFinding {
447            level: "WARN",
448            area: "metrics",
449            message: "/metrics reachable but push health metrics were not found".to_string(),
450            action: "确认 daemon 版本包含 v1.4.113 push-health Prometheus renderer".to_string(),
451        });
452    }
453
454    if let Some(capability_json) = endpoint_json(endpoints, "quote-capability") {
455        findings.extend(quote_capability_decision_findings(capability_json));
456    }
457
458    findings
459}
460
461pub(super) fn update_check_finding_from_decision(
462    decision: &UpdateDecision,
463) -> Option<DoctorFinding> {
464    match decision.level {
465        UpdateLevel::UpToDate => None,
466        UpdateLevel::Info => Some(DoctorFinding {
467            level: "INFO",
468            area: "update",
469            message: decision.message.clone(),
470            action: decision.action.clone(),
471        }),
472        UpdateLevel::Recommended => Some(DoctorFinding {
473            level: "WARN",
474            area: "update",
475            message: decision.message.clone(),
476            action: decision.action.clone(),
477        }),
478        UpdateLevel::Critical => Some(DoctorFinding {
479            level: "ERROR",
480            area: "update",
481            message: decision.message.clone(),
482            action: decision.action.clone(),
483        }),
484    }
485}
486
487fn endpoint<'a>(endpoints: &'a [DoctorEndpoint], name: &str) -> Option<&'a DoctorEndpoint> {
488    endpoints.iter().find(|ep| ep.name == name)
489}
490
491fn endpoint_json<'a>(endpoints: &'a [DoctorEndpoint], name: &str) -> Option<&'a Value> {
492    endpoint(endpoints, name).and_then(|ep| ep.json.as_ref())
493}
494
495fn missing_sub_type_finding(
496    sub_type: i64,
497    market_active: Option<bool>,
498    symbol: Option<&str>,
499) -> DoctorFinding {
500    match market_active {
501        Some(true) => DoctorFinding {
502            level: "WARN",
503            area: "subscription",
504            message: format!(
505                "SubType {sub_type} is subscribed but has no regular push watermark while {} is active",
506                symbol.unwrap_or("the reference symbol")
507            ),
508            action: "开市仍无该类型 push:确认订阅参数、backend push 通道;必要时 /api/admin/reload 后重订阅".to_string(),
509        },
510        Some(false) => DoctorFinding {
511            level: "INFO",
512            area: "subscription",
513            message: format!("SubType {sub_type} is subscribed but has no regular push watermark"),
514            action: "参考 symbol 当前非连续交易时段;闭市/午休/盘前结束时部分 SubType 无新数据通常正常".to_string(),
515        },
516        None => DoctorFinding {
517            level: "INFO",
518            area: "subscription",
519            message: format!("SubType {sub_type} is subscribed but has no regular push watermark"),
520            action: "未传 --symbol,无法区分闭市正常与开市断流;可加 --symbol HK.00700 或查 /api/market-state".to_string(),
521        },
522    }
523}
524
525fn collect_sub_types(value: &Value) -> BTreeSet<i64> {
526    let mut out = BTreeSet::new();
527    collect_i64_key(value, "sub_type", &mut out);
528    out
529}
530
531fn collect_sub_type_watermarks(push_health: &Value) -> BTreeMap<i64, i64> {
532    let mut out = BTreeMap::new();
533    if let Some(items) = push_health
534        .get("last_push_by_sub_type")
535        .and_then(Value::as_array)
536    {
537        for item in items {
538            if let (Some(sub_type), Some(at_ms)) = (
539                item.get("sub_type").and_then(Value::as_i64),
540                item.get("last_push_received_at_ms").and_then(Value::as_i64),
541            ) {
542                out.insert(sub_type, at_ms);
543            }
544        }
545    }
546    out
547}
548
549fn collect_i64_key(value: &Value, key: &str, out: &mut BTreeSet<i64>) {
550    match value {
551        Value::Object(map) => {
552            if let Some(v) = map.get(key).and_then(Value::as_i64) {
553                out.insert(v);
554            }
555            for child in map.values() {
556                collect_i64_key(child, key, out);
557            }
558        }
559        Value::Array(items) => {
560            for child in items {
561                collect_i64_key(child, key, out);
562            }
563        }
564        _ => {}
565    }
566}
567
568fn quote_capability_decision_findings(value: &Value) -> Vec<DoctorFinding> {
569    let Some(decisions) = value.get("decisions").and_then(Value::as_array) else {
570        return Vec::new();
571    };
572    decisions
573        .iter()
574        .filter_map(|decision| {
575            let allowed = decision.get("allowed").and_then(Value::as_bool)?;
576            if allowed {
577                return None;
578            }
579            let topic = decision
580                .get("topic")
581                .and_then(Value::as_str)
582                .unwrap_or("unknown");
583            let reason = decision
584                .get("reason")
585                .and_then(Value::as_str)
586                .unwrap_or("blocked by quote capability decision");
587            let source = decision
588                .get("source")
589                .and_then(Value::as_str)
590                .unwrap_or("unknown");
591            let freshness = decision
592                .get("freshness")
593                .and_then(Value::as_str)
594                .unwrap_or("unknown");
595            let action = decision
596                .get("action")
597                .and_then(Value::as_str)
598                .unwrap_or("inspect futucli quote-rights and quote-capability")
599                .to_string();
600            Some(DoctorFinding {
601                level: "WARN",
602                area: "quote-capability",
603                message: format!(
604                    "{topic} blocked: {reason} (source={source}, freshness={freshness})"
605                ),
606                action,
607            })
608        })
609        .collect()
610}
611
612fn first_market_state(value: &Value) -> Option<i32> {
613    let mut values = BTreeSet::new();
614    collect_i64_key(value, "market_state", &mut values);
615    values
616        .into_iter()
617        .next()
618        .and_then(|v| i32::try_from(v).ok())
619}
620
621fn is_market_state_active(state: i32) -> bool {
622    matches!(
623        state,
624        1 | 3 | 5 | 8 | 10 | 13 | 15 | 19 | 21 | 23 | 25 | 28 | 32 | 33 | 35 | 36 | 37
625    )
626}
627
628fn print_doctor_report(report: &DoctorReport, output: OutputFormat) -> Result<()> {
629    match output {
630        OutputFormat::Json => {
631            println!("{}", serde_json::to_string_pretty(report)?);
632        }
633        OutputFormat::Jsonl => {
634            println!("{}", serde_json::to_string(report)?);
635        }
636        OutputFormat::Table | OutputFormat::Markdown => {
637            println!("futucli doctor");
638            println!("REST: {}", report.rest_url);
639            if let Some(symbol) = &report.symbol {
640                println!("Symbol: {symbol}");
641            }
642            println!();
643            println!("Endpoints:");
644            for ep in &report.endpoints {
645                let status = ep
646                    .http_status
647                    .map(|s| s.to_string())
648                    .unwrap_or_else(|| "-".to_string());
649                let mark = if ep.ok { "OK" } else { "FAIL" };
650                println!("- [{mark}] {} {} HTTP {}", ep.method, ep.path, status);
651                if let Some(error) = &ep.error {
652                    println!("  error: {error}");
653                }
654            }
655            println!();
656            println!("Findings:");
657            if report.findings.is_empty() {
658                println!("- [OK] no actionable findings");
659            } else {
660                for finding in &report.findings {
661                    println!(
662                        "- [{}] {}: {}",
663                        finding.level, finding.area, finding.message
664                    );
665                    println!("  next: {}", finding.action);
666                }
667            }
668        }
669    }
670    Ok(())
671}
672
673fn select_metrics_preview(body: &str) -> String {
674    let selected: Vec<&str> = body
675        .lines()
676        .filter(|line| {
677            line.contains("futu_gateway_push_health")
678                || line.contains("futu_gateway_broker_tcp")
679                || line.contains("futu_metrics_registry")
680        })
681        .collect();
682    if selected.is_empty() {
683        preview_text(body, 1200)
684    } else {
685        selected.join("\n")
686    }
687}
688
689fn preview_text(body: &str, max_chars: usize) -> String {
690    body.chars().take(max_chars).collect()
691}
692
693fn write_doctor_bundle(report: &DoctorReport, dir: &Path) -> Result<()> {
694    std::fs::create_dir_all(dir).with_context(|| format!("create bundle dir {}", dir.display()))?;
695    let endpoints_dir = dir.join("endpoints");
696    std::fs::create_dir_all(&endpoints_dir)
697        .with_context(|| format!("create endpoints dir {}", endpoints_dir.display()))?;
698
699    let redacted_report = redacted_json(report)?;
700    write_json_file(&dir.join("doctor-report.json"), &redacted_report)?;
701
702    let findings_path = dir.join("doctor-findings.jsonl");
703    let mut findings = String::new();
704    for finding in &report.findings {
705        findings.push_str(&serde_json::to_string(finding)?);
706        findings.push('\n');
707    }
708    std::fs::write(&findings_path, redact_sensitive_text_for_bundle(&findings))
709        .with_context(|| format!("write {}", findings_path.display()))?;
710
711    let mut files = vec![
712        "doctor-report.json".to_string(),
713        "doctor-findings.jsonl".to_string(),
714    ];
715    for endpoint in &report.endpoints {
716        let base_name = bundle_file_stem(&endpoint.name);
717        if let Some(json) = &endpoint.json {
718            let mut value = json.clone();
719            redact_sensitive_json_for_bundle(&mut value);
720            let rel = format!("endpoints/{base_name}.json");
721            write_json_file(&dir.join(&rel), &value)?;
722            files.push(rel);
723        }
724        if let Some(text) = &endpoint.text_preview {
725            let rel = format!("endpoints/{base_name}.txt");
726            let path = dir.join(&rel);
727            std::fs::write(&path, redact_sensitive_text_for_bundle(text))
728                .with_context(|| format!("write {}", path.display()))?;
729            files.push(rel);
730        }
731        if let Some(error) = &endpoint.error {
732            let rel = format!("endpoints/{base_name}.error.txt");
733            let path = dir.join(&rel);
734            std::fs::write(&path, redact_sensitive_text_for_bundle(error))
735                .with_context(|| format!("write {}", path.display()))?;
736            files.push(rel);
737        }
738    }
739
740    let manifest = json!({
741        "schema_version": 1,
742        "generated_unix_secs": SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(),
743        "rest_url": report.rest_url,
744        "symbol": report.symbol,
745        "redaction": "sensitive keys and key=value tokens are replaced with <redacted>",
746        "files": files,
747    });
748    write_json_file(&dir.join("MANIFEST.json"), &manifest)?;
749    Ok(())
750}
751
752fn write_json_file(path: &Path, value: &Value) -> Result<()> {
753    let text = serde_json::to_string_pretty(value)?;
754    std::fs::write(path, text).with_context(|| format!("write {}", path.display()))
755}
756
757fn redacted_json<T: Serialize>(value: &T) -> Result<Value> {
758    let mut json = serde_json::to_value(value)?;
759    redact_sensitive_json_for_bundle(&mut json);
760    Ok(json)
761}
762
763fn bundle_file_stem(name: &str) -> String {
764    name.chars()
765        .map(|c| {
766            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
767                c
768            } else {
769                '-'
770            }
771        })
772        .collect()
773}
774
775pub(super) fn redact_sensitive_json_for_bundle(value: &mut Value) {
776    match value {
777        Value::Object(map) => {
778            for (key, child) in map.iter_mut() {
779                if is_sensitive_key(key) {
780                    *child = Value::String("<redacted>".to_string());
781                } else {
782                    redact_sensitive_json_for_bundle(child);
783                }
784            }
785        }
786        Value::Array(items) => {
787            for item in items {
788                redact_sensitive_json_for_bundle(item);
789            }
790        }
791        _ => {}
792    }
793}
794
795pub(super) fn redact_sensitive_text_for_bundle(text: &str) -> String {
796    text.split_whitespace()
797        .map(|token| {
798            let Some((key, _value)) = token.split_once('=') else {
799                return token.to_string();
800            };
801            let normalized = key.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '_');
802            if is_sensitive_key(normalized) {
803                format!("{key}=<redacted>")
804            } else {
805                token.to_string()
806            }
807        })
808        .collect::<Vec<_>>()
809        .join(" ")
810}
811
812fn is_sensitive_key(key: &str) -> bool {
813    let k = key.to_ascii_lowercase();
814    if matches!(
815        k.as_str(),
816        "client_sig_proactive_refresh_enabled" | "client_sig_reactive_refresh_enabled"
817    ) {
818        return false;
819    }
820    k.contains("password")
821        || k == "pwd"
822        || k.contains("api_key")
823        || k.contains("token")
824        || k.contains("secret")
825        || k.contains("tgtgt")
826        || k.contains("device_sig")
827        || k.contains("dvs")
828        || k.contains("auth_code")
829        || k.contains("session_key")
830        || k.contains("client_key")
831        || k.contains("client_sig")
832        || k.contains("web_sig")
833}