наблюдаемость: ввести безопасный контракт метрик
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
+16
View File
@@ -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"
+431
View File
@@ -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",
}
}
+26
View File
@@ -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,
};
+243
View File
@@ -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);
}
}
+179
View File
@@ -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,
}
}
+235
View File
@@ -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
);
}
}