наблюдаемость: ввести безопасный контракт метрик
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
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:
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "crank-metrics"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
metrics.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
metrics-util = "0.20.4"
|
||||
@@ -0,0 +1,431 @@
|
||||
macro_rules! string_enum {
|
||||
(
|
||||
$(#[$meta:meta])*
|
||||
pub enum $name:ident {
|
||||
$($variant:ident => $value:literal),+ $(,)?
|
||||
}
|
||||
) => {
|
||||
$(#[$meta])*
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum $name {
|
||||
$($variant),+
|
||||
}
|
||||
|
||||
impl $name {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
$(Self::$variant => $value),+
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct HttpRoute(&'static str);
|
||||
|
||||
impl HttpRoute {
|
||||
pub const fn unmatched() -> Self {
|
||||
Self("unmatched")
|
||||
}
|
||||
|
||||
pub fn from_matched_path(path: &str) -> Self {
|
||||
match path {
|
||||
"/health" => Self("/health"),
|
||||
"/ready" => Self("/ready"),
|
||||
"/api/auth/login" => Self("/api/auth/login"),
|
||||
"/api/auth/logout" => Self("/api/auth/logout"),
|
||||
"/api/auth/session" => Self("/api/auth/session"),
|
||||
"/api/auth/profile" => Self("/api/auth/profile"),
|
||||
"/api/auth/password" => Self("/api/auth/password"),
|
||||
"/api/admin/capabilities" => Self("/api/admin/capabilities"),
|
||||
"/api/admin/workspaces" => Self("/api/admin/workspaces"),
|
||||
"/api/admin/workspaces/{workspace_id}" => Self("/api/admin/workspaces/{workspace_id}"),
|
||||
"/api/admin/workspaces/{workspace_id}/operations" => {
|
||||
Self("/api/admin/workspaces/{workspace_id}/operations")
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/imports/openapi/preview" => {
|
||||
Self("/api/admin/workspaces/{workspace_id}/imports/openapi/preview")
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/imports/openapi/{job_id}/create" => {
|
||||
Self("/api/admin/workspaces/{workspace_id}/imports/openapi/{job_id}/create")
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/operations/analyze-quality" => {
|
||||
Self("/api/admin/workspaces/{workspace_id}/operations/analyze-quality")
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/operations/import" => {
|
||||
Self("/api/admin/workspaces/{workspace_id}/operations/import")
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/operations/{operation_id}" => {
|
||||
Self("/api/admin/workspaces/{workspace_id}/operations/{operation_id}")
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/operations/{operation_id}/versions" => {
|
||||
Self("/api/admin/workspaces/{workspace_id}/operations/{operation_id}/versions")
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/operations/{operation_id}/versions/{version}" => {
|
||||
Self(
|
||||
"/api/admin/workspaces/{workspace_id}/operations/{operation_id}/versions/{version}",
|
||||
)
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/operations/{operation_id}/publish" => {
|
||||
Self("/api/admin/workspaces/{workspace_id}/operations/{operation_id}/publish")
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/operations/{operation_id}/archive" => {
|
||||
Self("/api/admin/workspaces/{workspace_id}/operations/{operation_id}/archive")
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/operations/{operation_id}/test-runs" => {
|
||||
Self("/api/admin/workspaces/{workspace_id}/operations/{operation_id}/test-runs")
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/operations/{operation_id}/samples/input-json" => {
|
||||
Self(
|
||||
"/api/admin/workspaces/{workspace_id}/operations/{operation_id}/samples/input-json",
|
||||
)
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/operations/{operation_id}/samples/output-json" => {
|
||||
Self(
|
||||
"/api/admin/workspaces/{workspace_id}/operations/{operation_id}/samples/output-json",
|
||||
)
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/operations/{operation_id}/drafts/generate" => {
|
||||
Self(
|
||||
"/api/admin/workspaces/{workspace_id}/operations/{operation_id}/drafts/generate",
|
||||
)
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/operations/{operation_id}/export" => {
|
||||
Self("/api/admin/workspaces/{workspace_id}/operations/{operation_id}/export")
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/agents" => {
|
||||
Self("/api/admin/workspaces/{workspace_id}/agents")
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/agents/tool-search/preview" => {
|
||||
Self("/api/admin/workspaces/{workspace_id}/agents/tool-search/preview")
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/agents/{agent_id}" => {
|
||||
Self("/api/admin/workspaces/{workspace_id}/agents/{agent_id}")
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/agents/{agent_id}/versions/{version}" => {
|
||||
Self("/api/admin/workspaces/{workspace_id}/agents/{agent_id}/versions/{version}")
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/agents/{agent_id}/bindings" => {
|
||||
Self("/api/admin/workspaces/{workspace_id}/agents/{agent_id}/bindings")
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/agents/{agent_id}/publish" => {
|
||||
Self("/api/admin/workspaces/{workspace_id}/agents/{agent_id}/publish")
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/agents/{agent_id}/unpublish" => {
|
||||
Self("/api/admin/workspaces/{workspace_id}/agents/{agent_id}/unpublish")
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/agents/{agent_id}/archive" => {
|
||||
Self("/api/admin/workspaces/{workspace_id}/agents/{agent_id}/archive")
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/agents/{agent_id}/platform-api-keys" => {
|
||||
Self("/api/admin/workspaces/{workspace_id}/agents/{agent_id}/platform-api-keys")
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/agents/{agent_id}/platform-api-keys/{key_id}/revoke" => {
|
||||
Self(
|
||||
"/api/admin/workspaces/{workspace_id}/agents/{agent_id}/platform-api-keys/{key_id}/revoke",
|
||||
)
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/agents/{agent_id}/platform-api-keys/{key_id}" => {
|
||||
Self(
|
||||
"/api/admin/workspaces/{workspace_id}/agents/{agent_id}/platform-api-keys/{key_id}",
|
||||
)
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/auth-profiles" => {
|
||||
Self("/api/admin/workspaces/{workspace_id}/auth-profiles")
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/auth-profiles/{auth_profile_id}" => {
|
||||
Self("/api/admin/workspaces/{workspace_id}/auth-profiles/{auth_profile_id}")
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/upstreams" => {
|
||||
Self("/api/admin/workspaces/{workspace_id}/upstreams")
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/upstreams/{upstream_id}" => {
|
||||
Self("/api/admin/workspaces/{workspace_id}/upstreams/{upstream_id}")
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/secrets" => {
|
||||
Self("/api/admin/workspaces/{workspace_id}/secrets")
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/secrets/{secret_id}" => {
|
||||
Self("/api/admin/workspaces/{workspace_id}/secrets/{secret_id}")
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/secrets/{secret_id}/rotate" => {
|
||||
Self("/api/admin/workspaces/{workspace_id}/secrets/{secret_id}/rotate")
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/export" => {
|
||||
Self("/api/admin/workspaces/{workspace_id}/export")
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/logs" => {
|
||||
Self("/api/admin/workspaces/{workspace_id}/logs")
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/logs/{log_id}" => {
|
||||
Self("/api/admin/workspaces/{workspace_id}/logs/{log_id}")
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/approvals" => {
|
||||
Self("/api/admin/workspaces/{workspace_id}/approvals")
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/approvals/{approval_id}" => {
|
||||
Self("/api/admin/workspaces/{workspace_id}/approvals/{approval_id}")
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/usage" => {
|
||||
Self("/api/admin/workspaces/{workspace_id}/usage")
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/usage/operations/{operation_id}" => {
|
||||
Self("/api/admin/workspaces/{workspace_id}/usage/operations/{operation_id}")
|
||||
}
|
||||
"/api/admin/workspaces/{workspace_id}/usage/agents/{agent_id}" => {
|
||||
Self("/api/admin/workspaces/{workspace_id}/usage/agents/{agent_id}")
|
||||
}
|
||||
"/v1/{workspace_slug}/{agent_slug}" => Self("/v1/{workspace_slug}/{agent_slug}"),
|
||||
"/v1/{workspace_slug}/{agent_slug}/approvals" => {
|
||||
Self("/v1/{workspace_slug}/{agent_slug}/approvals")
|
||||
}
|
||||
"/v1/{workspace_slug}/{agent_slug}/approvals/{approval_id}/approve" => {
|
||||
Self("/v1/{workspace_slug}/{agent_slug}/approvals/{approval_id}/approve")
|
||||
}
|
||||
"/v1/{workspace_slug}/{agent_slug}/approvals/{approval_id}" => {
|
||||
Self("/v1/{workspace_slug}/{agent_slug}/approvals/{approval_id}")
|
||||
}
|
||||
"/v1/{workspace_slug}/{agent_slug}/approvals/{approval_id}/deny" => {
|
||||
Self("/v1/{workspace_slug}/{agent_slug}/approvals/{approval_id}/deny")
|
||||
}
|
||||
_ => Self::unmatched(),
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
string_enum! {
|
||||
pub enum HttpMethod {
|
||||
Get => "GET",
|
||||
Post => "POST",
|
||||
Put => "PUT",
|
||||
Patch => "PATCH",
|
||||
Delete => "DELETE",
|
||||
Options => "OPTIONS",
|
||||
Head => "HEAD",
|
||||
Connect => "CONNECT",
|
||||
Trace => "TRACE",
|
||||
Other => "OTHER",
|
||||
}
|
||||
}
|
||||
|
||||
impl HttpMethod {
|
||||
pub fn classify(method: &str) -> Self {
|
||||
match method {
|
||||
"GET" => Self::Get,
|
||||
"POST" => Self::Post,
|
||||
"PUT" => Self::Put,
|
||||
"PATCH" => Self::Patch,
|
||||
"DELETE" => Self::Delete,
|
||||
"OPTIONS" => Self::Options,
|
||||
"HEAD" => Self::Head,
|
||||
"CONNECT" => Self::Connect,
|
||||
"TRACE" => Self::Trace,
|
||||
_ => Self::Other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl McpOutcome {
|
||||
pub const fn from_http_status(status: u16) -> Self {
|
||||
match status {
|
||||
200..=299 => Self::Success,
|
||||
400..=499 => Self::ClientError,
|
||||
500..=599 => Self::ServerError,
|
||||
_ => Self::Other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
string_enum! {
|
||||
pub enum HttpStatusClass {
|
||||
Informational => "1xx",
|
||||
Success => "2xx",
|
||||
Redirection => "3xx",
|
||||
ClientError => "4xx",
|
||||
ServerError => "5xx",
|
||||
Other => "other",
|
||||
}
|
||||
}
|
||||
|
||||
impl HttpStatusClass {
|
||||
pub const fn from_status(status: u16) -> Self {
|
||||
match status {
|
||||
100..=199 => Self::Informational,
|
||||
200..=299 => Self::Success,
|
||||
300..=399 => Self::Redirection,
|
||||
400..=499 => Self::ClientError,
|
||||
500..=599 => Self::ServerError,
|
||||
_ => Self::Other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
string_enum! {
|
||||
pub enum McpMethod {
|
||||
Initialize => "initialize",
|
||||
Initialized => "initialized",
|
||||
Ping => "ping",
|
||||
ToolsList => "tools_list",
|
||||
ToolsCall => "tools_call",
|
||||
Notification => "notification",
|
||||
Unsupported => "unsupported",
|
||||
Response => "response",
|
||||
Invalid => "invalid",
|
||||
}
|
||||
}
|
||||
|
||||
string_enum! {
|
||||
pub enum McpResponseMode {
|
||||
Json => "json",
|
||||
Sse => "sse",
|
||||
Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
string_enum! {
|
||||
pub enum McpOutcome {
|
||||
Success => "success",
|
||||
ClientError => "client_error",
|
||||
ServerError => "server_error",
|
||||
JsonRpcError => "jsonrpc_error",
|
||||
ToolError => "tool_error",
|
||||
Aborted => "aborted",
|
||||
Other => "other",
|
||||
}
|
||||
}
|
||||
|
||||
string_enum! {
|
||||
pub enum InvocationSource {
|
||||
Internal => "internal",
|
||||
AdminTestRun => "admin_test_run",
|
||||
AgentToolCall => "agent_tool_call",
|
||||
}
|
||||
}
|
||||
|
||||
string_enum! {
|
||||
pub enum ToolOutcome {
|
||||
Success => "success",
|
||||
Error => "error",
|
||||
Aborted => "aborted",
|
||||
}
|
||||
}
|
||||
|
||||
string_enum! {
|
||||
pub enum ToolErrorKind {
|
||||
None => "none",
|
||||
Schema => "schema",
|
||||
Mapping => "mapping",
|
||||
RestAdapter => "rest_adapter",
|
||||
ProtocolAdapter => "protocol_adapter",
|
||||
UnsupportedProtocol => "unsupported_protocol",
|
||||
UnsupportedExecutionMode => "unsupported_execution_mode",
|
||||
ConcurrencyLimit => "concurrency_limit",
|
||||
InvalidPreparedRequest => "invalid_prepared_request",
|
||||
ConfirmationRequired => "confirmation_required",
|
||||
InvalidConfirmationToken => "invalid_confirmation_token",
|
||||
ConfirmationStore => "confirmation_store",
|
||||
IdempotencyStore => "idempotency_store",
|
||||
IdempotencyInProgress => "idempotency_in_progress",
|
||||
IdempotencyConflict => "idempotency_conflict",
|
||||
IdempotencyOutcomeUnknown => "idempotency_outcome_unknown",
|
||||
MissingAuthProfile => "missing_auth_profile",
|
||||
MissingSecret => "missing_secret",
|
||||
MissingSecretVersion => "missing_secret_version",
|
||||
InvalidAuthSecret => "invalid_auth_secret",
|
||||
SecretCrypto => "secret_crypto",
|
||||
Aborted => "aborted",
|
||||
}
|
||||
}
|
||||
|
||||
string_enum! {
|
||||
pub enum UpstreamOperationKind {
|
||||
Rest => "rest",
|
||||
}
|
||||
}
|
||||
|
||||
string_enum! {
|
||||
pub enum UpstreamOutcome {
|
||||
Success => "success",
|
||||
ClientError => "client_error",
|
||||
ServerError => "server_error",
|
||||
UnexpectedStatus => "unexpected_status",
|
||||
Timeout => "timeout",
|
||||
TransportError => "transport_error",
|
||||
ResponseTooLarge => "response_too_large",
|
||||
Rejected => "rejected",
|
||||
WindowExpired => "window_expired",
|
||||
InvalidResponse => "invalid_response",
|
||||
InvalidRequest => "invalid_request",
|
||||
Configuration => "configuration",
|
||||
Aborted => "aborted",
|
||||
}
|
||||
}
|
||||
|
||||
string_enum! {
|
||||
pub enum LimitStage {
|
||||
Concurrency => "concurrency",
|
||||
RateLimit => "rate_limit",
|
||||
McpStream => "mcp_stream",
|
||||
}
|
||||
}
|
||||
|
||||
string_enum! {
|
||||
pub enum CacheOutcome {
|
||||
Hit => "hit",
|
||||
Miss => "miss",
|
||||
ReadError => "read_error",
|
||||
DecodeError => "decode_error",
|
||||
EvictError => "evict_error",
|
||||
Stored => "stored",
|
||||
WriteError => "write_error",
|
||||
}
|
||||
}
|
||||
|
||||
string_enum! {
|
||||
pub enum IdempotencyOutcome {
|
||||
Execute => "execute",
|
||||
Replay => "replay",
|
||||
Completed => "completed",
|
||||
Conflict => "conflict",
|
||||
InProgress => "in_progress",
|
||||
OutcomeUnknown => "outcome_unknown",
|
||||
StoreUnavailable => "store_unavailable",
|
||||
Error => "error",
|
||||
}
|
||||
}
|
||||
|
||||
string_enum! {
|
||||
pub enum ConfirmationOutcome {
|
||||
Approved => "approved",
|
||||
Required => "required",
|
||||
InvalidToken => "invalid_token",
|
||||
StoreUnavailable => "store_unavailable",
|
||||
Error => "error",
|
||||
}
|
||||
}
|
||||
|
||||
string_enum! {
|
||||
pub enum DbPoolState {
|
||||
Idle => "idle",
|
||||
Used => "used",
|
||||
}
|
||||
}
|
||||
|
||||
string_enum! {
|
||||
pub enum SignalType {
|
||||
Trace => "trace",
|
||||
InvocationHistory => "invocation_history",
|
||||
}
|
||||
}
|
||||
|
||||
string_enum! {
|
||||
pub enum Exporter {
|
||||
Otlp => "otlp",
|
||||
Postgres => "postgres",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! Закрытый семантический контракт метрик Crank.
|
||||
//!
|
||||
//! Product crate передают только типизированные значения. Имена метрик,
|
||||
//! ключи и значения labels сосредоточены здесь, поэтому пользовательские
|
||||
//! идентификаторы и тексты нельзя случайно превратить во временные ряды.
|
||||
|
||||
mod labels;
|
||||
mod record;
|
||||
mod schema;
|
||||
|
||||
pub use labels::{
|
||||
CacheOutcome, ConfirmationOutcome, DbPoolState, Exporter, HttpMethod, HttpRoute,
|
||||
HttpStatusClass, IdempotencyOutcome, InvocationSource, LimitStage, McpMethod, McpOutcome,
|
||||
McpResponseMode, SignalType, ToolErrorKind, ToolOutcome, UpstreamOperationKind,
|
||||
UpstreamOutcome,
|
||||
};
|
||||
pub use record::{
|
||||
InFlightGuard, ToolInvocationMetrics, UpstreamRequestMetrics, initialize_gauges,
|
||||
record_cache_outcome, record_confirmation_outcome, record_export_failure, record_http_request,
|
||||
record_idempotency_outcome, record_invocation_history_lost, record_limit_rejection,
|
||||
record_mcp_request, record_tool_invocation, record_upstream_request, set_catalog,
|
||||
set_db_pool_connections, set_mcp_active_sessions,
|
||||
};
|
||||
pub use schema::{
|
||||
DURATION_BUCKETS_SECONDS, MetricDefinition, MetricKind, MetricUnit, metric_schema,
|
||||
};
|
||||
@@ -0,0 +1,243 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use metrics::Gauge;
|
||||
|
||||
use crate::{
|
||||
CacheOutcome, ConfirmationOutcome, DbPoolState, Exporter, HttpMethod, HttpRoute,
|
||||
HttpStatusClass, IdempotencyOutcome, InvocationSource, LimitStage, McpMethod, McpOutcome,
|
||||
McpResponseMode, SignalType, ToolErrorKind, ToolOutcome, UpstreamOperationKind,
|
||||
UpstreamOutcome,
|
||||
};
|
||||
|
||||
pub fn record_http_request(
|
||||
route: HttpRoute,
|
||||
method: HttpMethod,
|
||||
status: HttpStatusClass,
|
||||
duration: Duration,
|
||||
) {
|
||||
metrics::counter!(
|
||||
"crank_http_requests_total",
|
||||
"route" => route.as_str(),
|
||||
"method" => method.as_str(),
|
||||
"status_class" => status.as_str()
|
||||
)
|
||||
.increment(1);
|
||||
metrics::histogram!(
|
||||
"crank_http_request_duration_seconds",
|
||||
"route" => route.as_str(),
|
||||
"method" => method.as_str()
|
||||
)
|
||||
.record(duration.as_secs_f64());
|
||||
}
|
||||
|
||||
pub fn record_mcp_request(method: McpMethod, response_mode: McpResponseMode, outcome: McpOutcome) {
|
||||
metrics::counter!(
|
||||
"crank_mcp_requests_total",
|
||||
"method" => method.as_str(),
|
||||
"response_mode" => response_mode.as_str(),
|
||||
"outcome" => outcome.as_str()
|
||||
)
|
||||
.increment(1);
|
||||
}
|
||||
|
||||
pub fn set_mcp_active_sessions(count: u64) {
|
||||
metrics::gauge!("crank_mcp_active_sessions").set(count as f64);
|
||||
}
|
||||
|
||||
pub fn record_tool_invocation(
|
||||
source: InvocationSource,
|
||||
outcome: ToolOutcome,
|
||||
error_kind: ToolErrorKind,
|
||||
duration: Duration,
|
||||
) {
|
||||
metrics::counter!(
|
||||
"crank_tool_invocations_total",
|
||||
"source" => source.as_str(),
|
||||
"outcome" => outcome.as_str(),
|
||||
"error_kind" => error_kind.as_str()
|
||||
)
|
||||
.increment(1);
|
||||
metrics::histogram!(
|
||||
"crank_tool_invocation_duration_seconds",
|
||||
"source" => source.as_str(),
|
||||
"outcome" => outcome.as_str()
|
||||
)
|
||||
.record(duration.as_secs_f64());
|
||||
}
|
||||
|
||||
pub fn record_upstream_request(
|
||||
operation_kind: UpstreamOperationKind,
|
||||
outcome: UpstreamOutcome,
|
||||
duration: Duration,
|
||||
) {
|
||||
metrics::counter!(
|
||||
"crank_upstream_requests_total",
|
||||
"operation_kind" => operation_kind.as_str(),
|
||||
"outcome" => outcome.as_str()
|
||||
)
|
||||
.increment(1);
|
||||
metrics::histogram!(
|
||||
"crank_upstream_request_duration_seconds",
|
||||
"operation_kind" => operation_kind.as_str(),
|
||||
"outcome" => outcome.as_str()
|
||||
)
|
||||
.record(duration.as_secs_f64());
|
||||
}
|
||||
|
||||
pub struct ToolInvocationMetrics {
|
||||
source: InvocationSource,
|
||||
started_at: Option<Instant>,
|
||||
}
|
||||
|
||||
impl ToolInvocationMetrics {
|
||||
pub fn start(source: InvocationSource) -> Self {
|
||||
Self {
|
||||
source,
|
||||
started_at: Some(Instant::now()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn complete(mut self, outcome: ToolOutcome, error_kind: ToolErrorKind) {
|
||||
self.finish(outcome, error_kind);
|
||||
}
|
||||
|
||||
fn finish(&mut self, outcome: ToolOutcome, error_kind: ToolErrorKind) {
|
||||
if let Some(started_at) = self.started_at.take() {
|
||||
record_tool_invocation(self.source, outcome, error_kind, started_at.elapsed());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ToolInvocationMetrics {
|
||||
fn drop(&mut self) {
|
||||
self.finish(ToolOutcome::Aborted, ToolErrorKind::Aborted);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct UpstreamRequestMetrics {
|
||||
operation_kind: UpstreamOperationKind,
|
||||
started_at: Option<Instant>,
|
||||
}
|
||||
|
||||
impl UpstreamRequestMetrics {
|
||||
pub fn start(operation_kind: UpstreamOperationKind) -> Self {
|
||||
Self {
|
||||
operation_kind,
|
||||
started_at: Some(Instant::now()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn complete(mut self, outcome: UpstreamOutcome) {
|
||||
self.finish(outcome);
|
||||
}
|
||||
|
||||
fn finish(&mut self, outcome: UpstreamOutcome) {
|
||||
if let Some(started_at) = self.started_at.take() {
|
||||
record_upstream_request(self.operation_kind, outcome, started_at.elapsed());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for UpstreamRequestMetrics {
|
||||
fn drop(&mut self) {
|
||||
self.finish(UpstreamOutcome::Aborted);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_limit_rejection(stage: LimitStage) {
|
||||
metrics::counter!(
|
||||
"crank_runtime_limit_rejections_total",
|
||||
"stage" => stage.as_str()
|
||||
)
|
||||
.increment(1);
|
||||
}
|
||||
|
||||
pub fn record_cache_outcome(outcome: CacheOutcome) {
|
||||
metrics::counter!(
|
||||
"crank_runtime_cache_total",
|
||||
"outcome" => outcome.as_str()
|
||||
)
|
||||
.increment(1);
|
||||
}
|
||||
|
||||
pub fn record_idempotency_outcome(outcome: IdempotencyOutcome) {
|
||||
metrics::counter!(
|
||||
"crank_idempotency_total",
|
||||
"outcome" => outcome.as_str()
|
||||
)
|
||||
.increment(1);
|
||||
}
|
||||
|
||||
pub fn record_confirmation_outcome(outcome: ConfirmationOutcome) {
|
||||
metrics::counter!(
|
||||
"crank_confirmation_total",
|
||||
"outcome" => outcome.as_str()
|
||||
)
|
||||
.increment(1);
|
||||
}
|
||||
|
||||
pub fn set_db_pool_connections(state: DbPoolState, count: u32) {
|
||||
metrics::gauge!(
|
||||
"crank_db_pool_connections",
|
||||
"state" => state.as_str()
|
||||
)
|
||||
.set(f64::from(count));
|
||||
}
|
||||
|
||||
pub fn set_catalog(tool_count: usize, estimated_context_tokens: usize, warnings: usize) {
|
||||
metrics::gauge!("crank_catalog_tools").set(tool_count as f64);
|
||||
metrics::gauge!("crank_catalog_estimated_context_tokens").set(estimated_context_tokens as f64);
|
||||
metrics::gauge!("crank_catalog_warnings").set(warnings as f64);
|
||||
}
|
||||
|
||||
pub fn record_invocation_history_lost() {
|
||||
metrics::counter!("crank_invocation_history_lost_total").increment(1);
|
||||
}
|
||||
|
||||
pub fn record_export_failure(signal: SignalType, exporter: Exporter) {
|
||||
metrics::counter!(
|
||||
"crank_telemetry_export_failures_total",
|
||||
"signal_type" => signal.as_str(),
|
||||
"exporter" => exporter.as_str()
|
||||
)
|
||||
.increment(1);
|
||||
}
|
||||
|
||||
pub fn initialize_gauges() {
|
||||
metrics::gauge!("crank_http_inflight").set(0.0);
|
||||
metrics::gauge!("crank_mcp_active_sessions").set(0.0);
|
||||
metrics::gauge!("crank_mcp_active_streams").set(0.0);
|
||||
metrics::gauge!("crank_runtime_inflight").set(0.0);
|
||||
set_db_pool_connections(DbPoolState::Idle, 0);
|
||||
set_db_pool_connections(DbPoolState::Used, 0);
|
||||
set_catalog(0, 0, 0);
|
||||
}
|
||||
|
||||
pub struct InFlightGuard {
|
||||
gauge: Gauge,
|
||||
}
|
||||
|
||||
impl InFlightGuard {
|
||||
pub fn http() -> Self {
|
||||
Self::increment(metrics::gauge!("crank_http_inflight"))
|
||||
}
|
||||
|
||||
pub fn runtime() -> Self {
|
||||
Self::increment(metrics::gauge!("crank_runtime_inflight"))
|
||||
}
|
||||
|
||||
pub fn mcp_stream() -> Self {
|
||||
Self::increment(metrics::gauge!("crank_mcp_active_streams"))
|
||||
}
|
||||
|
||||
fn increment(gauge: Gauge) -> Self {
|
||||
gauge.increment(1.0);
|
||||
Self { gauge }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InFlightGuard {
|
||||
fn drop(&mut self) {
|
||||
self.gauge.decrement(1.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
#[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 persisted MCP transport sessions.",
|
||||
),
|
||||
gauge(
|
||||
"crank_mcp_active_streams",
|
||||
&[],
|
||||
"MCP event streams currently being served.",
|
||||
),
|
||||
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.",
|
||||
),
|
||||
counter(
|
||||
"crank_runtime_cache_total",
|
||||
&["outcome"],
|
||||
"Runtime response cache outcomes.",
|
||||
),
|
||||
counter(
|
||||
"crank_idempotency_total",
|
||||
&["outcome"],
|
||||
"Runtime idempotency outcomes.",
|
||||
),
|
||||
counter(
|
||||
"crank_confirmation_total",
|
||||
&["outcome"],
|
||||
"Runtime confirmation outcomes.",
|
||||
),
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
use std::{collections::BTreeSet, time::Duration};
|
||||
|
||||
use crank_metrics::{
|
||||
CacheOutcome, ConfirmationOutcome, DbPoolState, Exporter, HttpMethod, HttpRoute,
|
||||
HttpStatusClass, IdempotencyOutcome, InFlightGuard, InvocationSource, LimitStage, McpMethod,
|
||||
McpOutcome, McpResponseMode, SignalType, ToolErrorKind, ToolInvocationMetrics, ToolOutcome,
|
||||
UpstreamOperationKind, UpstreamOutcome, UpstreamRequestMetrics, initialize_gauges,
|
||||
metric_schema, record_cache_outcome, record_confirmation_outcome, record_export_failure,
|
||||
record_http_request, record_idempotency_outcome, record_invocation_history_lost,
|
||||
record_limit_rejection, record_mcp_request, record_tool_invocation, record_upstream_request,
|
||||
set_catalog, set_db_pool_connections, set_mcp_active_sessions,
|
||||
};
|
||||
use metrics_util::debugging::DebuggingRecorder;
|
||||
|
||||
#[test]
|
||||
fn schema_names_are_unique_and_labels_are_closed() {
|
||||
let schema = metric_schema();
|
||||
let names = schema
|
||||
.iter()
|
||||
.map(|metric| metric.name)
|
||||
.collect::<BTreeSet<_>>();
|
||||
|
||||
assert_eq!(names.len(), schema.len());
|
||||
assert!(names.contains("crank_runtime_cache_total"));
|
||||
assert!(names.contains("crank_idempotency_total"));
|
||||
assert!(names.contains("crank_confirmation_total"));
|
||||
assert!(names.contains("crank_mcp_active_streams"));
|
||||
|
||||
let allowed_labels = [
|
||||
"route",
|
||||
"method",
|
||||
"status_class",
|
||||
"response_mode",
|
||||
"outcome",
|
||||
"source",
|
||||
"error_kind",
|
||||
"operation_kind",
|
||||
"stage",
|
||||
"state",
|
||||
"signal_type",
|
||||
"exporter",
|
||||
];
|
||||
for metric in schema {
|
||||
assert!(
|
||||
metric
|
||||
.labels
|
||||
.iter()
|
||||
.all(|label| allowed_labels.contains(label)),
|
||||
"{} contains an unapproved label",
|
||||
metric.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untrusted_http_values_collapse_to_closed_variants() {
|
||||
assert_eq!(
|
||||
HttpRoute::from_matched_path("/api/admin/workspaces/{workspace_id}/operations").as_str(),
|
||||
"/api/admin/workspaces/{workspace_id}/operations"
|
||||
);
|
||||
assert_eq!(
|
||||
HttpRoute::from_matched_path("/customer-controlled"),
|
||||
HttpRoute::unmatched()
|
||||
);
|
||||
assert_eq!(HttpMethod::classify("CUSTOM"), HttpMethod::Other);
|
||||
assert_eq!(HttpStatusClass::from_status(999), HttpStatusClass::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_outcomes_are_distinct_from_transport_success() {
|
||||
assert_eq!(McpOutcome::from_http_status(200), McpOutcome::Success);
|
||||
assert_ne!(McpOutcome::JsonRpcError, McpOutcome::Success);
|
||||
assert_ne!(McpOutcome::ToolError, McpOutcome::Success);
|
||||
|
||||
assert_eq!(CacheOutcome::Hit.as_str(), "hit");
|
||||
assert_eq!(CacheOutcome::Miss.as_str(), "miss");
|
||||
assert_eq!(IdempotencyOutcome::Replay.as_str(), "replay");
|
||||
assert_eq!(IdempotencyOutcome::Conflict.as_str(), "conflict");
|
||||
assert_eq!(ConfirmationOutcome::Approved.as_str(), "approved");
|
||||
assert_eq!(ConfirmationOutcome::Required.as_str(), "required");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropped_product_guards_record_aborted_outcomes() {
|
||||
let recorder = DebuggingRecorder::new();
|
||||
let snapshotter = recorder.snapshotter();
|
||||
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
drop(ToolInvocationMetrics::start(
|
||||
InvocationSource::AgentToolCall,
|
||||
));
|
||||
drop(UpstreamRequestMetrics::start(UpstreamOperationKind::Rest));
|
||||
});
|
||||
|
||||
let snapshot = snapshotter.snapshot().into_vec();
|
||||
assert!(snapshot.iter().any(|(key, _, _, _)| {
|
||||
key.key().name() == "crank_tool_invocations_total"
|
||||
&& has_label(key.key(), "outcome", "aborted")
|
||||
&& has_label(key.key(), "error_kind", "aborted")
|
||||
}));
|
||||
assert!(snapshot.iter().any(|(key, _, _, _)| {
|
||||
key.key().name() == "crank_upstream_requests_total"
|
||||
&& has_label(key.key(), "outcome", "aborted")
|
||||
}));
|
||||
}
|
||||
|
||||
fn has_label(key: &metrics::Key, name: &str, value: &str) -> bool {
|
||||
key.labels()
|
||||
.any(|label| label.key() == name && label.value() == value)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diverse_calls_cannot_create_unbounded_series() {
|
||||
let recorder = DebuggingRecorder::new();
|
||||
let snapshotter = recorder.snapshotter();
|
||||
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
for status in 200..300 {
|
||||
let route = format!("/customer-controlled/{status}");
|
||||
let method = format!("CUSTOM-{status}");
|
||||
record_http_request(
|
||||
HttpRoute::from_matched_path(&route),
|
||||
HttpMethod::classify(&method),
|
||||
HttpStatusClass::from_status(status),
|
||||
Duration::from_millis(u64::from(status)),
|
||||
);
|
||||
record_mcp_request(
|
||||
McpMethod::ToolsCall,
|
||||
McpResponseMode::Json,
|
||||
McpOutcome::ToolError,
|
||||
);
|
||||
record_tool_invocation(
|
||||
InvocationSource::AgentToolCall,
|
||||
ToolOutcome::Error,
|
||||
ToolErrorKind::Mapping,
|
||||
Duration::from_millis(u64::from(status)),
|
||||
);
|
||||
record_cache_outcome(CacheOutcome::Miss);
|
||||
record_idempotency_outcome(IdempotencyOutcome::Replay);
|
||||
record_confirmation_outcome(ConfirmationOutcome::Required);
|
||||
}
|
||||
});
|
||||
|
||||
let snapshot = snapshotter.snapshot().into_vec();
|
||||
let series = snapshot
|
||||
.iter()
|
||||
.map(|(key, _, _, _)| format!("{:?}", key.key()))
|
||||
.collect::<BTreeSet<_>>();
|
||||
|
||||
assert_eq!(series.len(), 8);
|
||||
assert!(
|
||||
series
|
||||
.iter()
|
||||
.all(|series| !series.contains("customer-controlled"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_recording_api_matches_the_declared_schema() {
|
||||
let recorder = DebuggingRecorder::new();
|
||||
let snapshotter = recorder.snapshotter();
|
||||
|
||||
metrics::with_local_recorder(&recorder, || {
|
||||
initialize_gauges();
|
||||
record_http_request(
|
||||
HttpRoute::from_matched_path("/api/auth/session"),
|
||||
HttpMethod::Get,
|
||||
HttpStatusClass::Success,
|
||||
Duration::from_millis(1),
|
||||
);
|
||||
record_mcp_request(
|
||||
McpMethod::ToolsCall,
|
||||
McpResponseMode::Json,
|
||||
McpOutcome::Success,
|
||||
);
|
||||
set_mcp_active_sessions(1);
|
||||
let _stream = InFlightGuard::mcp_stream();
|
||||
let _runtime = InFlightGuard::runtime();
|
||||
record_tool_invocation(
|
||||
InvocationSource::AgentToolCall,
|
||||
ToolOutcome::Error,
|
||||
ToolErrorKind::Mapping,
|
||||
Duration::from_millis(1),
|
||||
);
|
||||
record_upstream_request(
|
||||
UpstreamOperationKind::Rest,
|
||||
UpstreamOutcome::Timeout,
|
||||
Duration::from_millis(1),
|
||||
);
|
||||
record_limit_rejection(LimitStage::Concurrency);
|
||||
record_cache_outcome(CacheOutcome::Hit);
|
||||
record_idempotency_outcome(IdempotencyOutcome::Replay);
|
||||
record_confirmation_outcome(ConfirmationOutcome::Required);
|
||||
set_db_pool_connections(DbPoolState::Idle, 1);
|
||||
set_catalog(1, 2, 3);
|
||||
record_invocation_history_lost();
|
||||
record_export_failure(SignalType::Trace, Exporter::Otlp);
|
||||
});
|
||||
|
||||
let snapshot = snapshotter.snapshot().into_vec();
|
||||
let actual_names = snapshot
|
||||
.iter()
|
||||
.map(|(key, _, _, _)| key.key().name().to_owned())
|
||||
.collect::<BTreeSet<_>>();
|
||||
let expected_names = metric_schema()
|
||||
.iter()
|
||||
.map(|definition| definition.name.to_owned())
|
||||
.collect::<BTreeSet<_>>();
|
||||
|
||||
assert_eq!(actual_names, expected_names);
|
||||
for definition in metric_schema() {
|
||||
let actual_label_sets = snapshot
|
||||
.iter()
|
||||
.filter(|(key, _, _, _)| key.key().name() == definition.name)
|
||||
.map(|(key, _, _, _)| {
|
||||
key.key()
|
||||
.labels()
|
||||
.map(|label| label.key().to_owned())
|
||||
.collect::<BTreeSet<_>>()
|
||||
})
|
||||
.collect::<BTreeSet<_>>();
|
||||
let expected_labels = definition
|
||||
.labels
|
||||
.iter()
|
||||
.map(|label| (*label).to_owned())
|
||||
.collect::<BTreeSet<_>>();
|
||||
|
||||
assert_eq!(
|
||||
actual_label_sets,
|
||||
BTreeSet::from([expected_labels]),
|
||||
"recording API for {} diverges from the schema",
|
||||
definition.name
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user