tracellm-cli 0.1.0__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 +1 -0
- app/database/__init__.py +1 -0
- app/database/mongodb.py +94 -0
- app/database/project_service.py +97 -0
- app/database/trace_service.py +417 -0
- app/main.py +44 -0
- app/models/__init__.py +14 -0
- app/models/health.py +5 -0
- app/models/project.py +32 -0
- app/models/trace.py +71 -0
- app/models/trace_model.py +62 -0
- app/routes/__init__.py +1 -0
- app/routes/health.py +10 -0
- app/routes/observability.py +60 -0
- app/routes/projects.py +25 -0
- app/websocket/__init__.py +1 -0
- app/websocket/socket.py +64 -0
- sdk/__init__.py +3 -0
- sdk/tracer.py +8 -0
- tracellm/__init__.py +6 -0
- tracellm/banner.py +34 -0
- tracellm/cli.py +124 -0
- tracellm/db.py +75 -0
- tracellm/exporter.py +65 -0
- tracellm/integrations/__init__.py +4 -0
- tracellm/integrations/langchain.py +186 -0
- tracellm/integrations/openai.py +234 -0
- tracellm/integrations/tool_tracer.py +151 -0
- tracellm/mascot.py +49 -0
- tracellm/monitor.py +381 -0
- tracellm/palette.py +186 -0
- tracellm/replay.py +80 -0
- tracellm/startup.py +121 -0
- tracellm/summary.py +53 -0
- tracellm/trace_stream.py +68 -0
- tracellm/tracer.py +598 -0
- tracellm/tree_renderer.py +78 -0
- tracellm/utils.py +390 -0
- tracellm_cli-0.1.0.dist-info/METADATA +30 -0
- tracellm_cli-0.1.0.dist-info/RECORD +43 -0
- tracellm_cli-0.1.0.dist-info/WHEEL +5 -0
- tracellm_cli-0.1.0.dist-info/entry_points.txt +2 -0
- tracellm_cli-0.1.0.dist-info/top_level.txt +3 -0
app/models/project.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
from pydantic import Field
|
|
5
|
+
|
|
6
|
+
from app.models.trace import MongoFriendlyModel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _utcnow() -> datetime:
|
|
10
|
+
return datetime.now(timezone.utc)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
EnvironmentLiteral = Literal["development", "staging", "production"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ProjectSchema(MongoFriendlyModel):
|
|
17
|
+
project_id: str
|
|
18
|
+
name: str
|
|
19
|
+
description: str = ""
|
|
20
|
+
created_at: datetime = Field(default_factory=_utcnow)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ApiKeySchema(MongoFriendlyModel):
|
|
24
|
+
key: str
|
|
25
|
+
project_id: str
|
|
26
|
+
environment: str
|
|
27
|
+
created_at: datetime = Field(default_factory=_utcnow)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ProjectCreateResponse(MongoFriendlyModel):
|
|
31
|
+
project: ProjectSchema
|
|
32
|
+
api_key: ApiKeySchema
|
app/models/trace.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
from typing import Any, Literal, Optional
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _utcnow() -> datetime:
|
|
8
|
+
return datetime.now(timezone.utc)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
TraceStatus = Literal["success", "warning", "failed"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MongoFriendlyModel(BaseModel):
|
|
15
|
+
"""Base settings shared by MongoDB-backed models."""
|
|
16
|
+
|
|
17
|
+
model_config = ConfigDict(
|
|
18
|
+
populate_by_name=True,
|
|
19
|
+
json_encoders={datetime: lambda value: value.isoformat()},
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class StepSchema(MongoFriendlyModel):
|
|
24
|
+
step_id: str
|
|
25
|
+
tool_name: str
|
|
26
|
+
input: dict[str, Any] = Field(default_factory=dict)
|
|
27
|
+
output: dict[str, Any] = Field(default_factory=dict)
|
|
28
|
+
duration: float
|
|
29
|
+
success: bool
|
|
30
|
+
timestamp: datetime = Field(default_factory=_utcnow)
|
|
31
|
+
|
|
32
|
+
@field_validator("duration")
|
|
33
|
+
@classmethod
|
|
34
|
+
def validate_duration(cls, value: float) -> float:
|
|
35
|
+
if value < 0:
|
|
36
|
+
raise ValueError("duration must be greater than or equal to 0")
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TraceSchema(MongoFriendlyModel):
|
|
41
|
+
trace_id: str
|
|
42
|
+
prompt: str
|
|
43
|
+
response: Optional[str] = None
|
|
44
|
+
latency: float
|
|
45
|
+
token_count: int
|
|
46
|
+
model_name: Optional[str] = None
|
|
47
|
+
project_id: str = "default"
|
|
48
|
+
project_name: Optional[str] = None
|
|
49
|
+
api_key: Optional[str] = None
|
|
50
|
+
environment: str = "development"
|
|
51
|
+
status: TraceStatus
|
|
52
|
+
steps: list[StepSchema] = Field(default_factory=list)
|
|
53
|
+
retry_count: int = 0
|
|
54
|
+
slow_request: bool = False
|
|
55
|
+
failure_reason: Optional[str] = None
|
|
56
|
+
created_at: datetime = Field(default_factory=_utcnow)
|
|
57
|
+
updated_at: datetime = Field(default_factory=_utcnow)
|
|
58
|
+
|
|
59
|
+
@field_validator("latency")
|
|
60
|
+
@classmethod
|
|
61
|
+
def validate_latency(cls, value: float) -> float:
|
|
62
|
+
if value < 0:
|
|
63
|
+
raise ValueError("latency must be greater than or equal to 0")
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
@field_validator("token_count", "retry_count")
|
|
67
|
+
@classmethod
|
|
68
|
+
def validate_non_negative_ints(cls, value: int) -> int:
|
|
69
|
+
if value < 0:
|
|
70
|
+
raise ValueError("value must be greater than or equal to 0")
|
|
71
|
+
return value
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
from app.models.trace import TraceSchema
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TraceListResponse(BaseModel):
|
|
9
|
+
total: int
|
|
10
|
+
items: list[TraceSchema]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TraceFilters(BaseModel):
|
|
14
|
+
latency_min: Optional[float] = None
|
|
15
|
+
latency_max: Optional[float] = None
|
|
16
|
+
status: Optional[str] = None
|
|
17
|
+
model: Optional[str] = None
|
|
18
|
+
project_id: Optional[str] = None
|
|
19
|
+
environment: Optional[str] = None
|
|
20
|
+
token_min: Optional[int] = None
|
|
21
|
+
token_max: Optional[int] = None
|
|
22
|
+
limit: int = Field(default=50, ge=1, le=200)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AnalyticsSummary(BaseModel):
|
|
26
|
+
total_traces: int
|
|
27
|
+
success_rate: float
|
|
28
|
+
average_latency: float
|
|
29
|
+
p95_latency: float
|
|
30
|
+
total_token_usage: int
|
|
31
|
+
failed_traces: int
|
|
32
|
+
warning_traces: int
|
|
33
|
+
retries: int
|
|
34
|
+
slow_requests: int
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AnalyticsChartPoint(BaseModel):
|
|
38
|
+
label: str
|
|
39
|
+
latency: float
|
|
40
|
+
tokens: int
|
|
41
|
+
traces: int
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AnalyticsBreakdownItem(BaseModel):
|
|
45
|
+
key: str
|
|
46
|
+
count: int
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AnalyticsResponse(BaseModel):
|
|
50
|
+
summary: AnalyticsSummary
|
|
51
|
+
charts: list[AnalyticsChartPoint]
|
|
52
|
+
status_breakdown: list[AnalyticsBreakdownItem]
|
|
53
|
+
model_breakdown: list[AnalyticsBreakdownItem]
|
|
54
|
+
project_breakdown: list[AnalyticsBreakdownItem]
|
|
55
|
+
recent_failures: list[TraceSchema]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class FailureResponse(BaseModel):
|
|
59
|
+
failed_traces: list[TraceSchema]
|
|
60
|
+
retries: list[TraceSchema]
|
|
61
|
+
slow_requests: list[TraceSchema]
|
|
62
|
+
totals: dict[str, int]
|
app/routes/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""API route modules for TraceLLM."""
|
app/routes/health.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from fastapi import APIRouter
|
|
2
|
+
|
|
3
|
+
from app.models.health import HealthResponse
|
|
4
|
+
|
|
5
|
+
router = APIRouter()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@router.get("/", response_model=HealthResponse)
|
|
9
|
+
async def health_check() -> HealthResponse:
|
|
10
|
+
return HealthResponse(message="TraceLLM backend running")
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from fastapi import APIRouter, Query
|
|
2
|
+
|
|
3
|
+
from app.database.trace_service import get_analytics, get_failures, get_trace_by_id, list_traces
|
|
4
|
+
from app.models.trace import TraceSchema
|
|
5
|
+
from app.models.trace_model import AnalyticsResponse, FailureResponse, TraceFilters, TraceListResponse
|
|
6
|
+
|
|
7
|
+
router = APIRouter()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@router.get("/traces", response_model=TraceListResponse)
|
|
11
|
+
async def get_traces(
|
|
12
|
+
latency_min: float | None = Query(default=None, ge=0),
|
|
13
|
+
latency_max: float | None = Query(default=None, ge=0),
|
|
14
|
+
status: str | None = Query(default=None),
|
|
15
|
+
model: str | None = Query(default=None),
|
|
16
|
+
project_id: str | None = Query(default=None),
|
|
17
|
+
environment: str | None = Query(default=None),
|
|
18
|
+
token_min: int | None = Query(default=None, ge=0),
|
|
19
|
+
token_max: int | None = Query(default=None, ge=0),
|
|
20
|
+
limit: int = Query(default=50, ge=1, le=200),
|
|
21
|
+
) -> TraceListResponse:
|
|
22
|
+
filters = TraceFilters(
|
|
23
|
+
latency_min=latency_min,
|
|
24
|
+
latency_max=latency_max,
|
|
25
|
+
status=status,
|
|
26
|
+
model=model,
|
|
27
|
+
project_id=project_id,
|
|
28
|
+
environment=environment,
|
|
29
|
+
token_min=token_min,
|
|
30
|
+
token_max=token_max,
|
|
31
|
+
limit=limit,
|
|
32
|
+
)
|
|
33
|
+
return await list_traces(filters)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@router.get("/traces/{trace_id}", response_model=TraceSchema)
|
|
37
|
+
async def get_trace(trace_id: str) -> TraceSchema:
|
|
38
|
+
return await get_trace_by_id(trace_id)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@router.get("/analytics", response_model=AnalyticsResponse)
|
|
42
|
+
async def analytics(
|
|
43
|
+
project_id: str | None = Query(default=None),
|
|
44
|
+
environment: str | None = Query(default=None),
|
|
45
|
+
) -> AnalyticsResponse:
|
|
46
|
+
return await get_analytics(
|
|
47
|
+
TraceFilters(project_id=project_id, environment=environment)
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@router.get("/failures", response_model=FailureResponse)
|
|
52
|
+
async def failures(
|
|
53
|
+
limit: int = Query(default=25, ge=1, le=100),
|
|
54
|
+
project_id: str | None = Query(default=None),
|
|
55
|
+
environment: str | None = Query(default=None),
|
|
56
|
+
) -> FailureResponse:
|
|
57
|
+
return await get_failures(
|
|
58
|
+
limit=limit,
|
|
59
|
+
filters=TraceFilters(limit=limit, project_id=project_id, environment=environment),
|
|
60
|
+
)
|
app/routes/projects.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from fastapi import APIRouter, Query
|
|
2
|
+
|
|
3
|
+
from app.database.project_service import create_project, list_api_keys, list_projects
|
|
4
|
+
from app.models.project import ApiKeySchema, ProjectCreateResponse, ProjectSchema
|
|
5
|
+
|
|
6
|
+
router = APIRouter()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@router.get("/projects", response_model=list[ProjectSchema])
|
|
10
|
+
async def get_projects() -> list[ProjectSchema]:
|
|
11
|
+
return await list_projects()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@router.get("/api-keys", response_model=list[ApiKeySchema])
|
|
15
|
+
async def get_api_keys(project_id: str | None = Query(default=None)) -> list[ApiKeySchema]:
|
|
16
|
+
return await list_api_keys(project_id=project_id)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@router.post("/projects", response_model=ProjectCreateResponse)
|
|
20
|
+
async def create_project_route(
|
|
21
|
+
name: str,
|
|
22
|
+
environment: str,
|
|
23
|
+
description: str = "",
|
|
24
|
+
) -> ProjectCreateResponse:
|
|
25
|
+
return await create_project(name=name, description=description, environment=environment)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""WebSocket modules for TraceLLM."""
|
app/websocket/socket.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
|
6
|
+
|
|
7
|
+
router = APIRouter()
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConnectionManager:
|
|
12
|
+
"""Small websocket manager for broadcasting observability events."""
|
|
13
|
+
|
|
14
|
+
def __init__(self) -> None:
|
|
15
|
+
self._connections: list[WebSocket] = []
|
|
16
|
+
self._lock = asyncio.Lock()
|
|
17
|
+
|
|
18
|
+
async def connect(self, websocket: WebSocket) -> None:
|
|
19
|
+
await websocket.accept()
|
|
20
|
+
async with self._lock:
|
|
21
|
+
self._connections.append(websocket)
|
|
22
|
+
|
|
23
|
+
async def disconnect(self, websocket: WebSocket) -> None:
|
|
24
|
+
async with self._lock:
|
|
25
|
+
if websocket in self._connections:
|
|
26
|
+
self._connections.remove(websocket)
|
|
27
|
+
|
|
28
|
+
async def broadcast(self, payload: dict[str, Any]) -> None:
|
|
29
|
+
async with self._lock:
|
|
30
|
+
connections = list(self._connections)
|
|
31
|
+
|
|
32
|
+
stale_connections: list[WebSocket] = []
|
|
33
|
+
for connection in connections:
|
|
34
|
+
try:
|
|
35
|
+
await connection.send_json(payload)
|
|
36
|
+
except Exception:
|
|
37
|
+
logger.exception("Failed to send websocket payload")
|
|
38
|
+
stale_connections.append(connection)
|
|
39
|
+
|
|
40
|
+
for stale_connection in stale_connections:
|
|
41
|
+
await self.disconnect(stale_connection)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
manager = ConnectionManager()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@router.websocket("/ws")
|
|
48
|
+
async def websocket_endpoint(websocket: WebSocket) -> None:
|
|
49
|
+
await manager.connect(websocket)
|
|
50
|
+
await websocket.send_json(
|
|
51
|
+
{
|
|
52
|
+
"type": "system.connected",
|
|
53
|
+
"status": "connected",
|
|
54
|
+
"message": "TraceLLM websocket active",
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
while True:
|
|
60
|
+
message = await websocket.receive_json()
|
|
61
|
+
if message.get("type") == "ping":
|
|
62
|
+
await websocket.send_json({"type": "system.pong", "timestamp": message.get("ts")})
|
|
63
|
+
except WebSocketDisconnect:
|
|
64
|
+
await manager.disconnect(websocket)
|
sdk/__init__.py
ADDED
sdk/tracer.py
ADDED
tracellm/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
from tracellm.tracer import trace
|
|
2
|
+
from tracellm.integrations.openai import wrap_openai, TraceOpenAI
|
|
3
|
+
from tracellm.integrations.langchain import TracellmCallbackHandler
|
|
4
|
+
from tracellm.integrations.tool_tracer import trace_tool
|
|
5
|
+
|
|
6
|
+
__all__ = ["trace", "wrap_openai", "TraceOpenAI", "TracellmCallbackHandler", "trace_tool"]
|
tracellm/banner.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Premium startup banner for TraceLLM CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from rich.box import DOUBLE
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
from rich.align import Align
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def render_banner(
|
|
15
|
+
version: str = "0.2.0",
|
|
16
|
+
environment: str = "development",
|
|
17
|
+
) -> Panel:
|
|
18
|
+
"""Render a centered premium startup banner panel."""
|
|
19
|
+
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
20
|
+
|
|
21
|
+
inner = Table.grid(padding=(0, 0))
|
|
22
|
+
inner.add_column(justify="center")
|
|
23
|
+
inner.add_row(Text(f"TraceLLM v{version}", style="bold white"))
|
|
24
|
+
inner.add_row(Text("Open-source LLM Observability", style="bright_black"))
|
|
25
|
+
inner.add_row(Text(""))
|
|
26
|
+
inner.add_row(Text(f"Started at {now}", style="dim"))
|
|
27
|
+
inner.add_row(Text(f"Environment: {environment}", style="dim"))
|
|
28
|
+
|
|
29
|
+
return Panel(
|
|
30
|
+
Align.center(inner),
|
|
31
|
+
box=DOUBLE,
|
|
32
|
+
border_style="bright_black",
|
|
33
|
+
padding=(1, 4),
|
|
34
|
+
)
|
tracellm/cli.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from tracellm.db import create_project_with_key
|
|
6
|
+
from tracellm.exporter import export_traces
|
|
7
|
+
from tracellm.replay import replay_trace
|
|
8
|
+
from tracellm.startup import run_start
|
|
9
|
+
from tracellm.tracer import llm_response, run_live_trace
|
|
10
|
+
from tracellm.utils import console, render_project_credentials
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(help="TraceLLM SDK and developer CLI.")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.callback(invoke_without_command=True)
|
|
16
|
+
def cli_entry(ctx: typer.Context) -> None:
|
|
17
|
+
if ctx.invoked_subcommand is None:
|
|
18
|
+
from tracellm.palette import run_palette
|
|
19
|
+
run_palette(app)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.command()
|
|
23
|
+
def demo() -> None:
|
|
24
|
+
"""Generate a realistic demo trace."""
|
|
25
|
+
result = llm_response()
|
|
26
|
+
retries = result.get("retry_count", 0)
|
|
27
|
+
steps = len(result.get("steps", []))
|
|
28
|
+
console.print()
|
|
29
|
+
console.print("[bold white]Demo complete[/bold white]")
|
|
30
|
+
console.print(f"[bright_black]{steps} steps, {retries} retries[/bright_black]")
|
|
31
|
+
console.print()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.command()
|
|
35
|
+
def start(
|
|
36
|
+
port: int = typer.Option(8000, "--port", "-p", help="Port for the API server."),
|
|
37
|
+
dashboard: bool = typer.Option(False, "--dashboard", "-d", help="Open the dashboard in your browser."),
|
|
38
|
+
dashboard_port: int = typer.Option(3000, "--dashboard-port", help="Port for the frontend dashboard."),
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Start the TraceLLM observability stack (backend + dashboard)."""
|
|
41
|
+
run_start(port=port, dashboard_port=dashboard_port, launch_dashboard=dashboard)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.command("trace")
|
|
45
|
+
def trace_command(
|
|
46
|
+
prompt: str = typer.Argument(..., help="Prompt to trace."),
|
|
47
|
+
model: str = typer.Option("gpt-4.1-mini", "--model", help="Model name label for the trace."),
|
|
48
|
+
project: str = typer.Option("default", "--project", help="Project identifier or display name."),
|
|
49
|
+
environment: str = typer.Option("development", "--environment", help="Environment label."),
|
|
50
|
+
api_key: str | None = typer.Option(None, "--api-key", help="TraceLLM API key."),
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Run a production-style traced prompt simulation."""
|
|
53
|
+
run_live_trace(
|
|
54
|
+
prompt=prompt,
|
|
55
|
+
model_name=model,
|
|
56
|
+
project=project,
|
|
57
|
+
environment=environment,
|
|
58
|
+
api_key=api_key,
|
|
59
|
+
render=True,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@app.command()
|
|
64
|
+
def replay(
|
|
65
|
+
trace_id: str = typer.Argument(..., help="Trace ID to replay from MongoDB."),
|
|
66
|
+
speed: float = typer.Option(1.0, "--speed", min=0.1, help="Replay speed multiplier."),
|
|
67
|
+
show_response: bool = typer.Option(False, "--show-response", help="Show the full saved response after replay."),
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Replay a saved trace from MongoDB in the terminal."""
|
|
70
|
+
replay_trace(trace_id=trace_id, speed=speed, show_response=show_response)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@app.command()
|
|
74
|
+
def monitor(
|
|
75
|
+
refresh: float = typer.Option(2.0, "--refresh", "-r", help="Polling interval in seconds."),
|
|
76
|
+
limit: int = typer.Option(10, "--limit", "-l", help="Number of recent traces to show."),
|
|
77
|
+
ws_host: str = typer.Option(os.environ.get("TRACELLM_WS_HOST", "127.0.0.1"), "--ws-host", help="Backend WebSocket host. Falls back to TRACELLM_WS_HOST env."),
|
|
78
|
+
ws_port: int = typer.Option(int(os.environ.get("TRACELLM_WS_PORT", "8000")), "--ws-port", help="Backend WebSocket port. Falls back to TRACELLM_WS_PORT env."),
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Watch incoming real traces in realtime."""
|
|
81
|
+
from tracellm.monitor import run_monitor as _run_monitor
|
|
82
|
+
try:
|
|
83
|
+
_run_monitor(refresh=refresh, limit=limit, ws_host=ws_host, ws_port=ws_port)
|
|
84
|
+
except KeyboardInterrupt:
|
|
85
|
+
console.print("\n[yellow]Monitor stopped.[/yellow]")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@app.command()
|
|
89
|
+
def export(
|
|
90
|
+
format: str = typer.Option("json", "--format", help="Export format: json or csv."),
|
|
91
|
+
limit: int = typer.Option(100, "--limit", min=1, max=1000, help="How many recent traces to export."),
|
|
92
|
+
) -> None:
|
|
93
|
+
"""Export traces from MongoDB."""
|
|
94
|
+
normalized = format.lower()
|
|
95
|
+
if normalized not in {"json", "csv"}:
|
|
96
|
+
raise typer.BadParameter("format must be one of: json, csv")
|
|
97
|
+
export_traces(export_format=normalized, limit=limit)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@app.command("create-project")
|
|
101
|
+
def create_project_command() -> None:
|
|
102
|
+
"""Create a project and generate a secure API key."""
|
|
103
|
+
name = typer.prompt("Project name").strip()
|
|
104
|
+
description = typer.prompt("Description", default="", show_default=False).strip()
|
|
105
|
+
environment = typer.prompt("Environment", default="development").strip().lower()
|
|
106
|
+
if environment not in {"development", "staging", "production"}:
|
|
107
|
+
raise typer.BadParameter("environment must be development, staging, or production")
|
|
108
|
+
|
|
109
|
+
response = create_project_with_key(name=name, description=description, environment=environment)
|
|
110
|
+
render_project_credentials(
|
|
111
|
+
project_id=response.project.project_id,
|
|
112
|
+
name=response.project.name,
|
|
113
|
+
environment=response.api_key.environment,
|
|
114
|
+
api_key=response.api_key.key,
|
|
115
|
+
description=response.project.description,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def main() -> None:
|
|
120
|
+
app()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
main()
|
tracellm/db.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Database helpers with persistent async lifecycle management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any, TypeVar
|
|
8
|
+
|
|
9
|
+
from app.database.project_service import create_project, get_project_by_api_key, list_projects
|
|
10
|
+
from app.database.trace_service import get_trace_by_id, list_traces, save_trace, save_trace_sync
|
|
11
|
+
from app.models.project import ApiKeySchema, ProjectCreateResponse, ProjectSchema
|
|
12
|
+
from app.models.trace import TraceSchema
|
|
13
|
+
from app.models.trace_model import TraceFilters
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
T = TypeVar("T")
|
|
18
|
+
|
|
19
|
+
_ASYNC_LOOP: asyncio.AbstractEventLoop | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _run_async(coro: Any) -> T:
|
|
23
|
+
"""Run a coroutine synchronously using a persistent event loop.
|
|
24
|
+
|
|
25
|
+
Unlike ``asyncio.run()`` this does **not** close the loop after the
|
|
26
|
+
coroutine completes, which avoids “event loop is closed” errors when
|
|
27
|
+
Motor / MongoDB background tasks reference the loop.
|
|
28
|
+
"""
|
|
29
|
+
global _ASYNC_LOOP
|
|
30
|
+
|
|
31
|
+
# If an event loop is already running (e.g. inside FastAPI / uvicorn),
|
|
32
|
+
# schedule on that loop instead.
|
|
33
|
+
try:
|
|
34
|
+
running = asyncio.get_running_loop()
|
|
35
|
+
if running.is_running():
|
|
36
|
+
future = asyncio.run_coroutine_threadsafe(coro, running)
|
|
37
|
+
return future.result()
|
|
38
|
+
except RuntimeError:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
# Reuse or create a persistent loop for synchronous callers (CLI).
|
|
42
|
+
if _ASYNC_LOOP is None or _ASYNC_LOOP.is_closed():
|
|
43
|
+
_ASYNC_LOOP = asyncio.new_event_loop()
|
|
44
|
+
asyncio.set_event_loop(_ASYNC_LOOP)
|
|
45
|
+
|
|
46
|
+
return _ASYNC_LOOP.run_until_complete(coro)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def save_trace_payload(trace_data: dict[str, Any]) -> None:
|
|
50
|
+
save_trace_sync(trace_data)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def save_trace_payload_async(trace_data: dict[str, Any]) -> dict[str, Any]:
|
|
54
|
+
return await save_trace(trace_data)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def fetch_trace(trace_id: str) -> TraceSchema:
|
|
58
|
+
return _run_async(get_trace_by_id(trace_id))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def fetch_recent_traces(limit: int = 100) -> list[TraceSchema]:
|
|
62
|
+
response = _run_async(list_traces(TraceFilters(limit=limit)))
|
|
63
|
+
return response.items
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def create_project_with_key(name: str, description: str, environment: str) -> ProjectCreateResponse:
|
|
67
|
+
return _run_async(create_project(name=name, description=description, environment=environment))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def fetch_projects() -> list[ProjectSchema]:
|
|
71
|
+
return _run_async(list_projects())
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def resolve_api_key(api_key: str) -> ApiKeySchema:
|
|
75
|
+
return _run_async(get_project_by_api_key(api_key))
|
tracellm/exporter.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from tracellm.db import fetch_recent_traces
|
|
5
|
+
from tracellm.utils import console, ensure_export_dir, export_timestamp, render_export_success, write_csv
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def export_traces(export_format: str, limit: int = 100) -> Path:
|
|
9
|
+
console.print()
|
|
10
|
+
console.print("[bold white]Exporting traces...[/bold white]")
|
|
11
|
+
|
|
12
|
+
traces = fetch_recent_traces(limit=limit)
|
|
13
|
+
export_dir = ensure_export_dir()
|
|
14
|
+
timestamp = export_timestamp()
|
|
15
|
+
|
|
16
|
+
if export_format == "json":
|
|
17
|
+
path = export_dir / f"traces-{timestamp}.json"
|
|
18
|
+
payload = [trace.model_dump(mode="json") for trace in traces]
|
|
19
|
+
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
20
|
+
else:
|
|
21
|
+
path = export_dir / f"traces-{timestamp}.csv"
|
|
22
|
+
rows = [
|
|
23
|
+
{
|
|
24
|
+
"trace_id": trace.trace_id,
|
|
25
|
+
"project_id": trace.project_id,
|
|
26
|
+
"project_name": trace.project_name,
|
|
27
|
+
"environment": trace.environment,
|
|
28
|
+
"prompt": trace.prompt,
|
|
29
|
+
"model_name": trace.model_name,
|
|
30
|
+
"status": trace.status,
|
|
31
|
+
"latency": trace.latency,
|
|
32
|
+
"token_count": trace.token_count,
|
|
33
|
+
"retry_count": trace.retry_count,
|
|
34
|
+
"slow_request": trace.slow_request,
|
|
35
|
+
"failure_reason": trace.failure_reason,
|
|
36
|
+
"created_at": trace.created_at.isoformat(),
|
|
37
|
+
"updated_at": trace.updated_at.isoformat(),
|
|
38
|
+
"step_count": len(trace.steps),
|
|
39
|
+
}
|
|
40
|
+
for trace in traces
|
|
41
|
+
]
|
|
42
|
+
write_csv(
|
|
43
|
+
path,
|
|
44
|
+
rows,
|
|
45
|
+
[
|
|
46
|
+
"trace_id",
|
|
47
|
+
"project_id",
|
|
48
|
+
"project_name",
|
|
49
|
+
"environment",
|
|
50
|
+
"prompt",
|
|
51
|
+
"model_name",
|
|
52
|
+
"status",
|
|
53
|
+
"latency",
|
|
54
|
+
"token_count",
|
|
55
|
+
"retry_count",
|
|
56
|
+
"slow_request",
|
|
57
|
+
"failure_reason",
|
|
58
|
+
"created_at",
|
|
59
|
+
"updated_at",
|
|
60
|
+
"step_count",
|
|
61
|
+
],
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
render_export_success(path, len(traces))
|
|
65
|
+
return path
|