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
+147 -48
View File
@@ -1,4 +1,7 @@
use std::{sync::Arc, time::Duration};
use std::{
sync::Arc,
time::{Duration, Instant},
};
use axum::{
Json, Router,
@@ -10,10 +13,14 @@ use axum::{
response::{IntoResponse, Response},
routing::get,
};
use crank_registry::{PostgresRegistry, PublishedAgentTool};
use crank_core::{
InvocationLevel, InvocationLog, InvocationLogId, InvocationSource, InvocationStatus,
};
use crank_registry::{CreateInvocationLogRequest, PostgresRegistry, PublishedAgentTool};
use crank_runtime::{RuntimeError, RuntimeExecutor, RuntimeOperation};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use crate::{
catalog::PublishedToolCatalog,
@@ -30,6 +37,7 @@ const HEADER_MCP_PROTOCOL_VERSION: &str = "MCP-Protocol-Version";
#[derive(Clone)]
pub struct AppState {
registry: PostgresRegistry,
catalog: PublishedToolCatalog,
runtime: RuntimeExecutor,
sessions: SessionStore,
@@ -66,6 +74,7 @@ pub fn build_app(
public_base_url: Option<String>,
) -> Router {
let state = Arc::new(AppState {
registry: registry.clone(),
catalog: PublishedToolCatalog::new(registry, refresh_interval),
runtime: RuntimeExecutor::new(),
sessions: SessionStore::new(),
@@ -223,52 +232,83 @@ async fn mcp_post(
.await
{
Ok(Some(tool)) => {
match state
.runtime
.execute(&runtime_operation(tool), &arguments)
.await
{
let runtime_operation = runtime_operation(&tool);
let request_preview =
build_request_preview(&state.runtime, &runtime_operation, &arguments);
let started_at = Instant::now();
match state.runtime.execute(&runtime_operation, &arguments).await {
Ok(output) => json_response(
StatusCode::OK,
jsonrpc_result(
request_id(&message),
json!({
"content": [
{
"type": "text",
"text": serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_owned())
}
],
"structuredContent": output,
"isError": false
}),
),
None,
Some(&session.protocol_version),
),
Err(error) => json_response(
StatusCode::OK,
jsonrpc_result(
request_id(&message),
json!({
"content": [
{
"type": "text",
"text": error.to_string()
}
],
"structuredContent": {
"error": {
"code": runtime_error_code(&error),
"message": error.to_string()
}
},
"isError": true
}),
),
{
let _ = persist_invocation(
&state,
&tool,
InvocationStatus::Ok,
InvocationLevel::Info,
"agent tool call completed",
None,
None,
started_at.elapsed(),
request_preview,
output.clone(),
)
.await;
jsonrpc_result(
request_id(&message),
json!({
"content": [
{
"type": "text",
"text": serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_owned())
}
],
"structuredContent": output,
"isError": false
}),
)
},
None,
Some(&session.protocol_version),
),
Err(error) => {
let _ = persist_invocation(
&state,
&tool,
InvocationStatus::Error,
InvocationLevel::Error,
&error.to_string(),
None,
Some(runtime_error_code(&error)),
started_at.elapsed(),
request_preview,
Value::Null,
)
.await;
json_response(
StatusCode::OK,
jsonrpc_result(
request_id(&message),
json!({
"content": [
{
"type": "text",
"text": error.to_string()
}
],
"structuredContent": {
"error": {
"code": runtime_error_code(&error),
"message": error.to_string()
}
},
"isError": true
}),
),
None,
Some(&session.protocol_version),
)
}
}
}
Ok(None) => json_response(
@@ -492,6 +532,65 @@ fn internal_jsonrpc_error(message: &Value, error: impl std::fmt::Display) -> Res
)
}
fn build_request_preview(
runtime: &RuntimeExecutor,
operation: &RuntimeOperation,
arguments: &Value,
) -> Value {
match runtime.prepare_request(operation, arguments) {
Ok(prepared) => json!({
"path": prepared.path_params,
"query": prepared.query_params,
"headers": prepared.headers,
"grpc": prepared.grpc.unwrap_or(Value::Null),
"variables": prepared.variables.unwrap_or(Value::Null),
"body": prepared.body.unwrap_or(Value::Null)
}),
Err(_) => Value::Null,
}
}
async fn persist_invocation(
state: &Arc<AppState>,
tool: &PublishedAgentTool,
status: InvocationStatus,
level: InvocationLevel,
message: &str,
status_code: Option<u16>,
error_kind: Option<&str>,
duration: Duration,
request_preview: Value,
response_preview: Value,
) -> Result<(), crank_registry::RegistryError> {
let created_at = OffsetDateTime::now_utc()
.format(&Rfc3339)
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_owned());
let duration_ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX);
let log = InvocationLog {
id: InvocationLogId::new(format!("log_{}", uuid::Uuid::now_v7().simple())),
workspace_id: tool.workspace_id.clone(),
agent_id: Some(tool.agent_id.clone()),
operation_id: tool.operation.id.clone(),
source: InvocationSource::AgentToolCall,
level,
status,
tool_name: tool.tool_name.clone(),
message: message.to_owned(),
request_id: None,
status_code,
duration_ms,
error_kind: error_kind.map(ToOwned::to_owned),
request_preview,
response_preview,
created_at,
};
state
.registry
.create_invocation_log(CreateInvocationLogRequest { log: &log })
.await
}
fn runtime_error_code(error: &RuntimeError) -> &'static str {
match error {
RuntimeError::Schema(_) => "schema_validation_error",
@@ -540,11 +639,11 @@ fn tool_definition(tool: &PublishedAgentTool) -> Value {
})
}
fn runtime_operation(tool: PublishedAgentTool) -> RuntimeOperation {
let mut operation = RuntimeOperation::from(tool.operation);
operation.tool_name = tool.tool_name;
operation.tool_description.title = tool.tool_title;
operation.tool_description.description = tool.tool_description;
fn runtime_operation(tool: &PublishedAgentTool) -> RuntimeOperation {
let mut operation = RuntimeOperation::from(tool.operation.clone());
operation.tool_name = tool.tool_name.clone();
operation.tool_description.title = tool.tool_title.clone();
operation.tool_description.description = tool.tool_description.clone();
operation
}
+25 -2
View File
@@ -57,7 +57,8 @@ mod tests {
};
use crank_mapping::{MappingRule, MappingSet};
use crank_registry::{
CreateAgentRequest, PostgresRegistry, PublishAgentRequest, PublishRequest,
CreateAgentRequest, ListInvocationLogsQuery, PostgresRegistry, PublishAgentRequest,
PublishRequest,
};
use crank_schema::{Schema, SchemaKind};
use serde_json::{Value, json};
@@ -97,7 +98,7 @@ mod tests {
publish_agent_for_operation(&registry, &operation, "sales-rest").await;
let base_url = spawn_mcp_server(build_app(
registry,
registry.clone(),
Duration::from_millis(0),
Some("https://crank.example.com".to_owned()),
))
@@ -142,6 +143,28 @@ mod tests {
json!({ "id": "lead_123" })
);
assert_eq!(call_result["result"]["isError"], false);
let logs = registry
.list_invocation_logs(ListInvocationLogsQuery {
workspace_id: &test_workspace_id(),
level: None,
search_text: None,
source: Some(crank_core::InvocationSource::AgentToolCall),
operation_id: Some(&operation.id),
agent_id: None,
created_after: None,
limit: 10,
})
.await
.unwrap();
assert_eq!(logs.len(), 1);
assert_eq!(
logs[0].log.source,
crank_core::InvocationSource::AgentToolCall
);
assert_eq!(logs[0].log.status, crank_core::InvocationStatus::Ok);
assert_eq!(logs[0].log.tool_name, "crm_create_lead");
}
#[tokio::test]