1use std::sync::Arc;
2
3use axum::Json;
4use axum::extract::{Path, Query, State};
5use axum::http::StatusCode;
6use axum::response::{IntoResponse, Response};
7use serde::Deserialize;
8use uuid::Uuid;
9
10use mnemo_core::error::Error as CoreError;
11use mnemo_core::hash::compute_content_hash;
12use mnemo_core::model::acl::Permission;
13use mnemo_core::model::delegation::{Delegation, DelegationScope};
14use mnemo_core::model::event::{AgentEvent, EventType};
15use mnemo_core::model::memory::{MemoryType, Scope};
16use mnemo_core::model::write_provenance::WriteProvenance;
17use mnemo_core::query::MnemoEngine;
18use mnemo_core::query::branch::{BranchRequest, BranchResponse};
19use mnemo_core::query::checkpoint::{CheckpointRequest, CheckpointResponse};
20use mnemo_core::query::consolidate::{ConsolidateRequest, ConsolidateResponse};
21use mnemo_core::query::forget::{
22 ForgetRequest, ForgetResponse, ForgetStrategy, ForgetSubjectRequest, ForgetSubjectResponse,
23};
24use mnemo_core::query::merge::{MergeRequest, MergeResponse};
25use mnemo_core::query::recall::{RecallRequest, RecallResponse};
26use mnemo_core::query::remember::{RememberRequest, RememberResponse};
27use mnemo_core::query::replay::{ReplayRequest, ReplayResponse};
28use mnemo_core::query::share::{ShareRequest, ShareResponse};
29
30type AppState = Arc<MnemoEngine>;
31
32pub struct AppError(CoreError);
37
38impl IntoResponse for AppError {
39 fn into_response(self) -> Response {
40 let (status, msg) = match &self.0 {
41 CoreError::Validation(m) => (StatusCode::BAD_REQUEST, m.clone()),
42 CoreError::PermissionDenied(m) => (StatusCode::FORBIDDEN, m.clone()),
43 CoreError::NotFound(m) => (StatusCode::NOT_FOUND, m.clone()),
44 other => {
45 tracing::error!("internal error: {other}");
46 (
47 StatusCode::INTERNAL_SERVER_ERROR,
48 "internal server error".to_string(),
49 )
50 }
51 };
52 (status, Json(serde_json::json!({"error": msg}))).into_response()
53 }
54}
55
56impl From<CoreError> for AppError {
57 fn from(e: CoreError) -> Self {
58 AppError(e)
59 }
60}
61
62#[derive(Debug, Deserialize)]
67pub struct RecallParams {
68 pub query: String,
69 pub agent_id: Option<String>,
70 pub limit: Option<usize>,
71 pub memory_type: Option<String>,
72 pub scope: Option<String>,
73 pub min_importance: Option<f32>,
74 pub tags: Option<String>,
75 pub org_id: Option<String>,
76 pub strategy: Option<String>,
77 pub as_of: Option<String>,
78 pub memory_types: Option<String>,
79 pub hybrid_weights: Option<String>,
80 pub rrf_k: Option<f32>,
81 pub explain: Option<bool>,
82 pub current_fact_key: Option<String>,
86 pub current_fact_include_chain: Option<bool>,
89 pub orientation_cache: Option<bool>,
96 pub orientation_namespace: Option<String>,
99 pub orientation_token_budget: Option<u32>,
102 pub orientation_include_in_response: Option<bool>,
105 pub orientation_distill: Option<bool>,
109}
110
111#[derive(Debug, Deserialize)]
112pub struct ForgetParams {
113 pub strategy: Option<String>,
114 pub agent_id: Option<String>,
115}
116
117#[derive(Debug, Deserialize)]
118pub struct ShareBody {
119 pub target_agent_id: String,
120 pub target_agent_ids: Option<Vec<String>>,
121 pub permission: Option<String>,
122 pub expires_in_hours: Option<f64>,
123 pub agent_id: Option<String>,
124}
125
126#[derive(Debug, Deserialize)]
127pub struct VerifyBody {
128 pub agent_id: Option<String>,
129 pub thread_id: Option<String>,
130}
131
132#[derive(Debug, Deserialize)]
133pub struct TrajectoryAuditBody {
134 pub agent_id: Option<String>,
135 pub thread_id: Option<String>,
136 pub active_bank_ceiling: Option<usize>,
137 pub fact_key: Option<String>,
138 pub named_forget_strategies: Option<Vec<String>>,
139}
140
141#[derive(Debug, Deserialize)]
142pub struct DelegateRequest {
143 pub delegate_id: String,
144 pub permission: String,
145 pub memory_ids: Option<Vec<String>>,
146 pub tags: Option<Vec<String>>,
147 pub max_depth: Option<u32>,
148 pub expires_in_hours: Option<f64>,
149 pub agent_id: Option<String>,
152}
153
154pub async fn remember_handler(
160 State(engine): State<AppState>,
161 Json(request): Json<RememberRequest>,
162) -> Result<Json<RememberResponse>, AppError> {
163 let response = engine.remember(request).await?;
164 Ok(Json(response))
165}
166
167pub async fn recall_handler(
169 State(engine): State<AppState>,
170 Query(params): Query<RecallParams>,
171) -> Result<Json<RecallResponse>, AppError> {
172 let memory_type = match params.memory_type.as_deref() {
173 Some(s) => Some(s.parse::<MemoryType>().map_err(|_| {
174 AppError(CoreError::Validation(format!(
175 "invalid memory_type '{}': expected one of: episodic, semantic, procedural, working",
176 s
177 )))
178 })?),
179 None => None,
180 };
181
182 let scope = match params.scope.as_deref() {
183 Some(s) => Some(s.parse::<Scope>().map_err(|_| {
184 AppError(CoreError::Validation(format!(
185 "invalid scope '{}': expected one of: private, shared, public, global",
186 s
187 )))
188 })?),
189 None => None,
190 };
191
192 let tags = params.tags.as_deref().map(|t| {
193 t.split(',')
194 .map(|s| s.trim().to_string())
195 .collect::<Vec<_>>()
196 });
197
198 let memory_types = match params.memory_types.as_deref() {
199 Some(s) => {
200 let mut parsed = Vec::new();
201 for t in s.split(',') {
202 let trimmed = t.trim();
203 let mt = trimmed.parse::<MemoryType>().map_err(|_| {
204 AppError(CoreError::Validation(format!(
205 "invalid memory_type '{}' in memory_types: expected one of: episodic, semantic, procedural, working",
206 trimmed
207 )))
208 })?;
209 parsed.push(mt);
210 }
211 Some(parsed)
212 }
213 None => None,
214 };
215
216 let hybrid_weights = match params.hybrid_weights.as_deref() {
217 Some(s) => {
218 let mut weights = Vec::new();
219 for w in s.split(',') {
220 let trimmed = w.trim();
221 let val = trimmed.parse::<f32>().map_err(|_| {
222 AppError(CoreError::Validation(format!(
223 "invalid weight '{}' in hybrid_weights: expected a floating-point number",
224 trimmed
225 )))
226 })?;
227 weights.push(val);
228 }
229 Some(weights)
230 }
231 None => None,
232 };
233
234 let request = RecallRequest {
235 query: params.query,
236 agent_id: params.agent_id,
237 limit: params.limit,
238 memory_type,
239 memory_types,
240 scope,
241 min_importance: params.min_importance,
242 tags,
243 org_id: params.org_id,
244 strategy: params.strategy,
245 temporal_range: None,
246 recency_half_life_hours: None,
247 hybrid_weights,
248 rrf_k: params.rrf_k,
249 as_of: params.as_of,
250 explain: params.explain,
251 with_provenance: None,
252 mode: None,
253 current_fact_resolver: params.current_fact_key.map(|fact_key| {
254 mnemo_core::query::current_fact_resolver::CurrentFactResolverConfig {
255 fact_key,
256 include_supersession_chain: params.current_fact_include_chain.unwrap_or(false),
257 }
258 }),
259 orientation_cache: if params.orientation_cache.unwrap_or(false) {
260 Some(
261 mnemo_core::query::orientation_cache::OrientationCacheConfig {
262 namespace: params.orientation_namespace.clone(),
263 token_budget: params.orientation_token_budget,
264 include_in_response: params.orientation_include_in_response.unwrap_or(true),
265 distill: params.orientation_distill.unwrap_or(true),
266 },
267 )
268 } else {
269 None
270 },
271 evidence_budget: None,
272 retained_token_budget: None,
273 domain_scope: None,
274 reasoning_trust: None,
275 };
276
277 let response = engine.recall(request).await?;
278 Ok(Json(response))
279}
280
281pub async fn get_memory_handler(
283 State(engine): State<AppState>,
284 Path(id): Path<Uuid>,
285) -> Result<Json<serde_json::Value>, AppError> {
286 let record = engine
287 .storage
288 .get_memory(id)
289 .await?
290 .ok_or_else(|| CoreError::NotFound(format!("memory {id} not found")))?;
291
292 let value = serde_json::json!({
293 "id": record.id,
294 "agent_id": record.agent_id,
295 "content": record.content,
296 "memory_type": record.memory_type,
297 "scope": record.scope,
298 "importance": record.importance,
299 "tags": record.tags,
300 "metadata": record.metadata,
301 "source_type": record.source_type,
302 "source_id": record.source_id,
303 "consolidation_state": record.consolidation_state,
304 "access_count": record.access_count,
305 "org_id": record.org_id,
306 "thread_id": record.thread_id,
307 "created_at": record.created_at,
308 "updated_at": record.updated_at,
309 "last_accessed_at": record.last_accessed_at,
310 "expires_at": record.expires_at,
311 "deleted_at": record.deleted_at,
312 "decay_rate": record.decay_rate,
313 "created_by": record.created_by,
314 "version": record.version,
315 "prev_version_id": record.prev_version_id,
316 "quarantined": record.quarantined,
317 "quarantine_reason": record.quarantine_reason,
318 });
319
320 Ok(Json(value))
321}
322
323pub async fn forget_handler(
325 State(engine): State<AppState>,
326 Path(id): Path<Uuid>,
327 Query(params): Query<ForgetParams>,
328) -> Result<Json<ForgetResponse>, AppError> {
329 let strategy = match params.strategy.as_deref() {
330 Some(s) => Some(match s {
331 "soft_delete" => ForgetStrategy::SoftDelete,
332 "hard_delete" => ForgetStrategy::HardDelete,
333 "decay" => ForgetStrategy::Decay,
334 "consolidate" => ForgetStrategy::Consolidate,
335 "archive" => ForgetStrategy::Archive,
336 "redact" => ForgetStrategy::Redact,
337 other => {
338 return Err(AppError(CoreError::Validation(format!(
339 "invalid forget strategy '{}': expected one of: soft_delete, hard_delete, decay, consolidate, archive, redact",
340 other
341 ))));
342 }
343 }),
344 None => None,
345 };
346
347 let request = ForgetRequest {
348 memory_ids: vec![id],
349 agent_id: params.agent_id,
350 strategy,
351 criteria: None,
352 };
353
354 let response = engine.forget(request).await?;
355 Ok(Json(response))
356}
357
358#[derive(Debug, Deserialize)]
359pub struct ForgetSubjectBody {
360 pub subject_id: String,
361 pub strategy: Option<String>,
362 pub agent_id: Option<String>,
363}
364
365pub async fn forget_subject_handler(
367 State(engine): State<AppState>,
368 Json(body): Json<ForgetSubjectBody>,
369) -> Result<Json<ForgetSubjectResponse>, AppError> {
370 let strategy = match body.strategy.as_deref().unwrap_or("redact") {
371 "redact" => ForgetStrategy::Redact,
372 "hard_delete" => ForgetStrategy::HardDelete,
373 "soft_delete" => ForgetStrategy::SoftDelete,
374 other => {
375 return Err(AppError(CoreError::Validation(format!(
376 "invalid forget_subject strategy '{}': expected one of: redact, hard_delete, soft_delete",
377 other
378 ))));
379 }
380 };
381
382 let request = ForgetSubjectRequest {
383 subject_id: body.subject_id,
384 agent_id: body.agent_id,
385 strategy,
386 };
387
388 let response = engine.forget_subject(request).await?;
389 Ok(Json(response))
390}
391
392pub async fn share_handler(
394 State(engine): State<AppState>,
395 Path(id): Path<Uuid>,
396 Json(body): Json<ShareBody>,
397) -> Result<Json<ShareResponse>, AppError> {
398 let permission = match body.permission.as_deref() {
399 Some(s) => Some(s.parse::<Permission>().map_err(|_| {
400 AppError(CoreError::Validation(format!(
401 "invalid permission '{}': expected one of: read, write, delete, share, delegate, admin",
402 s
403 )))
404 })?),
405 None => None,
406 };
407
408 let request = ShareRequest {
409 memory_id: id,
410 agent_id: body.agent_id,
411 target_agent_id: body.target_agent_id,
412 target_agent_ids: body.target_agent_ids,
413 permission,
414 expires_in_hours: body.expires_in_hours,
415 };
416
417 let response = engine.share(request).await?;
418 Ok(Json(response))
419}
420
421pub async fn checkpoint_handler(
423 State(engine): State<AppState>,
424 Json(request): Json<CheckpointRequest>,
425) -> Result<Json<CheckpointResponse>, AppError> {
426 let response = engine.checkpoint(request).await?;
427 Ok(Json(response))
428}
429
430pub async fn consolidate_handler(
432 State(engine): State<AppState>,
433 Json(request): Json<ConsolidateRequest>,
434) -> Result<Json<ConsolidateResponse>, AppError> {
435 let response = engine.consolidate(request).await?;
436 Ok(Json(response))
437}
438
439pub async fn branch_handler(
441 State(engine): State<AppState>,
442 Json(request): Json<BranchRequest>,
443) -> Result<Json<BranchResponse>, AppError> {
444 let response = engine.branch(request).await?;
445 Ok(Json(response))
446}
447
448pub async fn merge_handler(
450 State(engine): State<AppState>,
451 Json(request): Json<MergeRequest>,
452) -> Result<Json<MergeResponse>, AppError> {
453 let response = engine.merge(request).await?;
454 Ok(Json(response))
455}
456
457pub async fn replay_handler(
459 State(engine): State<AppState>,
460 Json(request): Json<ReplayRequest>,
461) -> Result<Json<ReplayResponse>, AppError> {
462 let response = engine.replay(request).await?;
463 Ok(Json(response))
464}
465
466pub async fn verify_handler(
468 State(engine): State<AppState>,
469 Json(body): Json<VerifyBody>,
470) -> Result<Json<serde_json::Value>, AppError> {
471 let result = engine
472 .verify_integrity(body.agent_id, body.thread_id.as_deref())
473 .await?;
474
475 let response = serde_json::json!({
476 "valid": result.valid,
477 "total_records": result.total_records,
478 "verified_records": result.verified_records,
479 "first_broken_at": result.first_broken_at.map(|id| id.to_string()),
480 "error_message": result.error_message,
481 "status": if result.valid { "verified" } else { "integrity_violation" },
482 });
483
484 Ok(Json(response))
485}
486
487pub async fn trajectory_audit_handler(
492 State(engine): State<AppState>,
493 Json(body): Json<TrajectoryAuditBody>,
494) -> Result<Json<serde_json::Value>, AppError> {
495 let agent_id = body
496 .agent_id
497 .clone()
498 .unwrap_or_else(|| engine.default_agent_id.clone());
499
500 let mut events = engine
503 .storage
504 .list_events(&agent_id, mnemo_core::query::MAX_BATCH_QUERY_LIMIT, 0)
505 .await?;
506 events.reverse();
507
508 let mut req = mnemo_compliance::trajectory::TrajectoryAuditRequest {
509 agent_id: Some(agent_id),
510 thread_id: body.thread_id.clone(),
511 ..Default::default()
512 };
513 if let Some(c) = body.active_bank_ceiling {
514 req.active_bank_ceiling = c;
515 }
516 if let Some(k) = body.fact_key {
517 req.fact_key = k;
518 }
519 if let Some(s) = body.named_forget_strategies {
520 req.named_forget_strategies = s;
521 }
522
523 let report = mnemo_compliance::trajectory::trajectory_audit(&events, &req)
524 .map_err(|e| AppError(CoreError::Validation(e.to_string())))?;
525
526 let response = serde_json::json!({
527 "report": report,
528 "all_ok": report.all_ok(),
529 });
530
531 Ok(Json(response))
532}
533
534pub async fn delegate_handler(
540 State(engine): State<AppState>,
541 Json(body): Json<DelegateRequest>,
542) -> Result<Json<serde_json::Value>, AppError> {
543 let permission: Permission = body
544 .permission
545 .parse()
546 .map_err(|e: CoreError| AppError(e))?;
547
548 let caller_agent_id = body
549 .agent_id
550 .unwrap_or_else(|| engine.default_agent_id.clone());
551
552 let scope = if let Some(ref ids) = body.memory_ids {
553 let parsed: std::result::Result<Vec<Uuid>, _> =
554 ids.iter().map(|s| Uuid::parse_str(s)).collect();
555 match parsed {
556 Ok(uuids) => {
557 for mid in &uuids {
559 let has_perm = engine
560 .storage
561 .check_permission(*mid, &caller_agent_id, Permission::Delegate)
562 .await?;
563 if !has_perm {
564 return Err(AppError(CoreError::PermissionDenied(format!(
565 "agent '{}' lacks delegate permission on memory {}",
566 caller_agent_id, mid
567 ))));
568 }
569 }
570 DelegationScope::ByMemoryId(uuids)
571 }
572 Err(e) => {
573 return Err(AppError(CoreError::Validation(format!(
574 "invalid UUID in memory_ids: {e}"
575 ))));
576 }
577 }
578 } else if let Some(ref tags) = body.tags {
579 DelegationScope::ByTag(tags.clone())
580 } else {
581 DelegationScope::AllMemories
582 };
583
584 let now = chrono::Utc::now();
585 let expires_at = body
586 .expires_in_hours
587 .map(|h| (now + chrono::Duration::seconds((h * 3600.0) as i64)).to_rfc3339());
588
589 let delegation = Delegation {
590 id: Uuid::now_v7(),
591 delegator_id: caller_agent_id,
592 delegate_id: body.delegate_id.clone(),
593 permission,
594 scope,
595 max_depth: body.max_depth.unwrap_or(0),
596 current_depth: 0,
597 parent_delegation_id: None,
598 created_at: now.to_rfc3339(),
599 expires_at,
600 revoked_at: None,
601 };
602
603 engine.storage.insert_delegation(&delegation).await?;
604
605 let response = serde_json::json!({
606 "delegation_id": delegation.id.to_string(),
607 "delegator": delegation.delegator_id,
608 "delegate": delegation.delegate_id,
609 "permission": delegation.permission.to_string(),
610 "status": "delegated",
611 });
612
613 Ok(Json(response))
614}
615
616pub async fn health_handler() -> Json<serde_json::Value> {
618 Json(serde_json::json!({"status": "ok"}))
619}
620
621fn provenance_limit(limit: Option<usize>) -> usize {
626 limit.unwrap_or(1000).min(10_000)
628}
629
630pub async fn get_provenance_handler(
634 State(engine): State<AppState>,
635 Path(id): Path<Uuid>,
636) -> Result<Json<WriteProvenance>, AppError> {
637 let prov = engine
638 .write_provenance_for(id)
639 .await?
640 .ok_or_else(|| CoreError::NotFound(format!("no write provenance for memory {id}")))?;
641 Ok(Json(prov))
642}
643
644#[derive(Debug, Deserialize)]
645pub struct ProvenanceListParams {
646 pub limit: Option<usize>,
647}
648
649pub async fn provenance_by_principal_handler(
651 State(engine): State<AppState>,
652 Path(principal): Path<String>,
653 Query(params): Query<ProvenanceListParams>,
654) -> Result<Json<Vec<WriteProvenance>>, AppError> {
655 let writes = engine
656 .writes_by_principal(&principal, provenance_limit(params.limit))
657 .await?;
658 Ok(Json(writes))
659}
660
661pub async fn provenance_by_session_handler(
663 State(engine): State<AppState>,
664 Path(session_id): Path<String>,
665 Query(params): Query<ProvenanceListParams>,
666) -> Result<Json<Vec<WriteProvenance>>, AppError> {
667 let writes = engine
668 .writes_by_session(&session_id, provenance_limit(params.limit))
669 .await?;
670 Ok(Json(writes))
671}
672
673pub async fn verify_provenance_handler(
676 State(engine): State<AppState>,
677 Query(params): Query<ProvenanceListParams>,
678) -> Result<Json<mnemo_core::hash::ChainVerificationResult>, AppError> {
679 let result = engine
680 .verify_provenance_chain(provenance_limit(params.limit))
681 .await?;
682 Ok(Json(result))
683}
684
685#[derive(Debug, Deserialize)]
686pub struct ForgetByProvenanceBody {
687 pub principal: Option<String>,
690 pub session_id: Option<String>,
692 pub strategy: Option<String>,
694}
695
696pub async fn forget_by_provenance_handler(
701 State(engine): State<AppState>,
702 Json(body): Json<ForgetByProvenanceBody>,
703) -> Result<Json<ForgetResponse>, AppError> {
704 let strategy = match body.strategy.as_deref().unwrap_or("soft_delete") {
705 "soft_delete" => ForgetStrategy::SoftDelete,
706 "hard_delete" => ForgetStrategy::HardDelete,
707 "redact" => ForgetStrategy::Redact,
708 other => {
709 return Err(AppError(CoreError::Validation(format!(
710 "invalid forget strategy '{other}': expected one of: soft_delete, hard_delete, redact"
711 ))));
712 }
713 };
714
715 let response = match (body.principal, body.session_id) {
716 (Some(p), None) => engine.forget_by_principal(&p, strategy).await?,
717 (None, Some(s)) => engine.forget_by_session(&s, strategy).await?,
718 (Some(_), Some(_)) => {
719 return Err(AppError(CoreError::Validation(
720 "provide exactly one of 'principal' or 'session_id', not both".to_string(),
721 )));
722 }
723 (None, None) => {
724 return Err(AppError(CoreError::Validation(
725 "provide one of 'principal' or 'session_id'".to_string(),
726 )));
727 }
728 };
729 Ok(Json(response))
730}
731
732struct GenAiFields {
737 event_type: EventType,
738 model: Option<String>,
739 tokens_input: Option<i64>,
740 tokens_output: Option<i64>,
741 cost_usd: Option<f64>,
742}
743
744fn extract_genai_fields(span: &serde_json::Value) -> GenAiFields {
747 let attributes = span.get("attributes").and_then(|v| v.as_array());
748
749 let mut model = None;
750 let mut tokens_input = None;
751 let mut tokens_output = None;
752 let mut cost_usd = None;
753 let mut operation_name = None;
754
755 if let Some(attrs) = attributes {
756 for attr in attrs {
757 let key = match attr.get("key").and_then(|k| k.as_str()) {
758 Some(k) => k,
759 None => continue,
760 };
761 let value = attr.get("value");
762
763 match key {
764 "gen_ai.request.model" => {
765 model = value
766 .and_then(|v| v.get("stringValue"))
767 .and_then(|v| v.as_str())
768 .map(|s| s.to_string());
769 }
770 "gen_ai.usage.input_tokens" => {
771 tokens_input = value.and_then(|v| v.get("intValue")).and_then(|v| {
772 v.as_str()
773 .and_then(|s| s.parse::<i64>().ok())
774 .or_else(|| v.as_i64())
775 });
776 }
777 "gen_ai.usage.output_tokens" => {
778 tokens_output = value.and_then(|v| v.get("intValue")).and_then(|v| {
779 v.as_str()
780 .and_then(|s| s.parse::<i64>().ok())
781 .or_else(|| v.as_i64())
782 });
783 }
784 "gen_ai.usage.cost" => {
785 cost_usd = value
786 .and_then(|v| v.get("doubleValue"))
787 .and_then(|v| v.as_f64());
788 }
789 "gen_ai.operation.name" => {
790 operation_name = value
791 .and_then(|v| v.get("stringValue"))
792 .and_then(|v| v.as_str())
793 .map(|s| s.to_string());
794 }
795 _ => {}
796 }
797 }
798 }
799
800 let op = operation_name.or_else(|| {
802 span.get("name")
803 .and_then(|v| v.as_str())
804 .map(|s| s.to_string())
805 });
806
807 let event_type = match op.as_deref() {
809 Some(s) if s.contains("chat") => EventType::AssistantMessage,
810 Some(s) if s.contains("embed") => EventType::RetrievalQuery,
811 Some(s) if s.contains("tool") => EventType::ToolCall,
812 _ => EventType::ToolCall, };
814
815 GenAiFields {
816 event_type,
817 model,
818 tokens_input,
819 tokens_output,
820 cost_usd,
821 }
822}
823
824pub async fn otlp_ingest_handler(
826 State(engine): State<AppState>,
827 Json(body): Json<serde_json::Value>,
828) -> Result<Json<serde_json::Value>, AppError> {
829 let resource_spans = body
830 .get("resourceSpans")
831 .and_then(|v| v.as_array())
832 .cloned()
833 .unwrap_or_default();
834
835 let mut count: u64 = 0;
836
837 for rs in &resource_spans {
838 let resource_agent_id = rs
840 .get("resource")
841 .and_then(|r| r.get("attributes"))
842 .and_then(|attrs| attrs.as_array())
843 .and_then(|attrs| {
844 attrs.iter().find_map(|attr| {
845 let key = attr.get("key")?.as_str()?;
846 if key == "agent.id" || key == "service.name" {
847 attr.get("value")
848 .and_then(|v| v.get("stringValue"))
849 .and_then(|v| v.as_str())
850 .map(|s| s.to_string())
851 } else {
852 None
853 }
854 })
855 });
856
857 let scope_spans = rs
858 .get("scopeSpans")
859 .and_then(|v| v.as_array())
860 .cloned()
861 .unwrap_or_default();
862
863 for ss in &scope_spans {
864 let spans = ss
865 .get("spans")
866 .and_then(|v| v.as_array())
867 .cloned()
868 .unwrap_or_default();
869
870 for span in &spans {
871 let trace_id = span
872 .get("traceId")
873 .and_then(|v| v.as_str())
874 .map(|s| s.to_string());
875
876 let span_id = span
877 .get("spanId")
878 .and_then(|v| v.as_str())
879 .map(|s| s.to_string());
880
881 let agent_id = resource_agent_id
882 .clone()
883 .unwrap_or_else(|| engine.default_agent_id.clone());
884
885 let start_nano: u64 = span
888 .get("startTimeUnixNano")
889 .and_then(|v| {
890 v.as_str()
891 .and_then(|s| s.parse::<u64>().ok())
892 .or_else(|| v.as_u64())
893 })
894 .unwrap_or(0);
895
896 let end_nano: u64 = span
897 .get("endTimeUnixNano")
898 .and_then(|v| {
899 v.as_str()
900 .and_then(|s| s.parse::<u64>().ok())
901 .or_else(|| v.as_u64())
902 })
903 .unwrap_or(0);
904
905 let latency_ms = if end_nano > start_nano {
906 Some(((end_nano - start_nano) / 1_000_000) as i64)
907 } else {
908 None
909 };
910
911 let timestamp = if start_nano > 0 {
913 let secs = (start_nano / 1_000_000_000) as i64;
914 let nsecs = (start_nano % 1_000_000_000) as u32;
915 chrono::DateTime::from_timestamp(secs, nsecs)
916 .map(|dt| dt.to_rfc3339())
917 .unwrap_or_else(|| chrono::Utc::now().to_rfc3339())
918 } else {
919 chrono::Utc::now().to_rfc3339()
920 };
921
922 let payload = span
924 .get("attributes")
925 .cloned()
926 .unwrap_or(serde_json::json!({}));
927
928 let genai = extract_genai_fields(span);
929
930 let content_hash =
931 compute_content_hash(&payload.to_string(), &agent_id, ×tamp);
932
933 let event = AgentEvent {
934 id: Uuid::now_v7(),
935 agent_id,
936 thread_id: None,
937 run_id: None,
938 parent_event_id: None,
939 event_type: genai.event_type,
940 payload,
941 trace_id,
942 span_id,
943 model: genai.model,
944 tokens_input: genai.tokens_input,
945 tokens_output: genai.tokens_output,
946 latency_ms,
947 cost_usd: genai.cost_usd,
948 timestamp,
949 logical_clock: 0,
950 content_hash,
951 prev_hash: None,
952 embedding: None,
953 };
954
955 engine.storage.insert_event(&event).await?;
956 count += 1;
957 }
958 }
959 }
960
961 Ok(Json(serde_json::json!({"accepted": count})))
962}
963
964#[cfg(test)]
969mod tests {
970 use super::*;
971
972 #[test]
973 fn test_extract_genai_fields_chat_span() {
974 let span = serde_json::json!({
975 "name": "chat gpt-4",
976 "attributes": [
977 {"key": "gen_ai.request.model", "value": {"stringValue": "gpt-4"}},
978 {"key": "gen_ai.usage.input_tokens", "value": {"intValue": "150"}},
979 {"key": "gen_ai.usage.output_tokens", "value": {"intValue": "50"}},
980 {"key": "gen_ai.usage.cost", "value": {"doubleValue": 0.006}},
981 {"key": "gen_ai.operation.name", "value": {"stringValue": "chat"}}
982 ]
983 });
984 let fields = extract_genai_fields(&span);
985 assert_eq!(fields.event_type, EventType::AssistantMessage);
986 assert_eq!(fields.model.as_deref(), Some("gpt-4"));
987 assert_eq!(fields.tokens_input, Some(150));
988 assert_eq!(fields.tokens_output, Some(50));
989 assert!((fields.cost_usd.unwrap() - 0.006).abs() < 1e-9);
990 }
991
992 #[test]
993 fn test_extract_genai_fields_non_genai_default() {
994 let span = serde_json::json!({
995 "name": "http.request",
996 "attributes": [
997 {"key": "http.method", "value": {"stringValue": "GET"}}
998 ]
999 });
1000 let fields = extract_genai_fields(&span);
1001 assert_eq!(fields.event_type, EventType::ToolCall);
1002 assert!(fields.model.is_none());
1003 assert!(fields.tokens_input.is_none());
1004 }
1005}