Observability #6

Merged
qpismont merged 6 commits from 1.1 into main 2026-07-26 21:55:42 +02:00
14 changed files with 450 additions and 169 deletions
+5 -1
View File
@@ -12,8 +12,12 @@
"containerEnv": { "containerEnv": {
"SHELL": "/bin/bash" "SHELL": "/bin/bash"
}, },
"customizations": {
"vscode": {
"extensions": ["fill-labs.dependi", "rust-lang.rust-analyzer", "tamasfe.even-better-toml"]
}
},
"workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/herald,type=bind", "workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/herald,type=bind",
"workspaceFolder": "/workspaces/herald", "workspaceFolder": "/workspaces/herald",
"runArgs": ["--userns=keep-id", "--security-opt", "label=disable"],
"appPort": [3000] "appPort": [3000]
} }
+3
View File
@@ -17,3 +17,6 @@ GITEA_TIMEOUT=30
SENTRY_DSN= SENTRY_DSN=
RUST_LOG=info RUST_LOG=info
RUST_BACKTRACE=1 RUST_BACKTRACE=1
METRICS_BIND_ADDR=
qpismont marked this conversation as resolved
Review

La variable d'environnement METRICS_BIND_ADDR est définie sans valeur par défaut ni commentaire. Il serait judicieux d'ajouter un exemple de format (ex: 0.0.0.0:9090) ou un commentaire indiquant le format attendu, afin d'éviter des erreurs de configuration.

