feat: add observability api foundation
This commit is contained in:
+147
-48
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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(®istry, &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]
|
||||
|
||||
Reference in New Issue
Block a user