ja-furigana-cli 0.1.11

CLI and HTTP server for ja-furigana — Japanese ruby annotation. Installs the `furigana` binary.
//! 軽量 Prometheus 互換 metrics (= 配信運用の trace + 簡易 alert 用)
//!
//! 外部 crate を増やさず、 std::sync::atomic + 固定 bucket histogram で実装。
//! `/metrics` endpoint で text format を返す。

use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering::Relaxed};

/// histogram の境界 (ms 単位、 8 bucket + +Inf)
const LATENCY_BUCKETS_MS: [f64; 8] = [5.0, 10.0, 25.0, 50.0, 100.0, 250.0, 500.0, 1000.0];

/// 1 process あたり 1 個生成、 Arc で共有
#[derive(Default)]
pub(super) struct ServerMetrics {
    // mode 別 request count
    req_tts: AtomicU64,
    req_hiragana: AtomicU64,
    req_ruby: AtomicU64,
    req_kanji: AtomicU64,
    req_romaji: AtomicU64,
    req_romaji_kunrei: AtomicU64,
    req_analyze: AtomicU64,
    // latency histogram (= total ms、 bucket cumulative count)
    latency_buckets: [AtomicU64; 9],
    latency_sum_ms: AtomicU64,
    latency_count: AtomicU64,
    // 各種 counter
    auth_failures: AtomicU64,
    rate_limited: AtomicU64,
    slow_requests: AtomicU64,
    failed_resolution: AtomicU64,
    reloads: AtomicU64,
    // gauge
    dict_size: AtomicUsize,
}

impl ServerMetrics {
    pub(super) fn record_request(&self, mode: &str, total_ms: f64) {
        let counter = match mode {
            "tts" => &self.req_tts,
            "hiragana" => &self.req_hiragana,
            "ruby" => &self.req_ruby,
            "kanji" => &self.req_kanji,
            "romaji" => &self.req_romaji,
            "romaji-kunrei" => &self.req_romaji_kunrei,
            "analyze" => &self.req_analyze,
            _ => return,
        };
        counter.fetch_add(1, Relaxed);
        // histogram (= 各 bucket は「<= 境界」 の cumulative count を保持)
        for (i, b) in LATENCY_BUCKETS_MS.iter().enumerate() {
            if total_ms <= *b {
                self.latency_buckets[i].fetch_add(1, Relaxed);
            }
        }
        self.latency_buckets[8].fetch_add(1, Relaxed); // +Inf bucket
        self.latency_sum_ms
            .fetch_add(total_ms.round() as u64, Relaxed);
        self.latency_count.fetch_add(1, Relaxed);
    }

    pub(super) fn record_auth_failure(&self) {
        self.auth_failures.fetch_add(1, Relaxed);
    }

    pub(super) fn record_rate_limited(&self) {
        self.rate_limited.fetch_add(1, Relaxed);
    }

    pub(super) fn record_slow_request(&self) {
        self.slow_requests.fetch_add(1, Relaxed);
    }

    pub(super) fn record_failed_resolution(&self) {
        self.failed_resolution.fetch_add(1, Relaxed);
    }

    pub(super) fn record_reload(&self, new_dict_size: usize) {
        self.reloads.fetch_add(1, Relaxed);
        self.dict_size.store(new_dict_size, Relaxed);
    }

    pub(super) fn set_dict_size(&self, size: usize) {
        self.dict_size.store(size, Relaxed);
    }

    /// Prometheus text exposition format で render
    pub(super) fn render(&self) -> String {
        let mut out = String::with_capacity(2048);

        // requests_total per mode
        out.push_str("# HELP furigana_requests_total Total requests served, partitioned by mode\n");
        out.push_str("# TYPE furigana_requests_total counter\n");
        for (mode, c) in [
            ("tts", &self.req_tts),
            ("hiragana", &self.req_hiragana),
            ("ruby", &self.req_ruby),
            ("kanji", &self.req_kanji),
            ("romaji", &self.req_romaji),
            ("romaji-kunrei", &self.req_romaji_kunrei),
            ("analyze", &self.req_analyze),
        ] {
            out.push_str(&format!(
                "furigana_requests_total{{mode=\"{}\"}} {}\n",
                mode,
                c.load(Relaxed)
            ));
        }

        // latency histogram
        out.push_str(
            "# HELP furigana_request_duration_ms Request total latency (process() 内の total_ms)\n",
        );
        out.push_str("# TYPE furigana_request_duration_ms histogram\n");
        for (i, b) in LATENCY_BUCKETS_MS.iter().enumerate() {
            out.push_str(&format!(
                "furigana_request_duration_ms_bucket{{le=\"{}\"}} {}\n",
                b,
                self.latency_buckets[i].load(Relaxed)
            ));
        }
        out.push_str(&format!(
            "furigana_request_duration_ms_bucket{{le=\"+Inf\"}} {}\n",
            self.latency_buckets[8].load(Relaxed)
        ));
        out.push_str(&format!(
            "furigana_request_duration_ms_sum {}\n",
            self.latency_sum_ms.load(Relaxed)
        ));
        out.push_str(&format!(
            "furigana_request_duration_ms_count {}\n",
            self.latency_count.load(Relaxed)
        ));

        // 各種 counter
        for (name, help, value) in [
            (
                "furigana_auth_failures_total",
                "Authentication failures (= token mismatch)",
                self.auth_failures.load(Relaxed),
            ),
            (
                "furigana_rate_limited_total",
                "Requests rejected by rate limiter (= HTTP 429)",
                self.rate_limited.load(Relaxed),
            ),
            (
                "furigana_slow_requests_total",
                "Requests whose total_ms exceeded slow threshold (= 100ms default)",
                self.slow_requests.load(Relaxed),
            ),
            (
                "furigana_failed_resolution_total",
                "Requests whose result indicated reading resolution failure",
                self.failed_resolution.load(Relaxed),
            ),
            (
                "furigana_reloads_total",
                "Dictionary reload events (SIGHUP / admin / auto_update / startup)",
                self.reloads.load(Relaxed),
            ),
        ] {
            out.push_str(&format!("# HELP {} {}\n", name, help));
            out.push_str(&format!("# TYPE {} counter\n", name));
            out.push_str(&format!("{} {}\n", name, value));
        }

        // gauge: dict_size
        out.push_str("# HELP furigana_dict_size Current dictionary size (entries)\n");
        out.push_str("# TYPE furigana_dict_size gauge\n");
        out.push_str(&format!(
            "furigana_dict_size {}\n",
            self.dict_size.load(Relaxed)
        ));

        out
    }
}