La variable d'environnement `METRICS_BIND_ADDR` est définie sans valeur par défaut ni commentaire. Il serait judicieux d'ajouter un exemple de format (ex: `0.0.0.0:9090`) ou un commentaire indiquant le format attendu, afin d'éviter des erreurs de configuration.
+3
View File
@@ -0,0 +1,3 @@
{
"rust-analyzer.check.command": "clippy"
}
-11
View File
@@ -1,11 +0,0 @@
{
"lsp": {
"rust-analyzer": {
"initialization_options": {
"check": {
"command": "clippy"
}
}
}
}
}
Generated
+223 -98
View File
@@ -34,7 +34,7 @@ dependencies = [
"bytestring", "bytestring",
"derive_more", "derive_more",
"encoding_rs", "encoding_rs",
"foldhash", "foldhash 0.1.5",
"futures-core", "futures-core",
"http 0.2.12", "http 0.2.12",
"httparse", "httparse",
@@ -129,7 +129,7 @@ dependencies = [
"cfg-if", "cfg-if",
"derive_more", "derive_more",
"encoding_rs", "encoding_rs",
"foldhash", "foldhash 0.1.5",
"futures-core", "futures-core",
"futures-util", "futures-util",
"impl-more", "impl-more",
@@ -280,15 +280,6 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "block-buffer"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
dependencies = [
"hybrid-array",
]
[[package]] [[package]]
name = "block2" name = "block2"
version = "0.6.2" version = "0.6.2"
@@ -341,18 +332,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "cmov"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]] [[package]]
name = "convert_case" name = "convert_case"
version = "0.10.0" version = "0.10.0"
@@ -379,31 +358,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]] [[package]]
name = "cpufeatures" name = "crossbeam-epoch"
version = "0.3.0" version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [ dependencies = [
"libc", "crossbeam-utils",
] ]
[[package]] [[package]]
name = "crypto-common" name = "crossbeam-utils"
version = "0.2.2" version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
dependencies = [
"hybrid-array",
]
[[package]]
name = "ctutils"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
dependencies = [
"cmov",
]
[[package]] [[package]]
name = "darling" name = "darling"
@@ -523,18 +490,6 @@ dependencies = [
"unicode-xid", "unicode-xid",
] ]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer",
"const-oid",
"crypto-common",
"ctutils",
]
[[package]] [[package]]
name = "dispatch2" name = "dispatch2"
version = "0.3.1" version = "0.3.1"
@@ -605,6 +560,17 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "evmap"
version = "11.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b8874945f036109c72242964c1174cf99434e30cfa45bf45fedc983f50046f8"
dependencies = [
"hashbag",
"left-right",
"smallvec",
]
[[package]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.4.1" version = "2.4.1"
@@ -641,6 +607,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]] [[package]]
name = "foreign-types" name = "foreign-types"
version = "0.3.2" version = "0.3.2"
@@ -726,6 +698,21 @@ dependencies = [
"slab", "slab",
] ]
[[package]]
name = "generator"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae"
dependencies = [
"cc",
"cfg-if",
"libc",
"log",
"rustversion",
"windows-link",
"windows-result",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.17" version = "0.2.17"
@@ -778,6 +765,21 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "hashbag"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7040a10f52cba493ddb09926e15d10a9d8a28043708a405931fe4c6f19fac064"
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"foldhash 0.2.0",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.17.1" version = "0.17.1"
@@ -786,7 +788,7 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]] [[package]]
name = "herald" name = "herald"
version = "1.0.1" version = "1.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -794,15 +796,15 @@ dependencies = [
"dotenvy", "dotenvy",
"futures-util", "futures-util",
"hex", "hex",
"hmac", "metrics",
"metrics-exporter-prometheus",
"openrouter-rs", "openrouter-rs",
"reqwest 0.12.28", "reqwest 0.12.28",
"ring",
"sentry", "sentry",
"sentry-anyhow", "sentry-anyhow",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"subtle",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
@@ -819,15 +821,6 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
dependencies = [
"digest",
]
[[package]] [[package]]
name = "hostname" name = "hostname"
version = "0.4.2" version = "0.4.2"
@@ -895,15 +888,6 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hybrid-array"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da"
dependencies = [
"typenum",
]
[[package]] [[package]]
name = "hyper" name = "hyper"
version = "1.9.0" version = "1.9.0"
@@ -1103,7 +1087,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown", "hashbrown 0.17.1",
] ]
[[package]] [[package]]
@@ -1142,6 +1126,17 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "left-right"
version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bc015ded5d9b3054dbbdb63332cdd6ee42352ccef19e911e25117490e2f48ee"
dependencies = [
"crossbeam-utils",
"loom",
"slab",
]
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.186" version = "0.2.186"
@@ -1181,6 +1176,19 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "loom"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca"
dependencies = [
"cfg-if",
"generator",
"scoped-tls",
"tracing",
"tracing-subscriber",
]
[[package]] [[package]]
name = "lru-slab" name = "lru-slab"
version = "0.1.2" version = "0.1.2"
@@ -1208,12 +1216,70 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "metrics"
version = "0.24.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89550ee9f79e88fef3119de263694973a8adb26c21d75322164fb8c493039fe2"
dependencies = [
"portable-atomic",
"rapidhash",
]
[[package]]
name = "metrics-exporter-prometheus"
version = "0.18.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1db0d8f1fc9e62caebd0319e11eaec5822b0186c171568f0480b46a0137f9108"
dependencies = [
"base64",
"evmap",
"http-body-util",
"hyper",
"hyper-util",
"indexmap",
"ipnet",
"metrics",
"metrics-util",
"quanta",
"thiserror 2.0.18",
"tokio",
"tracing",
]
[[package]]
name = "metrics-util"
version = "0.20.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96f8722f8562635f92f8ed992f26df0532266eb03d5202607c20c0d7e9745e13"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
"hashbrown 0.16.1",
"metrics",
"quanta",
"rand",
"rand_xoshiro",
"rapidhash",
"sketches-ddsketch",
]
[[package]] [[package]]
name = "mime" name = "mime"
version = "0.3.17" version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@@ -1455,9 +1521,9 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]] [[package]]
name = "openrouter-rs" name = "openrouter-rs"
version = "0.10.0" version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f7101578df2f54d9013594e94367adbe656521ef6002f03e4577f1e00e57cb4" checksum = "25918cfeef40cfd3adea348b268496ee5206eb0408f2aacfe4bdfef2bfbe3bf9"
dependencies = [ dependencies = [
"derive_builder", "derive_builder",
"dotenvy_macro", "dotenvy_macro",
@@ -1602,6 +1668,12 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "portable-atomic"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
[[package]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.5" version = "0.1.5"
@@ -1635,6 +1707,21 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "quanta"
version = "0.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
dependencies = [
"crossbeam-utils",
"libc",
"once_cell",
"raw-cpuid",
"wasi",
"web-sys",
"winapi",
]
[[package]] [[package]]
name = "quinn" name = "quinn"
version = "0.11.9" version = "0.11.9"
@@ -1734,6 +1821,33 @@ dependencies = [
"getrandom 0.3.4", "getrandom 0.3.4",
] ]
[[package]]
name = "rand_xoshiro"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41"
dependencies = [
"rand_core",
]
[[package]]
name = "rapidhash"
version = "4.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e"
dependencies = [
"rustversion",
]
[[package]]
name = "raw-cpuid"
version = "11.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
dependencies = [
"bitflags",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@@ -1816,6 +1930,7 @@ dependencies = [
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"mime_guess",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"quinn", "quinn",
@@ -2007,6 +2122,12 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "scoped-tls"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
[[package]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.2.0" version = "1.2.0"
@@ -2268,17 +2389,6 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "sha2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]] [[package]]
name = "sharded-slab" name = "sharded-slab"
version = "0.1.7" version = "0.1.7"
@@ -2304,6 +2414,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "sketches-ddsketch"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b"
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@@ -2516,9 +2632,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.52.3" version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [ dependencies = [
"bytes", "bytes",
"libc", "libc",
@@ -2701,12 +2817,6 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]] [[package]]
name = "uname" name = "uname"
version = "0.1.1" version = "0.1.1"
@@ -2716,6 +2826,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
@@ -2981,6 +3097,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.52.0" version = "0.52.0"
+7 -7
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "herald" name = "herald"
version = "1.0.1" version = "1.1.0"
edition = "2024" edition = "2024"
[profile.release] [profile.release]
@@ -8,7 +8,7 @@ debug = 1
[dependencies] [dependencies]
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1.52", features = ["full"] } tokio = { version = "1.53", features = ["full"] }
tokio-stream = "0.1" tokio-stream = "0.1"
tokio-util = "0.7" tokio-util = "0.7"
futures-util = "0.3" futures-util = "0.3"
@@ -16,7 +16,7 @@ serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
sentry = { version = "0.48", features = ["tower-axum-matched-path"] } sentry = { version = "0.48", features = ["tower-axum-matched-path"] }
sentry-anyhow = { version = "0.48", features = ["backtrace"] } sentry-anyhow = { version = "0.48", features = ["backtrace"] }
openrouter-rs = "0.10" openrouter-rs = "0.12"
dotenvy = "0.15" dotenvy = "0.15"
tower = "0.5" tower = "0.5"
tower-http = {version = "0.6", features = ["trace"] } tower-http = {version = "0.6", features = ["trace"] }
@@ -25,8 +25,8 @@ tracing-subscriber = { version = "0.3", features=["env-filter"] }
axum = "0.8" axum = "0.8"
anyhow = { version = "1.0", features = ["backtrace"] } anyhow = { version = "1.0", features = ["backtrace"] }
thiserror = "2.0" thiserror = "2.0"
hmac = "0.13" ring = "0.17"
sha2 = "0.11"
hex = "0.4" hex = "0.4"
subtle = "2.6" bytes = "1.1"
bytes = "1.11" metrics = "0.24"
metrics-exporter-prometheus = { version = "0.18", default-features = false, features = ["http-listener"] }
+39
View File
@@ -9,6 +9,7 @@ Herald is a Gitea bot that performs automated AI-powered code reviews on pull re
- Concurrent review processing with configurable parallelism - Concurrent review processing with configurable parallelism
- Graceful shutdown — in-progress reviews finish before the process exits - Graceful shutdown — in-progress reviews finish before the process exits
- Error tracking via Sentry - Error tracking via Sentry
- Prometheus metrics endpoint for monitoring
- Tiny memory footprint (~4MB) thanks to Rust - Tiny memory footprint (~4MB) thanks to Rust
## Installation ## Installation
@@ -34,6 +35,7 @@ Herald reads its configuration from environment variables (a `.env` file is supp
| `GITEA_URL` | Base URL of your Gitea instance | | `GITEA_URL` | Base URL of your Gitea instance |
| `GITEA_TOKEN` | Gitea API token | | `GITEA_TOKEN` | Gitea API token |
| `GITEA_TIMEOUT` | Gitea API request timeout in seconds | | `GITEA_TIMEOUT` | Gitea API request timeout in seconds |
| `METRICS_BIND_ADDR` | *(optional)* Bind address for the Prometheus metrics endpoint (e.g. `0.0.0.0:9100`). If unset, the metrics exporter is disabled. |
| `SENTRY_DSN` | *(optional)* Sentry DSN for error tracking | | `SENTRY_DSN` | *(optional)* Sentry DSN for error tracking |
| `RUST_LOG` | *(optional)* Log level, defaults to `info` | | `RUST_LOG` | *(optional)* Log level, defaults to `info` |
@@ -51,3 +53,40 @@ cargo run
``` ```
Copy `.env.example` to `.env` and fill in your values before running. Copy `.env.example` to `.env` and fill in your values before running.
## Metrics
Herald optionally exposes a Prometheus metrics endpoint, useful for scraping with an [OpenTelemetry Collector](https://opentelemetry.io/docs/collector/) or any Prometheus-compatible scraper.
Set `METRICS_BIND_ADDR` (e.g. `0.0.0.0:9100`) to enable it. The metrics are then available at `http://<host>:9100/metrics`.
### Exposed metrics
| Metric | Type | Description |
|---|---|---|
| `herald_webhooks_received_total` | counter | Total webhooks received (label: `event_type`) |
| `herald_webhooks_duplicate_total` | counter | Webhooks rejected as duplicates (label: `event_type`) |
| `herald_webhooks_channel_full_total` | counter | Webhooks dropped because the bot channel was full (label: `event_type`) |
| `herald_bot_tasks_active` | gauge | Bot tasks currently in progress |
| `herald_bot_tasks_completed_total` | counter | Bot tasks completed successfully (label: `event_type`) |
| `herald_bot_tasks_failed_total` | counter | Bot tasks that failed (label: `event_type`) |
| `herald_openrouter_cost_cents_total` | counter | Total OpenRouter cost in cents (divide by 100 for USD) |
### OTel collector example
```yaml
receivers:
prometheus:
config:
scrape_configs:
- job_name: herald
scrape_interval: 15s
static_configs:
- targets: ["herald:9100"]
service:
pipelines:
metrics:
receivers: [prometheus]
exporters: [otlp]
```
+10 -11
View File
@@ -4,12 +4,10 @@ use axum::http::Request;
use axum::response::IntoResponse; use axum::response::IntoResponse;
use axum::routing::{get, post}; use axum::routing::{get, post};
use axum::{Json, Router}; use axum::{Json, Router};
use hmac::{Hmac, KeyInit, Mac};
use reqwest::StatusCode; use reqwest::StatusCode;
use ring::hmac;
use sentry::integrations::tower::{NewSentryLayer, SentryHttpLayer}; use sentry::integrations::tower::{NewSentryLayer, SentryHttpLayer};
use serde_json::Value; use serde_json::Value;
use sha2::Sha256;
use subtle::ConstantTimeEq;
use tower::ServiceBuilder; use tower::ServiceBuilder;
use tower_http::trace::TraceLayer; use tower_http::trace::TraceLayer;
use tracing::{info, instrument}; use tracing::{info, instrument};
@@ -19,6 +17,7 @@ use tokio_util::sync::CancellationToken;
use crate::consts::{GITEA_EVENT_TYPE_HEADER_NAME, GITEA_SIG_HEADER_NAME, MAX_WEBHOOK_BODY_SIZE}; use crate::consts::{GITEA_EVENT_TYPE_HEADER_NAME, GITEA_SIG_HEADER_NAME, MAX_WEBHOOK_BODY_SIZE};
use crate::errors::AppError; use crate::errors::AppError;
use crate::gitea::WebhookType; use crate::gitea::WebhookType;
use crate::metrics;
use crate::state::AppState; use crate::state::AppState;
pub async fn start(app_state: AppState, shutdown: CancellationToken) -> anyhow::Result<()> { pub async fn start(app_state: AppState, shutdown: CancellationToken) -> anyhow::Result<()> {
@@ -58,13 +57,18 @@ async fn webhook(
) -> Result<impl IntoResponse, AppError> { ) -> Result<impl IntoResponse, AppError> {
tracing::Span::current().record("webhook_type", tracing::field::debug(&wb)); tracing::Span::current().record("webhook_type", tracing::field::debug(&wb));
let event_type = wb.event_type_str();
metrics::webhook_received(event_type);
let event_id = wb.event_id(); let event_id = wb.event_id();
if app_state.bot.check_and_mark(event_id).await { if app_state.bot.check_and_mark(event_id).await {
metrics::webhook_duplicate(event_type);
return Err(AppError::AlreadyProcessedErr); return Err(AppError::AlreadyProcessedErr);
} }
if app_state.bot_tx.try_send(wb).is_err() { if app_state.bot_tx.try_send(wb).is_err() {
app_state.bot.unmark(event_id).await; app_state.bot.unmark(event_id).await;
metrics::webhook_channel_full(event_type);
return Err(AppError::ChannelFullErr); return Err(AppError::ChannelFullErr);
} }
@@ -105,7 +109,7 @@ where
}); });
}); });
let webhook = parse_webhook(&type_header, &app_state.config.bot_name, &body_bytes)?; let webhook = parse_webhook(&type_header, &app_state.bot.name(), &body_bytes)?;
Ok(WebhookExtract(webhook)) Ok(WebhookExtract(webhook))
} }
} }
@@ -137,12 +141,7 @@ fn parse_webhook(header: &str, bot_name: &str, body_bytes: &[u8]) -> Result<Webh
fn verify_signature(secret_key: &[u8], sig_header: &str, body: &[u8]) -> Result<(), AppError> { fn verify_signature(secret_key: &[u8], sig_header: &str, body: &[u8]) -> Result<(), AppError> {
let sig_header_decoded = let sig_header_decoded =
hex::decode(sig_header).map_err(|_| AppError::WebHookSigHeaderInvalidErr)?; hex::decode(sig_header).map_err(|_| AppError::WebHookSigHeaderInvalidErr)?;
let mut mac = Hmac::<Sha256>::new_from_slice(secret_key).map_err(anyhow::Error::from)?; let key = hmac::Key::new(hmac::HMAC_SHA256, secret_key);
mac.update(body); hmac::verify(&key, body, &sig_header_decoded).map_err(|_| AppError::WebHookSigHeaderInvalidErr)
let generated_hmac = mac.finalize().into_bytes();
bool::from(generated_hmac.ct_eq(&sig_header_decoded))
.then_some(())
.ok_or(AppError::WebHookSigHeaderInvalidErr)
} }
+34 -24
View File
@@ -1,10 +1,8 @@
use crate::{ use crate::{
env::EnvConfig, gitea::{GiteaAPI, WebhookType}, metrics, open_router::OpenRouterClient,
gitea::{GiteaAPI, WebhookType},
open_router::OpenRouterClient,
}; };
use serde::Deserialize; use serde::Deserialize;
use std::{collections::HashSet, sync::Arc, time::Duration}; use std::{collections::HashSet, sync::Arc};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use tracing::{error, info, instrument}; use tracing::{error, info, instrument};
@@ -26,33 +24,37 @@ pub struct ReviewItem {
#[derive(Clone)] #[derive(Clone)]
pub struct Bot { pub struct Bot {
config: EnvConfig, bot_name: String,
gitea_api: GiteaAPI, gitea_api: GiteaAPI,
open_router_client: OpenRouterClient, open_router_client: OpenRouterClient,
http_client: reqwest::Client, http_client: reqwest::Client,
max_concurrent: usize, max_concurrent: usize,
open_router_model: String,
actions_handled: Arc<Mutex<HashSet<u64>>>, actions_handled: Arc<Mutex<HashSet<u64>>>,
} }
impl Bot { impl Bot {
pub fn new(config: EnvConfig) -> anyhow::Result<Self> { pub fn new(
let gitea_timeout = config.gitea_timeout; bot_name: String,
let open_router_timeout = config.open_router_timeout; gitea_api: GiteaAPI,
open_router_client: OpenRouterClient,
Ok(Self { http_client: reqwest::Client,
gitea_api: GiteaAPI::new(&config.gitea_url, &config.gitea_token, gitea_timeout)?, max_concurrent: usize,
open_router_client: OpenRouterClient::new( open_router_model: String,
&config.open_router_api_key, ) -> Self {
&config.open_router_model, Self {
open_router_timeout, bot_name,
)?, gitea_api,
max_concurrent: config.bot_max_concurrent, open_router_client,
config, http_client,
max_concurrent,
open_router_model,
actions_handled: Arc::new(Mutex::new(HashSet::new())), actions_handled: Arc::new(Mutex::new(HashSet::new())),
http_client: reqwest::Client::builder() }
.timeout(Duration::from_secs(gitea_timeout)) }
.build()?,
}) pub fn name(&self) -> String {
qpismont marked this conversation as resolved
Review

La méthode name() retourne une String en clonant self.bot_name. Cela peut être inefficace si appelée fréquemment. Envisagez de retourner un &str pour éviter une allocation, ou changez le champ en Arc<str> si un partage est nécessaire.

La méthode `name()` retourne une `String` en clonant `self.bot_name`. Cela peut être inefficace si appelée fréquemment. Envisagez de retourner un `&str` pour éviter une allocation, ou changez le champ en `Arc<str>` si un partage est nécessaire.
self.bot_name.clone()
} }
pub async fn start( pub async fn start(
@@ -84,10 +86,12 @@ impl Bot {
info!(queued = rx.len(), active = tasks.len(), "Webhook received"); info!(queued = rx.len(), active = tasks.len(), "Webhook received");
let permit = sem.clone().acquire_owned().await?; let permit = sem.clone().acquire_owned().await?;
let self_clone = self.clone(); let self_clone = self.clone();
metrics::increment_task_active();
tasks.spawn(async move { tasks.spawn(async move {
self_clone.exec(wb).await; self_clone.exec(wb).await;
drop(permit); drop(permit);
metrics::decrement_task_active();
}); });
} }
@@ -99,6 +103,8 @@ impl Bot {
#[instrument(skip(self, webhook), fields(repo, pr))] #[instrument(skip(self, webhook), fields(repo, pr))]
pub async fn exec(&self, webhook: WebhookType) { pub async fn exec(&self, webhook: WebhookType) {
let event_type_str = webhook.event_type_str();
match &webhook { match &webhook {
WebhookType::Review(p) => { WebhookType::Review(p) => {
tracing::Span::current().record("repo", &p.repository.full_name); tracing::Span::current().record("repo", &p.repository.full_name);
@@ -111,15 +117,19 @@ impl Bot {
&self.gitea_api, &self.gitea_api,
&self.open_router_client, &self.open_router_client,
&self.http_client, &self.http_client,
&self.config.open_router_model, &self.open_router_model,
review_payload, review_payload,
), ),
} }
.await; .await;
match exec_result { match exec_result {
Ok(_) => info!("Task completed"), Ok(_) => {
metrics::task_completed(event_type_str);
info!("Task completed");
}
Err(err) => { Err(err) => {
metrics::task_failed(event_type_str);
error!(%err, "Task error"); error!(%err, "Task error");
sentry_anyhow::capture_anyhow(&err); sentry_anyhow::capture_anyhow(&err);
} }
+4 -4
View File
@@ -4,10 +4,7 @@ use tokio_util::io::StreamReader;
use tracing::instrument; use tracing::instrument;
use crate::{ use crate::{
bot::ReviewResult, bot::ReviewResult, consts::{BOT_PROCESS_MSG, MAX_DIFF_SIZE, REVIEW_PROMPT}, gitea::{GiteaAPI, ReviewPayload}, metrics, open_router::OpenRouterClient,
consts::{BOT_PROCESS_MSG, MAX_DIFF_SIZE, REVIEW_PROMPT},
gitea::{GiteaAPI, ReviewPayload},
open_router::OpenRouterClient,
}; };
#[instrument(skip(gitea_api, open_router_client, http_client, review_payload))] #[instrument(skip(gitea_api, open_router_client, http_client, review_payload))]
@@ -48,6 +45,9 @@ pub async fn exec_review(
let mut review_result = serde_json::from_str::<ReviewResult>(&chat_result.message)?; let mut review_result = serde_json::from_str::<ReviewResult>(&chat_result.message)?;
review_result.cost = chat_result.cost; review_result.cost = chat_result.cost;
if let Some(cost) = review_result.cost {
metrics::openrouter_cost_usd(cost);
}
let final_review_markdown = review_result_to_markdown(&review_result); let final_review_markdown = review_result_to_markdown(&review_result);
+3 -3
View File
@@ -7,16 +7,15 @@ pub struct EnvConfig {
pub open_router_api_key: String, pub open_router_api_key: String,
pub open_router_model: String, pub open_router_model: String,
pub open_router_timeout: u64, pub open_router_timeout: u64,
pub bot_name: String,
pub bot_max_concurrent: usize, pub bot_max_concurrent: usize,
pub gitea_url: String, pub gitea_url: String,
pub gitea_token: String, pub gitea_token: String,
pub gitea_timeout: u64, pub gitea_timeout: u64,
pub metrics_bind_addr: Option<String>,
} }
pub fn load_config() -> anyhow::Result<EnvConfig> { pub fn load_config() -> anyhow::Result<EnvConfig> {
let http_port = try_get_env("HTTP_PORT")?.parse()?; let http_port = try_get_env("HTTP_PORT")?.parse()?;
let bot_name = try_get_env("BOT_NAME")?;
let webhook_secret = try_get_env("WEBHOOK_SIG_HEADER_SECRET")?; let webhook_secret = try_get_env("WEBHOOK_SIG_HEADER_SECRET")?;
let open_router_api_key = try_get_env("OPEN_ROUTER_API_KEY")?; let open_router_api_key = try_get_env("OPEN_ROUTER_API_KEY")?;
let open_router_model = try_get_env("OPEN_ROUTER_MODEL")?; let open_router_model = try_get_env("OPEN_ROUTER_MODEL")?;
@@ -25,11 +24,11 @@ pub fn load_config() -> anyhow::Result<EnvConfig> {
let gitea_url = try_get_env("GITEA_URL")?; let gitea_url = try_get_env("GITEA_URL")?;
let gitea_token = try_get_env("GITEA_TOKEN")?; let gitea_token = try_get_env("GITEA_TOKEN")?;
let gitea_timeout = try_get_env("GITEA_TIMEOUT")?.parse()?; let gitea_timeout = try_get_env("GITEA_TIMEOUT")?.parse()?;
let metrics_bind_addr = std::env::var("METRICS_BIND_ADDR").ok();
Ok(EnvConfig { Ok(EnvConfig {
http_port, http_port,
webhook_secret, webhook_secret,
bot_name,
open_router_api_key, open_router_api_key,
open_router_model, open_router_model,
open_router_timeout, open_router_timeout,
@@ -37,6 +36,7 @@ pub fn load_config() -> anyhow::Result<EnvConfig> {
gitea_url, gitea_url,
gitea_token, gitea_token,
gitea_timeout, gitea_timeout,
metrics_bind_addr,
}) })
} }
+32 -5
View File
@@ -29,6 +29,21 @@ impl GiteaAPI {
}) })
} }
#[instrument(skip(self))]
pub async fn get_authorized_user(&self) -> anyhow::Result<User> {
let url = format!("{}/api/v1/user", self.base_url);
let res = self.client.get(url).send().await?;
if !res.status().is_success() {
return Err(anyhow::anyhow!(
"Failed to get authorized user: {}",
res.status()
));
}
res.json::<User>().await.map_err(anyhow::Error::from)
}
#[instrument(skip(self))] #[instrument(skip(self))]
pub async fn comment( pub async fn comment(
&self, &self,
@@ -164,6 +179,12 @@ impl WebhookType {
WebhookType::Review(payload) => payload.comment.id, WebhookType::Review(payload) => payload.comment.id,
} }
} }
pub fn event_type_str(&self) -> &'static str {
match self {
WebhookType::Review(_) => "review",
}
}
} }
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
@@ -192,6 +213,7 @@ pub struct Comment {
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct User { pub struct User {
pub id: u64, pub id: u64,
pub login: String,
} }
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
@@ -248,7 +270,8 @@ mod tests {
"id": 7, "id": 7,
"body": "@test_bot LGTM", "body": "@test_bot LGTM",
"user": { "user": {
"id": 100 "id": 100,
"login": "test_user"
} }
} }
}); });
@@ -312,7 +335,8 @@ mod tests {
"id": 1, "id": 1,
"body": "@test_bot body", "body": "@test_bot body",
"user": { "user": {
"id": 1 "id": 1,
"login": "test_user"
} }
} }
}); });
@@ -343,7 +367,8 @@ mod tests {
"id": 12, "id": 12,
"body": "Needs work", "body": "Needs work",
"user": { "user": {
"id": 200 "id": 200,
"login": "test_user"
} }
} }
}); });
@@ -380,7 +405,8 @@ mod tests {
"id": 1, "id": 1,
"body": "@other_bot do something", "body": "@other_bot do something",
"user": { "user": {
"id": 1 "id": 1,
"login": "test_user"
} }
} }
}); });
@@ -406,7 +432,8 @@ mod tests {
"id": 1, "id": 1,
"body": "just a comment without bot mention", "body": "just a comment without bot mention",
"user": { "user": {
"id": 1 "id": 1,
"login": "test_user"
} }
} }
}); });
+25 -5
View File
@@ -1,6 +1,4 @@
use std::sync::Arc; use crate::{bot::Bot, gitea::{GiteaAPI, WebhookType}, open_router::OpenRouterClient, state::AppState};
use crate::{bot::Bot, gitea::WebhookType, state::AppState};
use dotenvy::dotenv; use dotenvy::dotenv;
use tokio::signal::unix::{SignalKind, signal}; use tokio::signal::unix::{SignalKind, signal};
@@ -15,6 +13,7 @@ mod consts;
mod env; mod env;
mod errors; mod errors;
mod gitea; mod gitea;
mod metrics;
mod open_router; mod open_router;
mod state; mod state;
@@ -51,17 +50,38 @@ fn main() -> anyhow::Result<()> {
async fn run() -> anyhow::Result<()> { async fn run() -> anyhow::Result<()> {
let config = env::load_config()?; let config = env::load_config()?;
if let Some(metric_bind_addr) = &config.metrics_bind_addr {
qpismont marked this conversation as resolved
Review

Le démarrage conditionnel de l'exportateur Prometheus est une bonne pratique. Toutefois, si l'installation échoue, l'erreur est propagée via ?, ce qui met fin à l'application. Cela peut être acceptable, mais un message d'erreur plus explicite (ex: avec tracing::error) aiderait au débogage.

Le démarrage conditionnel de l'exportateur Prometheus est une bonne pratique. Toutefois, si l'installation échoue, l'erreur est propagée via `?`, ce qui met fin à l'application. Cela peut être acceptable, mais un message d'erreur plus explicite (ex: avec `tracing::error`) aiderait au débogage.
metrics::install(metric_bind_addr)?;
}
let gitea_api = GiteaAPI::new(&config.gitea_url, &config.gitea_token, config.gitea_timeout)?;
let gitea_user = gitea_api.get_authorized_user().await?;
info!( info!(
port = config.http_port, port = config.http_port,
model = %config.open_router_model, model = %config.open_router_model,
gitea_url = %config.gitea_url, gitea_url = %config.gitea_url,
bot_name = %config.bot_name, bot_name = %gitea_user.login,
"Starting Herald" "Starting Herald"
); );
let open_router_client = OpenRouterClient::new(
&config.open_router_api_key,
&config.open_router_model,
config.open_router_timeout,
)?;
let shutdown = CancellationToken::new(); let shutdown = CancellationToken::new();
let bot = Bot::new(config.clone())?; let bot = Bot::new(
gitea_user.login,
gitea_api,
open_router_client,
reqwest::Client::new(),
config.bot_max_concurrent,
config.open_router_model.clone(),
);
let (tx, rx) = tokio::sync::mpsc::channel::<WebhookType>(config.bot_max_concurrent * 2); let (tx, rx) = tokio::sync::mpsc::channel::<WebhookType>(config.bot_max_concurrent * 2);
let app_state = AppState { let app_state = AppState {
bot_tx: tx, bot_tx: tx,
+62
View File
@@ -0,0 +1,62 @@
use std::{net::SocketAddr, str::FromStr};
use metrics::{Unit, describe_counter, describe_gauge, counter, gauge};
pub fn webhook_received(event_type: &str) {
counter!("herald_webhooks_received_total", "event_type" => event_type.to_string())
.increment(1);
}
pub fn webhook_duplicate(event_type: &str) {
counter!("herald_webhooks_duplicate_total", "event_type" => event_type.to_string())
.increment(1);
}
pub fn webhook_channel_full(event_type: &str) {
counter!("herald_webhooks_channel_full_total", "event_type" => event_type.to_string())
.increment(1);
}
pub fn increment_task_active() {
gauge!("herald_bot_tasks_active").increment(1.0);
}
pub fn decrement_task_active() {
gauge!("herald_bot_tasks_active").decrement(1.0);
}
pub fn task_completed(event_type: &str) {
counter!("herald_bot_tasks_completed_total", "event_type" => event_type.to_string())
.increment(1);
}
pub fn task_failed(event_type: &str) {
counter!("herald_bot_tasks_failed_total", "event_type" => event_type.to_string())
.increment(1);
}
pub fn openrouter_cost_usd(cost: f64) {
qpismont marked this conversation as resolved Outdated
Outdated
Review

La métrique herald_openrouter_cost_usd_total utilise un compteur de type Count, mais elle représente un coût en dollars. Bien que cela soit fonctionnel, il serait plus clair d'utiliser un type Gauge ou d'ajouter une unité personnalisée si possible. Actuellement l'unité Count peut prêter à confusion pour les opérateurs.

La métrique `herald_openrouter_cost_usd_total` utilise un compteur de type `Count`, mais elle représente un coût en dollars. Bien que cela soit fonctionnel, il serait plus clair d'utiliser un type `Gauge` ou d'ajouter une unité personnalisée si possible. Actuellement l'unité `Count` peut prêter à confusion pour les opérateurs.
counter!("herald_openrouter_cost_cents_total").increment((cost * 100.0).round() as u64);
}
pub fn describe() {
describe_counter!("herald_webhooks_received_total", Unit::Count, "Total webhooks received");
describe_counter!("herald_webhooks_duplicate_total", Unit::Count, "Webhooks rejected as duplicates");
describe_counter!("herald_webhooks_channel_full_total", Unit::Count, "Webhooks dropped because the bot channel was full");
describe_gauge!("herald_bot_tasks_active", Unit::Count, "Bot tasks currently in progress");
describe_counter!("herald_bot_tasks_completed_total", Unit::Count, "Bot tasks completed successfully");
describe_counter!("herald_bot_tasks_failed_total", Unit::Count, "Bot tasks that failed");
describe_counter!("herald_openrouter_cost_cents_total", Unit::Count, "Total OpenRouter cost in cents (divide by 100 for USD)");
}
pub fn install(bind_addr: &str) -> anyhow::Result<()> {
describe();
let builder = metrics_exporter_prometheus::PrometheusBuilder::new();
builder
.with_http_listener(SocketAddr::from_str(bind_addr)?)
.install()?;
tracing::info!(bind_addr, "Prometheus metrics exporter installed");
Ok(())
}