feat: Prometheus-метрики GET /metrics + ленивый ISO-8601-парсер для backoff
Build LLM Proxy / Build and push (release) Successful in 40s
Build LLM Proxy / Build and push (release) Successful in 40s
- GET /metrics (Prometheus text 0.0.4): llm_proxy_requests_total{model,upstream,provider,result}
(ok/4xx/429/402/5xx/net_err/cancelled), llm_proxy_upstream_inflight,
llm_proxy_upstream_fail_streak, llm_proxy_upstream_cooling_seconds
- учёт исходов в handleChat (каждая фейловер-попытка — отдельно)
- parseIsoDuration: PnM≈n×30d, PnY≈n×365d (kotlin.time Duration.parse не принимает Y/M)
- remainingSeconds округляет остаток вверх (открытое окно ≥1s)
- доки (CONFIG.md/README/TESTING) + тесты (MetricsTest, BackoffTest)
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
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 | 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<RequestKey, Long>()
|
||||
|
||||
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<String, UpstreamCounter>,
|
||||
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"
|
||||
}
|
||||
Reference in New Issue
Block a user