наблюдаемость: ввести безопасный контракт метрик
CI / Rust Checks (pull_request) Successful in 6m15s
CI / UI Checks (pull_request) Successful in 5s
CI / Community Image Smoke (pull_request) Successful in 4m25s
CI / Frontend E2E (pull_request) Successful in 5m17s
CI / Deploy (pull_request) Has been skipped
CI / Rust Checks (push) Successful in 6m9s
CI / UI Checks (push) Successful in 5s
CI / Community Image Smoke (push) Successful in 1m3s
CI / Frontend E2E (push) Successful in 3m47s
CI / Deploy (push) Failing after 3s

This commit is contained in:
2026-07-31 05:04:01 +03:00
parent ec2453c00f
commit 9b1a739e39
50 changed files with 3066 additions and 433 deletions
+5 -7
View File
@@ -14,13 +14,11 @@ pub fn record_operational_incident(incident: OperationalIncident) {
});
match incident {
OperationalIncident::InvocationHistoryLost => {
metrics::counter!("crank_invocation_history_lost_total").increment(1);
metrics::counter!(
"crank_telemetry_export_failures_total",
"signal_type" => "invocation_history",
"exporter" => "postgres"
)
.increment(1);
crank_metrics::record_invocation_history_lost();
crank_metrics::record_export_failure(
crank_metrics::SignalType::InvocationHistory,
crank_metrics::Exporter::Postgres,
);
}
}
}
@@ -5,7 +5,8 @@ use axum::{
middleware::Next,
response::Response,
};
use metrics::{Gauge, Unit};
use crank_metrics::{DbPoolState, HttpMethod, HttpRoute, HttpStatusClass, InFlightGuard};
use metrics::Unit;
use crate::{MetricKind, MetricUnit, metric_schema};
@@ -13,36 +14,28 @@ pub async fn record_http_request(request: Request, next: Next) -> Response {
let route = request
.extensions()
.get::<MatchedPath>()
.map_or("unmatched", MatchedPath::as_str)
.to_owned();
let method = normalized_http_method(request.method().as_str());
.map_or(HttpRoute::unmatched(), |path| {
HttpRoute::from_matched_path(path.as_str())
});
let method = HttpMethod::classify(request.method().as_str());
let started_at = Instant::now();
let _inflight = GaugeGuard::increment("crank_http_inflight");
let _inflight = InFlightGuard::http();
let response = next.run(request).await;
let status_class = status_class(response.status().as_u16());
metrics::counter!(
"crank_http_requests_total",
"route" => route.clone(),
"method" => method,
"status_class" => status_class
)
.increment(1);
metrics::histogram!(
"crank_http_request_duration_seconds",
"route" => route,
"method" => method
)
.record(started_at.elapsed().as_secs_f64());
crank_metrics::record_http_request(
route,
method,
HttpStatusClass::from_status(response.status().as_u16()),
started_at.elapsed(),
);
response
}
pub fn record_db_pool_connections(total: u32, idle: usize) {
let idle = idle.min(total as usize) as f64;
metrics::gauge!("crank_db_pool_connections", "state" => "idle").set(idle);
metrics::gauge!("crank_db_pool_connections", "state" => "used").set(f64::from(total) - idle);
let idle = u32::try_from(idle.min(total as usize)).unwrap_or(total);
crank_metrics::set_db_pool_connections(DbPoolState::Idle, idle);
crank_metrics::set_db_pool_connections(DbPoolState::Used, total.saturating_sub(idle));
}
pub(crate) fn register_metric_schema() {
@@ -64,70 +57,29 @@ pub(crate) fn register_metric_schema() {
}
}
metrics::gauge!("crank_http_inflight").set(0.0);
metrics::gauge!("crank_mcp_active_sessions").set(0.0);
metrics::gauge!("crank_runtime_inflight").set(0.0);
metrics::gauge!("crank_db_pool_connections", "state" => "idle").set(0.0);
metrics::gauge!("crank_db_pool_connections", "state" => "used").set(0.0);
metrics::gauge!("crank_catalog_tools").set(0.0);
metrics::gauge!("crank_catalog_estimated_context_tokens").set(0.0);
metrics::gauge!("crank_catalog_warnings").set(0.0);
}
fn normalized_http_method(method: &str) -> &'static str {
match method {
"GET" => "GET",
"POST" => "POST",
"PUT" => "PUT",
"PATCH" => "PATCH",
"DELETE" => "DELETE",
"OPTIONS" => "OPTIONS",
"HEAD" => "HEAD",
"CONNECT" => "CONNECT",
"TRACE" => "TRACE",
_ => "OTHER",
}
}
fn status_class(status: u16) -> &'static str {
match status {
100..=199 => "1xx",
200..=299 => "2xx",
300..=399 => "3xx",
400..=499 => "4xx",
500..=599 => "5xx",
_ => "other",
}
}
struct GaugeGuard {
gauge: Gauge,
}
impl GaugeGuard {
fn increment(name: &'static str) -> Self {
let gauge = metrics::gauge!(name);
gauge.increment(1.0);
Self { gauge }
}
}
impl Drop for GaugeGuard {
fn drop(&mut self) {
self.gauge.decrement(1.0);
}
crank_metrics::initialize_gauges();
}
#[cfg(test)]
mod tests {
use super::{normalized_http_method, status_class};
use crank_metrics::{HttpMethod, HttpRoute, HttpStatusClass};
#[test]
fn normalizes_unbounded_http_values() {
assert_eq!(normalized_http_method("GET"), "GET");
assert_eq!(normalized_http_method("CUSTOM-user-controlled"), "OTHER");
assert_eq!(status_class(204), "2xx");
assert_eq!(status_class(429), "4xx");
assert_eq!(status_class(999), "other");
assert_eq!(HttpMethod::classify("GET"), HttpMethod::Get);
assert_eq!(
HttpMethod::classify("CUSTOM-user-controlled"),
HttpMethod::Other
);
assert_eq!(HttpStatusClass::from_status(204), HttpStatusClass::Success);
assert_eq!(
HttpStatusClass::from_status(429),
HttpStatusClass::ClientError
);
assert_eq!(HttpStatusClass::from_status(999), HttpStatusClass::Other);
assert_eq!(
HttpRoute::from_matched_path("/documents/{document_id}"),
HttpRoute::unmatched()
);
}
}
+3 -4
View File
@@ -5,7 +5,6 @@ mod incidents;
mod instrumentation;
mod lifecycle;
mod logging;
mod metrics_schema;
mod otlp;
mod prometheus;
mod propagation;
@@ -14,6 +13,9 @@ mod schema;
pub use config::{ObservabilityConfig, ObservabilityConfigError, ServiceIdentity};
pub use correlation::RequestId;
pub use crank_metrics::{
DURATION_BUCKETS_SECONDS, MetricDefinition, MetricKind, MetricUnit, metric_schema,
};
pub use error_reporting::{
CriticalErrorCategory, SentryConfig, SentryConfigError, capture_critical_error,
with_request_correlation,
@@ -22,9 +24,6 @@ pub use incidents::{OperationalIncident, operational_incident_total, record_oper
pub use instrumentation::{record_db_pool_connections, record_http_request};
pub use lifecycle::{ObservabilityInitError, ObservabilityLifecycle, init};
pub use logging::build_subscriber;
pub use metrics_schema::{
DURATION_BUCKETS_SECONDS, MetricDefinition, MetricKind, MetricUnit, metric_schema,
};
pub use otlp::{
OtlpBatchConfig, OtlpTraceConfig, OtlpTraceConfigError, OtlpTraceError, build_tracer_provider,
};
@@ -1,159 +0,0 @@
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MetricKind {
Counter,
Gauge,
Histogram,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MetricUnit {
Count,
Seconds,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct MetricDefinition {
pub name: &'static str,
pub kind: MetricKind,
pub unit: MetricUnit,
pub labels: &'static [&'static str],
pub description: &'static str,
}
pub const DURATION_BUCKETS_SECONDS: &[f64] = &[
0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0,
];
const METRIC_SCHEMA: &[MetricDefinition] = &[
counter(
"crank_http_requests_total",
&["route", "method", "status_class"],
"Total HTTP requests.",
),
histogram(
"crank_http_request_duration_seconds",
&["route", "method"],
"HTTP request duration in seconds.",
),
gauge(
"crank_http_inflight",
&[],
"HTTP requests currently being processed.",
),
counter(
"crank_mcp_requests_total",
&["method", "response_mode", "outcome"],
"Total MCP JSON-RPC requests.",
),
gauge(
"crank_mcp_active_sessions",
&[],
"Active MCP transport sessions.",
),
counter(
"crank_tool_invocations_total",
&["source", "outcome", "error_kind"],
"Total tool invocations.",
),
histogram(
"crank_tool_invocation_duration_seconds",
&["source", "outcome"],
"Tool invocation duration in seconds.",
),
counter(
"crank_upstream_requests_total",
&["operation_kind", "outcome"],
"Total upstream requests.",
),
histogram(
"crank_upstream_request_duration_seconds",
&["operation_kind", "outcome"],
"Upstream request duration in seconds.",
),
gauge(
"crank_runtime_inflight",
&[],
"Runtime executions currently in progress.",
),
counter(
"crank_runtime_limit_rejections_total",
&["stage"],
"Runtime executions rejected by a bounded limit.",
),
gauge(
"crank_db_pool_connections",
&["state"],
"PostgreSQL pool connections by state.",
),
gauge(
"crank_catalog_tools",
&[],
"Tools in the current published catalog.",
),
gauge(
"crank_catalog_estimated_context_tokens",
&[],
"Estimated context tokens in the current published catalog.",
),
gauge(
"crank_catalog_warnings",
&[],
"Warnings in the current published catalog.",
),
counter(
"crank_invocation_history_lost_total",
&[],
"Invocation history records lost after an action completed.",
),
counter(
"crank_telemetry_export_failures_total",
&["signal_type", "exporter"],
"Telemetry export failures.",
),
];
pub const fn metric_schema() -> &'static [MetricDefinition] {
METRIC_SCHEMA
}
const fn counter(
name: &'static str,
labels: &'static [&'static str],
description: &'static str,
) -> MetricDefinition {
MetricDefinition {
name,
kind: MetricKind::Counter,
unit: MetricUnit::Count,
labels,
description,
}
}
const fn gauge(
name: &'static str,
labels: &'static [&'static str],
description: &'static str,
) -> MetricDefinition {
MetricDefinition {
name,
kind: MetricKind::Gauge,
unit: MetricUnit::Count,
labels,
description,
}
}
const fn histogram(
name: &'static str,
labels: &'static [&'static str],
description: &'static str,
) -> MetricDefinition {
MetricDefinition {
name,
kind: MetricKind::Histogram,
unit: MetricUnit::Seconds,
labels,
description,
}
}
+4 -6
View File
@@ -340,12 +340,10 @@ impl SpanExporterTrait for ObservedSpanExporter {
sanitize_trace_batch(&mut batch);
let result = self.0.export(batch).await;
if result.is_err() {
metrics::counter!(
"crank_telemetry_export_failures_total",
"signal_type" => "trace",
"exporter" => "otlp"
)
.increment(1);
crank_metrics::record_export_failure(
crank_metrics::SignalType::Trace,
crank_metrics::Exporter::Otlp,
);
}
result
}
+18 -5
View File
@@ -155,7 +155,7 @@ impl MetricsSurface {
Router::new()
.route("/metrics", get(render_metrics))
.route("/health", get(metrics_health))
.layer(middleware::from_fn_with_state(
.route_layer(middleware::from_fn_with_state(
self.state.clone(),
authorize_metrics,
))
@@ -179,6 +179,12 @@ pub struct MetricsServer {
}
impl MetricsServer {
pub fn local_addr(&self) -> Result<SocketAddr, MetricsServeError> {
self.listener
.local_addr()
.map_err(|_| MetricsServeError::LocalAddress)
}
pub async fn serve(self) -> Result<(), MetricsServeError> {
axum::serve(self.listener, self.router)
.await
@@ -198,6 +204,8 @@ pub enum MetricsServeError {
Bind,
#[error("metrics listener stopped unexpectedly")]
Serve,
#[error("failed to read metrics listener address")]
LocalAddress,
}
pub(crate) fn install_prometheus_recorder(
@@ -257,10 +265,15 @@ async fn authorize_metrics(
}
fn bearer_token(headers: &HeaderMap) -> Option<&[u8]> {
headers
.get(header::AUTHORIZATION)?
.as_bytes()
.strip_prefix(b"Bearer ")
let value = headers.get(header::AUTHORIZATION)?.as_bytes();
let separator = value.iter().position(|byte| *byte == b' ')?;
let (scheme, token_with_spaces) = value.split_at(separator);
let token_start = token_with_spaces.iter().position(|byte| *byte != b' ')?;
let token = &token_with_spaces[token_start..];
scheme
.eq_ignore_ascii_case(b"bearer")
.then_some(token)
.filter(|token| !token.is_empty())
}