magic-lab-observer-backend 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
app/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """LLM Observatory Backend - FastAPI application."""
app/api/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """API routers for LLM Observatory."""
2
+
3
+ from app.api import analytics, evaluations, traces
4
+
5
+ __all__ = ["traces", "evaluations", "analytics"]
app/api/analytics.py ADDED
@@ -0,0 +1,389 @@
1
+ """Analytics API endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timedelta, timezone
6
+
7
+ from fastapi import APIRouter, Depends, Query
8
+ from pydantic import BaseModel
9
+ from sqlalchemy import func, select
10
+ from sqlalchemy.ext.asyncio import AsyncSession
11
+
12
+ from app.db import get_session
13
+ from app.models.evaluation import Evaluation
14
+ from app.models.trace import Span, Trace
15
+
16
+ router = APIRouter()
17
+
18
+
19
+ class AnalyticsSummary(BaseModel):
20
+ """Summary analytics response."""
21
+
22
+ total_traces: int
23
+ total_spans: int
24
+ total_cost_usd: float
25
+ avg_latency_ms: float
26
+ error_count: int
27
+ error_rate: float
28
+ total_evaluations: int
29
+ avg_evaluation_score: float | None
30
+
31
+
32
+ class CostByModel(BaseModel):
33
+ """Cost breakdown by model."""
34
+
35
+ model: str
36
+ cost_usd: float
37
+ span_count: int
38
+
39
+
40
+ class TraceTimeline(BaseModel):
41
+ """Trace count over time."""
42
+
43
+ timestamp: str
44
+ count: int
45
+ cost_usd: float
46
+
47
+
48
+ class QualityBreakdown(BaseModel):
49
+ """Per-turn occurrence count for one bounded metadata value."""
50
+
51
+ key: str
52
+ count: int
53
+ rate: float
54
+
55
+
56
+ class ManitOSQualitySummary(BaseModel):
57
+ """Metadata-only quality indicators emitted by a compatible integration."""
58
+
59
+ project_id: str
60
+ environment: str | None
61
+ hours: int
62
+ window_start: str | None = None
63
+ window_end: str | None = None
64
+ total_turns: int
65
+ error_count: int
66
+ error_rate: float
67
+ degraded_count: int
68
+ degraded_rate: float
69
+ truncated_count: int
70
+ truncated_rate: float
71
+ tool_error_count: int
72
+ tool_error_rate: float
73
+ fallback_count: int
74
+ fallback_rate: float
75
+ tts_error_count: int
76
+ tts_error_rate: float
77
+ avg_duration_ms: float
78
+ avg_ttft_ms: float
79
+ models: list[QualityBreakdown]
80
+ languages: list[QualityBreakdown]
81
+
82
+
83
+ @router.get("/summary", response_model=AnalyticsSummary)
84
+ async def get_analytics_summary(
85
+ session_id: str | None = Query(None),
86
+ hours: int = Query(24, ge=1, le=720),
87
+ session: AsyncSession = Depends(get_session),
88
+ ) -> dict:
89
+ """Get analytics summary for the specified time period."""
90
+ cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
91
+
92
+ # Total traces
93
+ trace_query = select(func.count(Trace.id)).where(Trace.created_at >= cutoff)
94
+ if session_id:
95
+ trace_query = trace_query.where(Trace.session_id == session_id)
96
+ result = await session.execute(trace_query)
97
+ total_traces = result.scalar() or 0
98
+
99
+ # Total spans and cost
100
+ span_query = select(Span.start_time, Span.end_time, Span.cost_usd).join(
101
+ Trace, Span.trace_id == Trace.id
102
+ ).where(Trace.created_at >= cutoff)
103
+ if session_id:
104
+ span_query = span_query.where(Trace.session_id == session_id)
105
+ result = await session.execute(span_query)
106
+ span_rows = result.all()
107
+ total_spans = len(span_rows)
108
+ total_cost_usd = sum(float(row.cost_usd or 0) for row in span_rows)
109
+ latencies_ms = [
110
+ (row.end_time - row.start_time).total_seconds() * 1000
111
+ for row in span_rows
112
+ if row.start_time is not None and row.end_time is not None
113
+ ]
114
+ avg_latency_ms = sum(latencies_ms) / len(latencies_ms) if latencies_ms else 0.0
115
+
116
+ # Error count
117
+ error_query = select(func.count(Trace.id)).where(
118
+ Trace.created_at >= cutoff, Trace.status == "error"
119
+ )
120
+ if session_id:
121
+ error_query = error_query.where(Trace.session_id == session_id)
122
+ result = await session.execute(error_query)
123
+ error_count = result.scalar() or 0
124
+
125
+ # Evaluations
126
+ eval_query = select(
127
+ func.count(Evaluation.id),
128
+ func.coalesce(func.avg(Evaluation.score), 0),
129
+ ).join(Trace, Evaluation.trace_id == Trace.id).where(Trace.created_at >= cutoff)
130
+ if session_id:
131
+ eval_query = eval_query.where(Trace.session_id == session_id)
132
+ result = await session.execute(eval_query)
133
+ eval_data = result.one()
134
+ total_evaluations = eval_data[0] or 0
135
+ avg_score = float(eval_data[1] or 0) if total_evaluations > 0 else None
136
+
137
+ return {
138
+ "total_traces": total_traces,
139
+ "total_spans": total_spans,
140
+ "total_cost_usd": total_cost_usd,
141
+ "avg_latency_ms": avg_latency_ms,
142
+ "error_count": error_count,
143
+ "error_rate": error_count / total_traces if total_traces > 0 else 0,
144
+ "total_evaluations": total_evaluations,
145
+ "avg_evaluation_score": avg_score,
146
+ }
147
+
148
+
149
+ @router.get("/cost-by-model", response_model=list[CostByModel])
150
+ async def get_cost_by_model(
151
+ hours: int = Query(24, ge=1, le=720),
152
+ session: AsyncSession = Depends(get_session),
153
+ ) -> list[dict]:
154
+ """Get cost breakdown by model (from span attributes)."""
155
+ cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
156
+
157
+ # SQLAlchemy JSON extraction differs between SQLite and PostgreSQL.
158
+ # Aggregate this bounded time-window result in Python for identical
159
+ # semantics on both supported databases.
160
+ query = select(Span.attributes, Span.cost_usd).join(
161
+ Trace, Span.trace_id == Trace.id
162
+ ).where(Trace.created_at >= cutoff)
163
+ result = await session.execute(query)
164
+ rows = result.all()
165
+ by_model: dict[str, dict[str, str | float | int]] = {}
166
+ for row in rows:
167
+ attributes = row.attributes if isinstance(row.attributes, dict) else {}
168
+ model = str(attributes.get("model") or "").strip()
169
+ if not model:
170
+ continue
171
+ bucket = by_model.setdefault(model, {"model": model, "cost_usd": 0.0, "span_count": 0})
172
+ bucket["cost_usd"] = float(bucket["cost_usd"]) + float(row.cost_usd or 0)
173
+ bucket["span_count"] = int(bucket["span_count"]) + 1
174
+ return sorted(by_model.values(), key=lambda item: float(item["cost_usd"]), reverse=True)
175
+
176
+
177
+ @router.get("/timeline", response_model=list[TraceTimeline])
178
+ async def get_trace_timeline(
179
+ hours: int = Query(24, ge=1, le=720),
180
+ interval_minutes: int = Query(60, ge=5, le=1440),
181
+ session: AsyncSession = Depends(get_session),
182
+ ) -> list[dict]:
183
+ """Get trace count and cost over time."""
184
+ cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
185
+
186
+ # Get all traces in the time range
187
+ query = (
188
+ select(Trace)
189
+ .where(Trace.created_at >= cutoff)
190
+ .order_by(Trace.created_at)
191
+ )
192
+ result = await session.execute(query)
193
+ traces = list(result.scalars().all())
194
+
195
+ # Get all span costs for traces in the time range (single query, no N+1)
196
+ cost_query = select(
197
+ Span.trace_id,
198
+ func.coalesce(func.sum(Span.cost_usd), 0).label("cost_usd"),
199
+ ).join(Trace, Span.trace_id == Trace.id).where(
200
+ Trace.created_at >= cutoff
201
+ ).group_by(Span.trace_id)
202
+ cost_result = await session.execute(cost_query)
203
+ cost_by_trace = {row.trace_id: float(row.cost_usd) for row in cost_result.all()}
204
+
205
+ # Bucket traces by interval
206
+ buckets: dict[str, dict] = {}
207
+ for trace in traces:
208
+ # Truncate to interval
209
+ ts = trace.created_at.replace(
210
+ minute=(trace.created_at.minute // interval_minutes) * interval_minutes,
211
+ second=0,
212
+ microsecond=0,
213
+ )
214
+ key = ts.isoformat()
215
+ if key not in buckets:
216
+ buckets[key] = {"timestamp": key, "count": 0, "cost_usd": 0}
217
+ buckets[key]["count"] += 1
218
+ buckets[key]["cost_usd"] += cost_by_trace.get(trace.id, 0)
219
+
220
+ return list(buckets.values())
221
+
222
+
223
+ @router.get("/manitos-quality", response_model=ManitOSQualitySummary)
224
+ async def get_manitos_quality(
225
+ hours: int = Query(24, ge=1, le=720),
226
+ project_id: str = Query("manitos", min_length=1, max_length=128),
227
+ environment: str | None = Query(None, min_length=1, max_length=64),
228
+ since: datetime | None = Query(None),
229
+ until: datetime | None = Query(None),
230
+ session: AsyncSession = Depends(get_session),
231
+ ) -> dict:
232
+ """Aggregate bounded, metadata-only turn quality signals across SQL dialects.
233
+
234
+ With ``since`` the aggregation uses a fixed half-open interval
235
+ ``[since, until or now)`` instead of the rolling ``hours`` window, so the
236
+ result is genuinely cumulative over that interval and never mixes turns
237
+ aging out of a sliding cutoff.
238
+ """
239
+ now = datetime.now(timezone.utc)
240
+ if since is not None:
241
+ if since.tzinfo is None:
242
+ since = since.replace(tzinfo=timezone.utc)
243
+ window_start = since
244
+ window_end = until or now
245
+ if window_end.tzinfo is None:
246
+ window_end = window_end.replace(tzinfo=timezone.utc)
247
+ cutoff = window_start
248
+ else:
249
+ cutoff = now - timedelta(hours=hours)
250
+ window_start = cutoff
251
+ window_end = now
252
+ trace_query = select(Trace.id, Trace.status).where(
253
+ Trace.created_at >= cutoff, Trace.created_at < window_end,
254
+ Trace.project_id == project_id,
255
+ )
256
+ if environment:
257
+ trace_query = trace_query.where(Trace.environment == environment)
258
+ trace_rows = (await session.execute(trace_query)).all()
259
+ trace_ids = {str(row.id) for row in trace_rows}
260
+ total_turns = len(trace_ids)
261
+ error_count = sum(1 for row in trace_rows if row.status == "error")
262
+
263
+ degraded: set[str] = set()
264
+ truncated: set[str] = set()
265
+ tool_errors: set[str] = set()
266
+ fallbacks: set[str] = set()
267
+ tts_errors: set[str] = set()
268
+ durations: list[float] = []
269
+ ttfts: list[float] = []
270
+ models: dict[str, set[str]] = {}
271
+ languages: dict[str, set[str]] = {}
272
+
273
+ if trace_ids:
274
+ span_query = select(Span.trace_id, Span.name, Span.attributes).where(
275
+ Span.trace_id.in_(trace_ids)
276
+ )
277
+ span_rows = (await session.execute(span_query)).all()
278
+ for row in span_rows:
279
+ trace_id = str(row.trace_id)
280
+ attributes = row.attributes if isinstance(row.attributes, dict) else {}
281
+ if row.name == "manitos.turn.lifecycle":
282
+ if attributes.get("llm_degraded") is True:
283
+ degraded.add(trace_id)
284
+ if attributes.get("llm_truncated") is True:
285
+ truncated.add(trace_id)
286
+ if attributes.get("tool_error") is True:
287
+ tool_errors.add(trace_id)
288
+ for key, target in (("duration_ms", durations), ("ttft_ms", ttfts)):
289
+ value = attributes.get(key)
290
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
291
+ target.append(float(value))
292
+ if attributes.get("local_fallback") is True:
293
+ fallbacks.add(trace_id)
294
+ if row.name == "tts.synthesis" and attributes.get("status") == "error":
295
+ tts_errors.add(trace_id)
296
+ for key, target in (("model", models), ("language", languages)):
297
+ value = str(attributes.get(key) or "").strip()[:160]
298
+ if value:
299
+ target.setdefault(value, set()).add(trace_id)
300
+
301
+ def rate(count: int) -> float:
302
+ return count / total_turns if total_turns else 0.0
303
+
304
+ def breakdown(values: dict[str, set[str]]) -> list[dict]:
305
+ items = [
306
+ {"key": key, "count": len(ids), "rate": rate(len(ids))}
307
+ for key, ids in values.items()
308
+ ]
309
+ return sorted(items, key=lambda item: (-item["count"], item["key"]))
310
+
311
+ return {
312
+ "project_id": project_id,
313
+ "environment": environment,
314
+ "hours": hours,
315
+ "window_start": window_start.isoformat(),
316
+ "window_end": window_end.isoformat(),
317
+ "total_turns": total_turns,
318
+ "error_count": error_count,
319
+ "error_rate": rate(error_count),
320
+ "degraded_count": len(degraded),
321
+ "degraded_rate": rate(len(degraded)),
322
+ "truncated_count": len(truncated),
323
+ "truncated_rate": rate(len(truncated)),
324
+ "tool_error_count": len(tool_errors),
325
+ "tool_error_rate": rate(len(tool_errors)),
326
+ "fallback_count": len(fallbacks),
327
+ "fallback_rate": rate(len(fallbacks)),
328
+ "tts_error_count": len(tts_errors),
329
+ "tts_error_rate": rate(len(tts_errors)),
330
+ "avg_duration_ms": sum(durations) / len(durations) if durations else 0.0,
331
+ "avg_ttft_ms": sum(ttfts) / len(ttfts) if ttfts else 0.0,
332
+ "models": breakdown(models),
333
+ "languages": breakdown(languages),
334
+ }
335
+
336
+
337
+ # ── Sessions ─────────────────────────────────────────────────────────
338
+
339
+
340
+ class SessionInfo(BaseModel):
341
+ """Session summary."""
342
+
343
+ session_id: str
344
+ trace_count: int
345
+ total_cost_usd: float
346
+ created_at: str | None
347
+ last_activity: str | None
348
+
349
+
350
+ @router.get("/sessions", response_model=list[SessionInfo])
351
+ async def list_sessions(
352
+ limit: int = Query(100, ge=1, le=1000),
353
+ session: AsyncSession = Depends(get_session),
354
+ ) -> list[dict]:
355
+ """List unique sessions with aggregated stats."""
356
+ query = (
357
+ select(
358
+ Trace.session_id,
359
+ func.count(Trace.id).label("trace_count"),
360
+ func.coalesce(
361
+ func.sum(
362
+ select(func.coalesce(func.sum(Span.cost_usd), 0))
363
+ .where(Span.trace_id == Trace.id)
364
+ .correlate(Trace)
365
+ .scalar_subquery()
366
+ ),
367
+ 0,
368
+ ).label("total_cost_usd"),
369
+ func.min(Trace.created_at).label("created_at"),
370
+ func.max(Trace.created_at).label("last_activity"),
371
+ )
372
+ .where(Trace.session_id.isnot(None))
373
+ .group_by(Trace.session_id)
374
+ .order_by(func.max(Trace.created_at).desc())
375
+ .limit(limit)
376
+ )
377
+ result = await session.execute(query)
378
+ rows = result.all()
379
+
380
+ return [
381
+ {
382
+ "session_id": str(row.session_id),
383
+ "trace_count": row.trace_count,
384
+ "total_cost_usd": float(row.total_cost_usd),
385
+ "created_at": row.created_at.isoformat() if row.created_at else None,
386
+ "last_activity": row.last_activity.isoformat() if row.last_activity else None,
387
+ }
388
+ for row in rows
389
+ ]