feat: add observability api foundation
This commit is contained in:
+347
-17
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user