feat: add observability api foundation

This commit is contained in:
a.tolmachev
2026-03-30 00:00:13 +03:00
parent 0a1680f24e
commit be9ee95cbe
21 changed files with 1535 additions and 94 deletions
+347 -17
View File
@@ -4,19 +4,22 @@ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use crank_core::{
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthConfig, AuthKind,
AuthProfile, AuthProfileId, ConfigExport, ExportMode, GeneratedDraft, GeneratedDraftStatus,
InvitationId, InvitationStatus, InvitationToken, MembershipRole, OperationId, OperationStatus,
PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus, Protocol,
SampleId, Samples, Target, Workspace, WorkspaceId, WorkspaceStatus,
InvitationId, InvitationStatus, InvitationToken, InvocationLevel, InvocationLog,
InvocationLogId, InvocationSource, InvocationStatus, MembershipRole, OperationId,
OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus,
Protocol, SampleId, Samples, Target, UsagePeriod, Workspace, WorkspaceId, WorkspaceStatus,
};
use crank_mapping::{JsonPathRoot, MappingSet, infer_mapping_from_samples};
use crank_proto::{ProtoService, services_from_descriptor_set_bytes};
use crank_registry::{
AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateInvitationRequest,
CreatePlatformApiKeyRequest, CreateVersionRequest, CreateWorkspaceRequest, InvitationRecord,
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateVersionRequest,
CreateWorkspaceRequest, InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery,
MembershipRecord, OperationSampleMetadata, OperationSummary, OperationVersionRecord,
PlatformApiKeyRecord, PostgresRegistry, PublishAgentRequest, PublishRequest, RegistryOperation,
SampleKind, SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
SaveSampleMetadataRequest, UpdateWorkspaceRequest, WorkspaceRecord,
SaveSampleMetadataRequest, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket,
UsageOperationBreakdown, UsageQuery, UsageSummary, UsageTimelinePoint, WorkspaceRecord,
};
use crank_runtime::{PreparedRequest, RuntimeError, RuntimeExecutor, RuntimeOperation};
use crank_schema::Schema;
@@ -163,6 +166,31 @@ pub struct CreatedPlatformApiKeyResponse {
pub secret: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct LogsQuery {
pub level: Option<InvocationLevel>,
pub search: Option<String>,
pub source: Option<InvocationSource>,
pub operation_id: Option<String>,
pub agent_id: Option<String>,
pub period: Option<UsagePeriod>,
pub limit: Option<u32>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct UsageRequestQuery {
pub period: Option<UsagePeriod>,
pub source: Option<InvocationSource>,
}
#[derive(Clone, Debug, Serialize)]
pub struct UsageOverviewResponse {
pub summary: UsageSummary,
pub timeline: Vec<UsageTimelinePoint>,
pub operations: Vec<UsageOperationBreakdown>,
pub agents: Vec<UsageAgentBreakdown>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct GenerateDraftPayload {
#[serde(default)]
@@ -461,6 +489,144 @@ impl AdminService {
Ok(())
}
#[instrument(skip(self))]
pub async fn list_logs(
&self,
workspace_id: &WorkspaceId,
query: LogsQuery,
) -> Result<Vec<InvocationLogRecord>, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
let operation_id = query.operation_id.as_deref().map(OperationId::new);
let agent_id = query.agent_id.as_deref().map(AgentId::new);
let (_, created_after, _) = usage_window(query.period.unwrap_or(UsagePeriod::Last7Days))?;
self.registry
.list_invocation_logs(ListInvocationLogsQuery {
workspace_id,
level: query.level,
search_text: query.search.as_deref(),
source: query.source,
operation_id: operation_id.as_ref(),
agent_id: agent_id.as_ref(),
created_after: Some(&created_after),
limit: query.limit.unwrap_or(100),
})
.await
.map_err(ApiError::from)
}
#[instrument(skip(self))]
pub async fn get_log(
&self,
workspace_id: &WorkspaceId,
log_id: &InvocationLogId,
) -> Result<InvocationLogRecord, ApiError> {
self.registry
.get_invocation_log(workspace_id, log_id)
.await?
.ok_or_else(|| {
ApiError::not_found(format!("invocation log {} was not found", log_id.as_str()))
})
}
#[instrument(skip(self))]
pub async fn get_usage_overview(
&self,
workspace_id: &WorkspaceId,
query: UsageRequestQuery,
) -> Result<UsageOverviewResponse, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
let (period, created_after, bucket) =
usage_window(query.period.unwrap_or(UsagePeriod::Last7Days))?;
let usage_query = UsageQuery {
workspace_id,
period,
source: query.source,
created_after: &created_after,
bucket,
};
let summary = self.registry.summarize_usage(usage_query.clone()).await?;
let timeline = self
.registry
.list_usage_timeline(usage_query.clone())
.await?;
let operations = self
.registry
.list_usage_by_operation(usage_query.clone())
.await?;
let agents = self.registry.list_usage_by_agent(usage_query).await?;
Ok(UsageOverviewResponse {
summary,
timeline,
operations,
agents,
})
}
#[instrument(skip(self))]
pub async fn get_operation_usage(
&self,
workspace_id: &WorkspaceId,
operation_id: &OperationId,
query: UsageRequestQuery,
) -> Result<crank_registry::UsageRollupRecord, ApiError> {
self.get_operation(workspace_id, operation_id).await?;
let (period, created_after, bucket) =
usage_window(query.period.unwrap_or(UsagePeriod::Last7Days))?;
self.registry
.get_usage_for_operation(
UsageQuery {
workspace_id,
period,
source: query.source,
created_after: &created_after,
bucket,
},
operation_id,
)
.await?
.ok_or_else(|| {
ApiError::not_found(format!(
"usage for operation {} was not found",
operation_id.as_str()
))
})
}
#[instrument(skip(self))]
pub async fn get_agent_usage(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
query: UsageRequestQuery,
) -> Result<crank_registry::UsageRollupRecord, ApiError> {
self.get_agent(workspace_id, agent_id).await?;
let (period, created_after, bucket) =
usage_window(query.period.unwrap_or(UsagePeriod::Last7Days))?;
self.registry
.get_usage_for_agent(
UsageQuery {
workspace_id,
period,
source: query.source,
created_after: &created_after,
bucket,
},
agent_id,
)
.await?
.ok_or_else(|| {
ApiError::not_found(format!(
"usage for agent {} was not found",
agent_id.as_str()
))
})
}
pub async fn list_operations(
&self,
workspace_id: &WorkspaceId,
@@ -658,6 +824,21 @@ impl AdminService {
match build_request_preview(&record.snapshot.input_mapping, &payload.input) {
Ok(preview) => preview,
Err(error) => {
self.record_invocation(
workspace_id,
None,
&record.snapshot,
InvocationSource::AdminTestRun,
InvocationLevel::Error,
InvocationStatus::Error,
"mapping preview failed".to_owned(),
None,
Some("mapping".to_owned()),
0,
Value::Null,
Value::Null,
)
.await?;
return Ok(TestRunResult {
ok: false,
request_preview: Value::Null,
@@ -669,19 +850,58 @@ impl AdminService {
}
};
let started_at = std::time::Instant::now();
match self.runtime.execute(&runtime, &payload.input).await {
Ok(output) => Ok(TestRunResult {
ok: true,
request_preview,
response_preview: output,
errors: Vec::new(),
}),
Err(error) => Ok(TestRunResult {
ok: false,
request_preview,
response_preview: Value::Null,
errors: vec![crate::error::runtime_test_failure(&error)],
}),
Ok(output) => {
let duration_ms =
u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
self.record_invocation(
workspace_id,
None,
&record.snapshot,
InvocationSource::AdminTestRun,
InvocationLevel::Info,
InvocationStatus::Ok,
"admin test run completed".to_owned(),
None,
None,
duration_ms,
request_preview.clone(),
output.clone(),
)
.await?;
Ok(TestRunResult {
ok: true,
request_preview,
response_preview: output,
errors: Vec::new(),
})
}
Err(error) => {
let duration_ms =
u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
self.record_invocation(
workspace_id,
None,
&record.snapshot,
InvocationSource::AdminTestRun,
InvocationLevel::Error,
InvocationStatus::Error,
error.to_string(),
None,
Some(runtime_error_code(&error).to_owned()),
duration_ms,
request_preview.clone(),
Value::Null,
)
.await?;
Ok(TestRunResult {
ok: false,
request_preview,
response_preview: Value::Null,
errors: vec![crate::error::runtime_test_failure(&error)],
})
}
}
}
@@ -1324,6 +1544,47 @@ impl AdminService {
async fn ensure_workspace_exists(&self, workspace_id: &WorkspaceId) -> Result<(), ApiError> {
self.get_workspace(workspace_id).await.map(|_| ())
}
async fn record_invocation(
&self,
workspace_id: &WorkspaceId,
agent_id: Option<&AgentId>,
operation: &RegistryOperation,
source: InvocationSource,
level: InvocationLevel,
status: InvocationStatus,
message: String,
status_code: Option<u16>,
error_kind: Option<String>,
duration_ms: u64,
request_preview: Value,
response_preview: Value,
) -> Result<(), ApiError> {
let log = InvocationLog {
id: InvocationLogId::new(new_prefixed_id("log")),
workspace_id: workspace_id.clone(),
agent_id: agent_id.cloned(),
operation_id: operation.id.clone(),
source,
level,
status,
tool_name: operation.name.clone(),
message,
request_id: None,
status_code,
duration_ms,
error_kind,
request_preview,
response_preview,
created_at: now_string()?,
};
self.registry
.create_invocation_log(CreateInvocationLogRequest { log: &log })
.await?;
Ok(())
}
}
fn build_request_preview(
@@ -1425,6 +1686,75 @@ fn default_invitation_expiry() -> Result<String, ApiError> {
.map_err(|error| ApiError::internal(error.to_string()))
}
fn runtime_error_code(error: &RuntimeError) -> &'static str {
match error {
RuntimeError::Schema(_) => "schema_error",
RuntimeError::Mapping(_) => "mapping_error",
RuntimeError::InvalidPreparedRequest { .. } => "invalid_request",
RuntimeError::GraphqlAdapter(_) => "graphql_error",
RuntimeError::GrpcAdapter(_) => "grpc_error",
RuntimeError::RestAdapter(_) => "rest_error",
RuntimeError::UnsupportedProtocol { .. } => "unsupported_protocol",
}
}
fn usage_window(period: UsagePeriod) -> Result<(UsagePeriod, String, UsageBucket), ApiError> {
let now = OffsetDateTime::now_utc();
let (start, bucket) = match period {
UsagePeriod::Last30Minutes => (
now.checked_sub(time::Duration::minutes(30))
.ok_or_else(|| ApiError::internal("failed to compute usage period"))?,
UsageBucket::Hour,
),
UsagePeriod::LastHour => (
now.checked_sub(time::Duration::hours(1))
.ok_or_else(|| ApiError::internal("failed to compute usage period"))?,
UsageBucket::Hour,
),
UsagePeriod::Last6Hours => (
now.checked_sub(time::Duration::hours(6))
.ok_or_else(|| ApiError::internal("failed to compute usage period"))?,
UsageBucket::Hour,
),
UsagePeriod::Last24Hours => (
now.checked_sub(time::Duration::hours(24))
.ok_or_else(|| ApiError::internal("failed to compute usage period"))?,
UsageBucket::Hour,
),
UsagePeriod::Last7Days => (
now.checked_sub(time::Duration::days(7))
.ok_or_else(|| ApiError::internal("failed to compute usage period"))?,
UsageBucket::Day,
),
UsagePeriod::Last30Days => (
now.checked_sub(time::Duration::days(30))
.ok_or_else(|| ApiError::internal("failed to compute usage period"))?,
UsageBucket::Week,
),
UsagePeriod::Last90Days => (
now.checked_sub(time::Duration::days(90))
.ok_or_else(|| ApiError::internal("failed to compute usage period"))?,
UsageBucket::Month,
),
UsagePeriod::ThisMonth => (
OffsetDateTime::from_unix_timestamp(
now.unix_timestamp() - i64::from(now.day() - 1) * 24 * 60 * 60,
)
.map_err(|error| ApiError::internal(error.to_string()))?
.replace_time(time::Time::MIDNIGHT),
UsageBucket::Week,
),
};
Ok((
period,
start
.format(&Rfc3339)
.map_err(|error| ApiError::internal(error.to_string()))?,
bucket,
))
}
fn proto_service_summary(service: ProtoService) -> Result<GrpcServiceSummary, ApiError> {
let methods = service
.unary_methods()