Skip to main content

cognee_http_server/routers/
activity.rs

1//! Activity & telemetry endpoints.
2//!
3//! Five handlers:
4//! - `GET /pipeline-runs` — durable observability tier (joins `pipeline_runs ⨝
5//!   datasets ⨝ users`).
6//! - `GET /spans` — live observability tier (in-memory ring buffer).
7//! - `GET /users` — list of users in the *default user's* tenant (Python
8//!   parity: not the authenticated user's tenant).
9//! - `GET /agents` — active users with agent metadata (OSS returns an empty
10//!   list; the closed `cognee-http-cloud` crate provides the real handler).
11//! - `GET /export/{dataset_id}` — Markdown report for one dataset.
12//!
13//! See [`docs/http-server/routers/activity.md`](../../../../docs/http-server/routers/activity.md).
14
15use 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
39// ─── Mount ───────────────────────────────────────────────────────────────────
40
41/// Build the activity router. Mounted by `build_router` at `/api/v1/activity`.
42pub 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// ─── 2.1  GET /pipeline-runs ─────────────────────────────────────────────────
52
53#[derive(Debug, Deserialize)]
54pub struct PipelineRunsQuery {
55    pub dataset_id: Option<Uuid>,
56}
57
58/// `GET /api/v1/activity/pipeline-runs` — list recent pipeline runs.
59///
60/// Reads `pipeline_runs ⨝ datasets ⨝ users` so the response carries
61/// "who/what/which dataset" attribution. No tenant filter (Python parity).
62pub 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
102/// `chrono::DateTime<Utc>::to_rfc3339_opts(SecondsFormat::AutoSi, false)`
103/// produces `"2026-04-24T18:30:00+00:00"` — matches Python's
104/// `datetime.isoformat()`. Passing `true` would emit `"...Z"` instead.
105fn format_iso8601(t: DateTime<Utc>) -> String {
106    t.to_rfc3339_opts(SecondsFormat::AutoSi, false)
107}
108
109// ─── 2.2  GET /spans ─────────────────────────────────────────────────────────
110
111/// `GET /api/v1/activity/spans` — read the in-memory span buffer.
112///
113/// **SELF-REFERENTIAL**: this handler emits a `cognee.api.activity.spans` span
114/// that lands in the buffer and shows up in the *next* call's response.
115/// Documented in [`docs/http-server/routers/activity.md §6.6`](../../../../docs/http-server/routers/activity.md#6-open-questions).
116#[tracing::instrument(name = "cognee.api.activity.spans", skip_all)]
117pub async fn get_spans(State(state): State<AppState>, _user: AuthenticatedUser) -> Response {
118    // Python wraps the entire body in a `try/except` and returns 200 with
119    // `{"error": "..."}` on failure. Our buffer read can only panic on a
120    // poisoned mutex; we catch via `catch_unwind` to mirror the wire shape.
121    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
169// ─── 2.3  GET /users ─────────────────────────────────────────────────────────
170
171/// `GET /api/v1/activity/users` — list users in the *default user's* tenant.
172///
173/// OSS stub: the auth tables (`users`, `tenants`, `user_tenants`) moved
174/// closed alongside `PermissionsRepository`, so the OSS surface returns
175/// an empty list. The closed `cognee-http-cloud` crate re-introduces the
176/// real handler via its own router.
177pub async fn get_users(
178    State(_state): State<AppState>,
179    _user: AuthenticatedUser,
180) -> Json<Vec<TenantUserDTO>> {
181    Json(Vec::new())
182}
183
184// ─── 2.4  GET /agents ────────────────────────────────────────────────────────
185
186/// `GET /api/v1/activity/agents` — list every active user with agent metadata.
187///
188/// OSS stub: the `users` / `user_api_key` tables moved closed
189/// alongside `SeaOrmUserAuthRepository`. OSS returns an empty list; the
190/// closed `cognee-http-cloud` crate provides the real handler.
191pub 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
198// ─── 2.5  GET /export/{dataset_id} ───────────────────────────────────────────
199
200/// Sanitize a dataset name for use in `Content-Disposition: filename=`.
201///
202/// RFC 6266 minimal: strip CR/LF, replace `"` with `'`. Python doesn't
203/// URL-encode, neither do we.
204fn 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
211/// `GET /api/v1/activity/export/{dataset_id}` — Markdown report.
212pub 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    // 1. Dataset lookup. 404 body is plain text per Python parity.
226    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    // 2. Documents in the dataset.
246    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    // 3. Graph data — errors silently swallow to empty (Python parity).
258    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
296// ─── Markdown rendering ──────────────────────────────────────────────────────
297
298/// Render the Markdown body for `/export/{dataset_id}`.
299///
300/// Mirrors Python's L248–L319 verbatim:
301/// - header, summaries, entities, relationships, documents, other-nodes
302/// - section gating on emptiness
303/// - `|` → `\|` in table cells; `\n` → ` ` in entity descriptions
304/// - `"related_to"` edge fallback; first-12-chars source/target fallback
305fn 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    // Categorize nodes.
315    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" => {} // silently dropped
339            _ => others.push(n),
340        }
341    }
342
343    // Header
344    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    // Summaries
356    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    // Entities
371    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    // Relationships
393    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    // Documents
425    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    // Other nodes
442    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        // One entity whose label contains a pipe.
472        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        // SecondsFormat::AutoSi suppresses fractional seconds when zero.
509        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}