cognee_http_server/routers/
activity.rs1use axum::Json;
16use axum::Router;
17use axum::extract::{Path, Query, State};
18use axum::http::{StatusCode, header};
19use axum::response::{IntoResponse, Response};
20use axum::routing::get;
21use chrono::{DateTime, SecondsFormat, Utc};
22use cognee_database::DeleteDb;
23use cognee_database::IngestDb;
24use cognee_database::PipelineRunRepository;
25use cognee_database::SeaOrmPipelineRunRepository;
26use cognee_models::Data;
27use serde::Deserialize;
28use uuid::Uuid;
29
30use crate::auth::AuthenticatedUser;
31use crate::dto::activity::{
32 AgentDTO, PipelineRunListItemDTO, RecordedSpanDTO, SpansErrorEnvelopeDTO, TenantUserDTO,
33 TraceSummaryDTO,
34};
35use crate::error::ApiError;
36use crate::observability::SpanStatus;
37use crate::state::AppState;
38
39pub fn router() -> Router<AppState> {
43 Router::new()
44 .route("/pipeline-runs", get(get_pipeline_runs))
45 .route("/spans", get(get_spans))
46 .route("/users", get(get_users))
47 .route("/agents", get(get_agents))
48 .route("/export/{dataset_id}", get(get_export))
49}
50
51#[derive(Debug, Deserialize)]
54pub struct PipelineRunsQuery {
55 pub dataset_id: Option<Uuid>,
56}
57
58pub async fn get_pipeline_runs(
63 State(state): State<AppState>,
64 _user: AuthenticatedUser,
65 Query(filter): Query<PipelineRunsQuery>,
66) -> Result<Json<Vec<PipelineRunListItemDTO>>, ApiError> {
67 let handles = state
68 .components()
69 .ok_or_else(|| ApiError::Internal(anyhow::anyhow!("components not initialized")))?;
70 let repo = SeaOrmPipelineRunRepository::new(handles.database.clone());
71 let rows = repo
72 .list_recent_with_attribution(filter.dataset_id, 50)
73 .await
74 .map_err(|e| ApiError::Internal(anyhow::anyhow!(e)))?;
75
76 let dtos = rows
77 .into_iter()
78 .map(|r| PipelineRunListItemDTO {
79 id: r.id,
80 pipeline_name: r.pipeline_name,
81 status: Some(status_to_str(&r.status)),
82 dataset_id: r.dataset_id,
83 dataset_name: r.dataset_name,
84 owner_id: r.owner_id,
85 owner_email: r.owner_email,
86 created_at: Some(format_iso8601(r.created_at)),
87 pipeline_run_id: Some(r.pipeline_run_id),
88 })
89 .collect();
90 Ok(Json(dtos))
91}
92
93fn status_to_str(s: &cognee_database::PipelineRunStatus) -> String {
94 match s {
95 cognee_database::PipelineRunStatus::Initiated => "DATASET_PROCESSING_INITIATED".into(),
96 cognee_database::PipelineRunStatus::Started => "DATASET_PROCESSING_STARTED".into(),
97 cognee_database::PipelineRunStatus::Completed => "DATASET_PROCESSING_COMPLETED".into(),
98 cognee_database::PipelineRunStatus::Errored => "DATASET_PROCESSING_ERRORED".into(),
99 }
100}
101
102fn format_iso8601(t: DateTime<Utc>) -> String {
106 t.to_rfc3339_opts(SecondsFormat::AutoSi, false)
107}
108
109#[tracing::instrument(name = "cognee.api.activity.spans", skip_all)]
117pub async fn get_spans(State(state): State<AppState>, _user: AuthenticatedUser) -> Response {
118 let result =
122 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| state.spans.all_traces()));
123 match result {
124 Ok(traces) => {
125 let dtos: Vec<TraceSummaryDTO> = traces
126 .into_iter()
127 .map(|t| TraceSummaryDTO {
128 trace_id: t.trace_id,
129 root_name: t.root_name,
130 duration_ms: t.duration_ms,
131 span_count: t.span_count,
132 status: t.status.map(span_status_to_string),
133 spans: t
134 .spans
135 .into_iter()
136 .map(|s| RecordedSpanDTO {
137 name: s.name,
138 trace_id: s.trace_id,
139 span_id: s.span_id,
140 parent_span_id: s.parent_span_id,
141 start_time_ns: s.start_time_ns,
142 end_time_ns: s.end_time_ns,
143 duration_ms: s.duration_ms,
144 status: span_status_to_string(s.status),
145 attributes: s.attributes,
146 })
147 .collect(),
148 })
149 .collect();
150 (StatusCode::OK, Json(dtos)).into_response()
151 }
152 Err(_) => {
153 tracing::error!("spans buffer read failed (panic in catch_unwind)");
154 (
155 StatusCode::OK,
156 Json(SpansErrorEnvelopeDTO {
157 error: "spans buffer read failed".into(),
158 }),
159 )
160 .into_response()
161 }
162 }
163}
164
165fn span_status_to_string(status: SpanStatus) -> String {
166 status.as_str().to_string()
167}
168
169pub async fn get_users(
178 State(_state): State<AppState>,
179 _user: AuthenticatedUser,
180) -> Json<Vec<TenantUserDTO>> {
181 Json(Vec::new())
182}
183
184pub async fn get_agents(
192 State(_state): State<AppState>,
193 _user: AuthenticatedUser,
194) -> Result<Json<Vec<AgentDTO>>, ApiError> {
195 Ok(Json(Vec::new()))
196}
197
198fn sanitize_filename(name: &str) -> String {
205 name.chars()
206 .filter(|c| *c != '\r' && *c != '\n')
207 .map(|c| if c == '"' { '\'' } else { c })
208 .collect()
209}
210
211pub async fn get_export(
213 State(state): State<AppState>,
214 user: AuthenticatedUser,
215 Path(dataset_id): Path<Uuid>,
216) -> Response {
217 let Some(handles) = state.components() else {
218 return (
219 StatusCode::INTERNAL_SERVER_ERROR,
220 "components not initialized",
221 )
222 .into_response();
223 };
224
225 let dataset = match handles.database.get_dataset(dataset_id).await {
227 Ok(Some(ds)) => ds,
228 Ok(None) => {
229 return (
230 StatusCode::NOT_FOUND,
231 [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
232 "Dataset not found",
233 )
234 .into_response();
235 }
236 Err(e) => {
237 return (
238 StatusCode::INTERNAL_SERVER_ERROR,
239 format!("export error: {e}"),
240 )
241 .into_response();
242 }
243 };
244
245 let docs = match handles.database.get_dataset_data(dataset_id).await {
247 Ok(rows) => rows,
248 Err(e) => {
249 return (
250 StatusCode::INTERNAL_SERVER_ERROR,
251 format!("export error: {e}"),
252 )
253 .into_response();
254 }
255 };
256
257 let graph_data = handles
259 .formatted_graph_data(Some(dataset_id), user.id)
260 .await
261 .unwrap_or_else(|e| {
262 tracing::warn!(error = %e, "graph fetch failed during export");
263 serde_json::json!({"nodes": [], "edges": []})
264 });
265
266 let nodes = graph_data
267 .get("nodes")
268 .and_then(|v| v.as_array())
269 .cloned()
270 .unwrap_or_default();
271 let edges = graph_data
272 .get("edges")
273 .and_then(|v| v.as_array())
274 .cloned()
275 .unwrap_or_default();
276
277 let body = render_markdown(&dataset.name, &docs, &nodes, &edges, Utc::now());
278 let filename = format!("{}-memory-export.md", sanitize_filename(&dataset.name));
279 (
280 StatusCode::OK,
281 [
282 (
283 header::CONTENT_TYPE,
284 "text/markdown; charset=utf-8".to_string(),
285 ),
286 (
287 header::CONTENT_DISPOSITION,
288 format!("attachment; filename=\"{filename}\""),
289 ),
290 ],
291 body,
292 )
293 .into_response()
294}
295
296fn render_markdown(
306 dataset_name: &str,
307 docs: &[Data],
308 nodes: &[serde_json::Value],
309 edges: &[serde_json::Value],
310 now: DateTime<Utc>,
311) -> String {
312 let mut lines: Vec<String> = Vec::new();
313
314 let mut entities: Vec<&serde_json::Value> = Vec::new();
316 let mut summaries: Vec<&serde_json::Value> = Vec::new();
317 let mut others: Vec<&serde_json::Value> = Vec::new();
318 let mut node_label_by_id: std::collections::HashMap<String, String> =
319 std::collections::HashMap::new();
320 for n in nodes {
321 let ty = n.get("type").and_then(|v| v.as_str()).unwrap_or("");
322 let id = n
323 .get("id")
324 .and_then(|v| v.as_str())
325 .map(|s| s.to_string())
326 .unwrap_or_default();
327 let label = n
328 .get("label")
329 .and_then(|v| v.as_str())
330 .map(|s| s.to_string())
331 .unwrap_or_default();
332 if !id.is_empty() {
333 node_label_by_id.insert(id, label.clone());
334 }
335 match ty {
336 "Entity" => entities.push(n),
337 "TextSummary" => summaries.push(n),
338 "DocumentChunk" | "TextDocument" => {} _ => others.push(n),
340 }
341 }
342
343 lines.push(format!("# Dataset: {dataset_name}"));
345 lines.push(String::new());
346 lines.push(format!(
347 "Exported: {} | {} documents | {} entities | {} relationships",
348 now.format("%b %d, %Y %H:%M UTC"),
349 docs.len(),
350 entities.len(),
351 edges.len(),
352 ));
353 lines.push(String::new());
354
355 if !summaries.is_empty() {
357 lines.push("## Summaries".into());
358 lines.push(String::new());
359 for s in &summaries {
360 let text = s
361 .get("properties")
362 .and_then(|p| p.get("text"))
363 .and_then(|v| v.as_str())
364 .unwrap_or("");
365 lines.push(format!("> {text}"));
366 }
367 lines.push(String::new());
368 }
369
370 if !entities.is_empty() {
372 lines.push("## Entities".into());
373 lines.push(String::new());
374 lines.push("| Entity | Description |".into());
375 lines.push("|--------|-------------|".into());
376 for e in &entities {
377 let label = e.get("label").and_then(|v| v.as_str()).unwrap_or("");
378 let description = e
379 .get("properties")
380 .and_then(|p| p.get("description"))
381 .and_then(|v| v.as_str())
382 .unwrap_or("");
383 lines.push(format!(
384 "| {} | {} |",
385 escape_pipes(label),
386 escape_pipes(&description.replace('\n', " ")),
387 ));
388 }
389 lines.push(String::new());
390 }
391
392 if !edges.is_empty() {
394 lines.push("## Relationships".into());
395 lines.push(String::new());
396 lines.push("| Source | Relationship | Target |".into());
397 lines.push("|--------|-------------|--------|".into());
398 for edge in edges {
399 let source_id = edge.get("source").and_then(|v| v.as_str()).unwrap_or("?");
400 let target_id = edge.get("target").and_then(|v| v.as_str()).unwrap_or("?");
401 let label = edge
402 .get("label")
403 .and_then(|v| v.as_str())
404 .filter(|s| !s.is_empty())
405 .unwrap_or("related_to");
406 let source_label = node_label_by_id
407 .get(source_id)
408 .cloned()
409 .unwrap_or_else(|| source_id.chars().take(12).collect());
410 let target_label = node_label_by_id
411 .get(target_id)
412 .cloned()
413 .unwrap_or_else(|| target_id.chars().take(12).collect());
414 lines.push(format!(
415 "| {} | {} | {} |",
416 escape_pipes(&source_label),
417 escape_pipes(label),
418 escape_pipes(&target_label),
419 ));
420 }
421 lines.push(String::new());
422 }
423
424 if !docs.is_empty() {
426 lines.push("## Documents".into());
427 lines.push(String::new());
428 for d in docs {
429 let name = if d.name.is_empty() {
430 "unnamed".to_string()
431 } else {
432 d.name.clone()
433 };
434 let extension = d.extension.to_uppercase();
435 let created = d.created_at.format("%b %d, %Y").to_string();
436 lines.push(format!("- **{name}** ({extension}, {created})"));
437 }
438 lines.push(String::new());
439 }
440
441 if !others.is_empty() {
443 lines.push("## Other Nodes".into());
444 lines.push(String::new());
445 for n in &others {
446 let ty = n.get("type").and_then(|v| v.as_str()).unwrap_or("");
447 let label = n.get("label").and_then(|v| v.as_str()).unwrap_or("");
448 lines.push(format!("- [{ty}] {label}"));
449 }
450 lines.push(String::new());
451 }
452
453 lines.join("\n")
454}
455
456fn escape_pipes(s: &str) -> String {
457 s.replace('|', r"\|")
458}
459
460#[cfg(test)]
461#[allow(
462 clippy::unwrap_used,
463 clippy::expect_used,
464 reason = "test code — panics are acceptable failures"
465)]
466mod tests {
467 use super::*;
468
469 #[test]
470 fn render_markdown_pipe_escape() {
471 let nodes = vec![serde_json::json!({
473 "id": "n1",
474 "type": "Entity",
475 "label": "a|b",
476 "properties": {"description": "fine"},
477 })];
478 let body = render_markdown("ds", &[], &nodes, &[], Utc::now());
479 assert!(body.contains(r"a\|b"));
480 }
481
482 #[test]
483 fn render_markdown_section_gating_no_entities() {
484 let body = render_markdown("ds", &[], &[], &[], Utc::now());
485 assert!(!body.contains("## Entities"));
486 assert!(!body.contains("## Summaries"));
487 assert!(!body.contains("## Relationships"));
488 assert!(!body.contains("## Documents"));
489 assert!(body.contains("# Dataset: ds"));
490 }
491
492 #[test]
493 fn render_markdown_uses_related_to_fallback() {
494 let edges = vec![serde_json::json!({
495 "source": "A",
496 "target": "B",
497 })];
498 let body = render_markdown("ds", &[], &[], &edges, Utc::now());
499 assert!(body.contains("related_to"));
500 }
501
502 #[test]
503 fn iso_format_produces_trailing_zero_offset() {
504 let t = DateTime::parse_from_rfc3339("2026-04-24T18:30:00Z")
505 .expect("parse")
506 .with_timezone(&Utc);
507 let s = format_iso8601(t);
508 assert!(s.starts_with("2026-04-24T18:30:00"), "got {s}");
510 assert!(s.ends_with("+00:00"), "got {s}");
511 }
512
513 #[test]
514 fn sanitize_filename_strips_crlf_and_quotes() {
515 assert_eq!(sanitize_filename("ok\nname"), "okname");
516 assert_eq!(sanitize_filename("a\"b"), "a'b");
517 }
518
519 #[test]
520 fn span_status_to_string_matches_wire() {
521 assert_eq!(span_status_to_string(SpanStatus::Ok), "OK");
522 assert_eq!(span_status_to_string(SpanStatus::Error), "ERROR");
523 assert_eq!(span_status_to_string(SpanStatus::Unset), "UNSET");
524 }
525}