package pw.binom.llmproxy import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock /** * Pull-метрики для Prometheus (формат экспозиции `text/plain; version=0.0.4`), * отдаются на `GET /metrics`. * * Метрики (всё в памяти, сброс при рестарте): * - `llm_proxy_requests_total{model,upstream,provider,result}` — счётчик * chat-запросов; `result`: `ok | 4xx | 429 | 402 | 5xx | net_err | timeout | cancelled`; * - `llm_proxy_upstream_inflight{upstream,provider}` — сейчас в работе (слоты); * - `llm_proxy_upstream_fail_streak{upstream,provider}` — текущая серия сбоев * (счётчик backoff); * - `llm_proxy_upstream_cooling_seconds{upstream,provider}` — сколько секунд * апстрим ещё в backoff-откате (0 = жив). */ class MetricsRegistry { private val lock = Mutex() private val requests = HashMap() private data class RequestKey( val model: String, val upstream: String, val provider: String, val result: String, ) /** Учёт chat-запроса по исходу (значения `result` — в описании класса). */ suspend fun record(model: String, up: UpstreamConf, result: String) { lock.withLock { val key = RequestKey(model, up.id, up.provider, result) requests[key] = (requests[key] ?: 0L) + 1 } } /** Текстовая выгрузка в формате Prometheus на текущий момент. */ suspend fun render( config: Config, active: Map, backoff: BackoffRegistry, ): String { val sb = StringBuilder() sb.append("# HELP llm_proxy_requests_total Chat-запросы llm-proxy по исходу\n") sb.append("# TYPE llm_proxy_requests_total counter\n") lock.withLock { requests.entries .sortedWith(compareBy({ it.key.model }, { it.key.upstream }, { it.key.provider }, { it.key.result })) .forEach { (k, n) -> sb.append( "llm_proxy_requests_total{model=\"" + esc(k.model) + "\",upstream=\"" + esc(k.upstream) + "\",provider=\"" + esc(k.provider) + "\",result=\"" + esc(k.result) + "\"} $n\n", ) } } sb.append("# HELP llm_proxy_upstream_inflight Занято слотов апстрима сейчас\n") sb.append("# TYPE llm_proxy_upstream_inflight gauge\n") for (up in config.upstreams) { val n = active[up.id]?.current ?: 0 sb.append( "llm_proxy_upstream_inflight{upstream=\"" + esc(up.id) + "\",provider=\"" + esc(up.provider) + "\"} $n\n", ) } sb.append("# HELP llm_proxy_upstream_fail_streak Серия сбоев апстрима подряд (счётчик backoff)\n") sb.append("# TYPE llm_proxy_upstream_fail_streak gauge\n") for (up in config.upstreams) { sb.append( "llm_proxy_upstream_fail_streak{upstream=\"" + esc(up.id) + "\",provider=\"" + esc(up.provider) + "\"} " + backoff.failStreakOf(up) + "\n", ) } sb.append("# HELP llm_proxy_upstream_cooling_seconds Секунд до выхода апстрима из backoff-отката (0 = не в откате)\n") sb.append("# TYPE llm_proxy_upstream_cooling_seconds gauge\n") for (up in config.upstreams) { sb.append( "llm_proxy_upstream_cooling_seconds{upstream=\"" + esc(up.id) + "\",provider=\"" + esc(up.provider) + "\"} " + backoff.remainingSeconds(up) + "\n", ) } return sb.toString() } private fun esc(s: String): String = s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") } /** HTTP-статус апстрима → класс исхода для метрик. */ internal fun statusClass(status: Int): String = when { status in 200..399 -> "ok" status == 429 -> "429" status == 402 -> "402" status in 400..499 -> "4xx" else -> "5xx" }