feat(observability): suppress successful health logs

This commit is contained in:
2026-07-22 14:40:07 +08:00
parent 49330744e5
commit c54f4214df
2 changed files with 91 additions and 3 deletions

View File

@@ -63,6 +63,15 @@ curl -fsS http://127.0.0.1:39180/health
仪表盘适合浏览和筛选;需要精确关联请求或比较版本时,打开 `Explore`,数据源选择 `Loki`
仪表盘包含两个日志面板:
- `HTTP Errors`:只显示能够解析出 HTTP `status >= 400` 的结构化日志。
- `Service Logs (health excluded)`:显示所选服务的普通日志,并默认排除例行 `/health` 请求。
API 不记录成功 `/health` 请求的开始和结束事件,以避免 Docker 日志与 Loki 被固定频率的探针日志占满。
如果 `/health` 返回非 2xx 或请求在中间件中失败,错误日志仍会保留。需要查看改造前的历史健康检查日志时,
`Explore` 中使用明确包含 `/health` 的查询,并把时间范围设置到对应部署时间之前。
## 常用 LogQL
查看教学反馈的全部日志:
@@ -71,6 +80,12 @@ curl -fsS http://127.0.0.1:39180/health
{application="teaching-feedback"}
```
排除改造前已采集的健康检查日志:
```logql
{application="teaching-feedback", service="api"} != "\"path\":\"/health\""
```
按服务、级别和文本筛选:
```logql

View File

@@ -12,7 +12,7 @@ use std::{path::PathBuf, sync::Arc, time::Duration};
use axum::{
Json, Router,
extract::{DefaultBodyLimit, MatchedPath},
http::{HeaderName, HeaderValue, Request},
http::{HeaderName, HeaderValue, Request, StatusCode},
response::Response,
routing::get,
};
@@ -55,6 +55,29 @@ impl From<&Config> for LogContext {
}
}
fn should_log_http_request_started(path: &str) -> bool {
path != "/health"
}
fn should_log_http_response(is_health_check: bool, status: StatusCode) -> bool {
!is_health_check || !status.is_success()
}
#[derive(Clone, Copy, Debug)]
struct HealthCheckResponse;
async fn mark_health_check_response(
request: axum::extract::Request,
next: axum::middleware::Next,
) -> Response {
let is_health_check = request.uri().path() == "/health";
let mut response = next.run(request).await;
if is_health_check {
response.extensions_mut().insert(HealthCheckResponse);
}
response
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
dotenvy::dotenv().ok();
@@ -161,6 +184,9 @@ fn build_app(
make_http_request_span(request, &request_span_context)
})
.on_request(move |request: &Request<_>, span: &Span| {
if !should_log_http_request_started(request.uri().path()) {
return;
}
tracing::info!(
parent: span,
service = "api",
@@ -174,6 +200,11 @@ fn build_app(
})
.on_response(move |response: &Response, latency: Duration, span: &Span| {
let status = response.status();
let is_health_check =
response.extensions().get::<HealthCheckResponse>().is_some();
if !should_log_http_response(is_health_check, status) {
return;
}
let latency_ms = u64::try_from(latency.as_millis()).unwrap_or(u64::MAX);
let request_id = response
.headers()
@@ -226,6 +257,7 @@ fn build_app(
},
),
)
.layer(axum::middleware::from_fn(mark_health_check_response))
.layer(PropagateRequestIdLayer::x_request_id()),
))
}
@@ -304,11 +336,17 @@ async fn shutdown_signal(log_context: LogContext) {
#[cfg(test)]
mod tests {
use axum::{body::Body, http::Request};
use axum::{
body::Body,
http::{Request, StatusCode},
};
use tower::ServiceExt;
use uuid::Uuid;
use super::{AppState, LogContext, build_app};
use super::{
AppState, HealthCheckResponse, LogContext, build_app, should_log_http_request_started,
should_log_http_response,
};
use crate::{
auth::AuthService, config::AuthConfig, speech::SpeechService, summary::SummaryService,
};
@@ -336,6 +374,41 @@ mod tests {
.unwrap()
}
#[test]
fn successful_health_request_is_not_logged() {
assert!(!should_log_http_request_started("/health"));
assert!(!should_log_http_response(true, StatusCode::OK));
}
#[test]
fn failed_health_request_is_logged() {
assert!(should_log_http_response(
true,
StatusCode::INTERNAL_SERVER_ERROR
));
}
#[test]
fn ordinary_successful_request_is_logged() {
assert!(should_log_http_request_started("/api/v1/profiles"));
assert!(should_log_http_response(false, StatusCode::OK));
}
#[tokio::test]
async fn health_response_is_marked_for_log_filtering() {
let response = app()
.oneshot(
Request::builder()
.uri("/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert!(response.extensions().get::<HealthCheckResponse>().is_some());
}
#[tokio::test]
async fn health_generates_a_uuid_request_id() {
let response = app()