ccproxy-api 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.
- ccproxy/__init__.py +4 -0
- ccproxy/__main__.py +7 -0
- ccproxy/_version.py +21 -0
- ccproxy/adapters/__init__.py +11 -0
- ccproxy/adapters/base.py +80 -0
- ccproxy/adapters/openai/__init__.py +43 -0
- ccproxy/adapters/openai/adapter.py +915 -0
- ccproxy/adapters/openai/models.py +412 -0
- ccproxy/adapters/openai/streaming.py +449 -0
- ccproxy/api/__init__.py +28 -0
- ccproxy/api/app.py +225 -0
- ccproxy/api/dependencies.py +140 -0
- ccproxy/api/middleware/__init__.py +11 -0
- ccproxy/api/middleware/auth.py +0 -0
- ccproxy/api/middleware/cors.py +55 -0
- ccproxy/api/middleware/errors.py +703 -0
- ccproxy/api/middleware/headers.py +51 -0
- ccproxy/api/middleware/logging.py +175 -0
- ccproxy/api/middleware/request_id.py +69 -0
- ccproxy/api/middleware/server_header.py +62 -0
- ccproxy/api/responses.py +84 -0
- ccproxy/api/routes/__init__.py +16 -0
- ccproxy/api/routes/claude.py +181 -0
- ccproxy/api/routes/health.py +489 -0
- ccproxy/api/routes/metrics.py +1033 -0
- ccproxy/api/routes/proxy.py +238 -0
- ccproxy/auth/__init__.py +75 -0
- ccproxy/auth/bearer.py +68 -0
- ccproxy/auth/credentials_adapter.py +93 -0
- ccproxy/auth/dependencies.py +229 -0
- ccproxy/auth/exceptions.py +79 -0
- ccproxy/auth/manager.py +102 -0
- ccproxy/auth/models.py +118 -0
- ccproxy/auth/oauth/__init__.py +26 -0
- ccproxy/auth/oauth/models.py +49 -0
- ccproxy/auth/oauth/routes.py +396 -0
- ccproxy/auth/oauth/storage.py +0 -0
- ccproxy/auth/storage/__init__.py +12 -0
- ccproxy/auth/storage/base.py +57 -0
- ccproxy/auth/storage/json_file.py +159 -0
- ccproxy/auth/storage/keyring.py +192 -0
- ccproxy/claude_sdk/__init__.py +20 -0
- ccproxy/claude_sdk/client.py +169 -0
- ccproxy/claude_sdk/converter.py +331 -0
- ccproxy/claude_sdk/options.py +120 -0
- ccproxy/cli/__init__.py +14 -0
- ccproxy/cli/commands/__init__.py +8 -0
- ccproxy/cli/commands/auth.py +553 -0
- ccproxy/cli/commands/config/__init__.py +14 -0
- ccproxy/cli/commands/config/commands.py +766 -0
- ccproxy/cli/commands/config/schema_commands.py +119 -0
- ccproxy/cli/commands/serve.py +630 -0
- ccproxy/cli/docker/__init__.py +34 -0
- ccproxy/cli/docker/adapter_factory.py +157 -0
- ccproxy/cli/docker/params.py +278 -0
- ccproxy/cli/helpers.py +144 -0
- ccproxy/cli/main.py +193 -0
- ccproxy/cli/options/__init__.py +14 -0
- ccproxy/cli/options/claude_options.py +216 -0
- ccproxy/cli/options/core_options.py +40 -0
- ccproxy/cli/options/security_options.py +48 -0
- ccproxy/cli/options/server_options.py +117 -0
- ccproxy/config/__init__.py +40 -0
- ccproxy/config/auth.py +154 -0
- ccproxy/config/claude.py +124 -0
- ccproxy/config/cors.py +79 -0
- ccproxy/config/discovery.py +87 -0
- ccproxy/config/docker_settings.py +265 -0
- ccproxy/config/loader.py +108 -0
- ccproxy/config/observability.py +158 -0
- ccproxy/config/pricing.py +88 -0
- ccproxy/config/reverse_proxy.py +31 -0
- ccproxy/config/scheduler.py +89 -0
- ccproxy/config/security.py +14 -0
- ccproxy/config/server.py +81 -0
- ccproxy/config/settings.py +534 -0
- ccproxy/config/validators.py +231 -0
- ccproxy/core/__init__.py +274 -0
- ccproxy/core/async_utils.py +675 -0
- ccproxy/core/constants.py +97 -0
- ccproxy/core/errors.py +256 -0
- ccproxy/core/http.py +328 -0
- ccproxy/core/http_transformers.py +428 -0
- ccproxy/core/interfaces.py +247 -0
- ccproxy/core/logging.py +189 -0
- ccproxy/core/middleware.py +114 -0
- ccproxy/core/proxy.py +143 -0
- ccproxy/core/system.py +38 -0
- ccproxy/core/transformers.py +259 -0
- ccproxy/core/types.py +129 -0
- ccproxy/core/validators.py +288 -0
- ccproxy/docker/__init__.py +67 -0
- ccproxy/docker/adapter.py +588 -0
- ccproxy/docker/docker_path.py +207 -0
- ccproxy/docker/middleware.py +103 -0
- ccproxy/docker/models.py +228 -0
- ccproxy/docker/protocol.py +192 -0
- ccproxy/docker/stream_process.py +264 -0
- ccproxy/docker/validators.py +173 -0
- ccproxy/models/__init__.py +123 -0
- ccproxy/models/errors.py +42 -0
- ccproxy/models/messages.py +243 -0
- ccproxy/models/requests.py +85 -0
- ccproxy/models/responses.py +227 -0
- ccproxy/models/types.py +102 -0
- ccproxy/observability/__init__.py +51 -0
- ccproxy/observability/access_logger.py +400 -0
- ccproxy/observability/context.py +447 -0
- ccproxy/observability/metrics.py +539 -0
- ccproxy/observability/pushgateway.py +366 -0
- ccproxy/observability/sse_events.py +303 -0
- ccproxy/observability/stats_printer.py +755 -0
- ccproxy/observability/storage/__init__.py +1 -0
- ccproxy/observability/storage/duckdb_simple.py +665 -0
- ccproxy/observability/storage/models.py +55 -0
- ccproxy/pricing/__init__.py +19 -0
- ccproxy/pricing/cache.py +212 -0
- ccproxy/pricing/loader.py +267 -0
- ccproxy/pricing/models.py +106 -0
- ccproxy/pricing/updater.py +309 -0
- ccproxy/scheduler/__init__.py +39 -0
- ccproxy/scheduler/core.py +335 -0
- ccproxy/scheduler/exceptions.py +34 -0
- ccproxy/scheduler/manager.py +186 -0
- ccproxy/scheduler/registry.py +150 -0
- ccproxy/scheduler/tasks.py +484 -0
- ccproxy/services/__init__.py +10 -0
- ccproxy/services/claude_sdk_service.py +614 -0
- ccproxy/services/credentials/__init__.py +55 -0
- ccproxy/services/credentials/config.py +105 -0
- ccproxy/services/credentials/manager.py +562 -0
- ccproxy/services/credentials/oauth_client.py +482 -0
- ccproxy/services/proxy_service.py +1536 -0
- ccproxy/static/.keep +0 -0
- ccproxy/testing/__init__.py +34 -0
- ccproxy/testing/config.py +148 -0
- ccproxy/testing/content_generation.py +197 -0
- ccproxy/testing/mock_responses.py +262 -0
- ccproxy/testing/response_handlers.py +161 -0
- ccproxy/testing/scenarios.py +241 -0
- ccproxy/utils/__init__.py +6 -0
- ccproxy/utils/cost_calculator.py +210 -0
- ccproxy/utils/streaming_metrics.py +199 -0
- ccproxy_api-0.1.0.dist-info/METADATA +253 -0
- ccproxy_api-0.1.0.dist-info/RECORD +148 -0
- ccproxy_api-0.1.0.dist-info/WHEEL +4 -0
- ccproxy_api-0.1.0.dist-info/entry_points.txt +2 -0
- ccproxy_api-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1033 @@
|
|
|
1
|
+
"""Metrics endpoints for CCProxy API Server."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from datetime import datetime as dt
|
|
5
|
+
from typing import Any, Optional, cast
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
|
8
|
+
from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
|
|
9
|
+
from sqlmodel import Session, col, desc, func, select
|
|
10
|
+
from typing_extensions import TypedDict
|
|
11
|
+
|
|
12
|
+
from ccproxy.api.dependencies import (
|
|
13
|
+
DuckDBStorageDep,
|
|
14
|
+
LogStorageDep,
|
|
15
|
+
ObservabilityMetricsDep,
|
|
16
|
+
SettingsDep,
|
|
17
|
+
)
|
|
18
|
+
from ccproxy.observability.storage.models import AccessLog
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AnalyticsSummary(TypedDict):
|
|
22
|
+
"""TypedDict for analytics summary data."""
|
|
23
|
+
|
|
24
|
+
total_requests: int
|
|
25
|
+
total_successful_requests: int
|
|
26
|
+
total_error_requests: int
|
|
27
|
+
avg_duration_ms: float
|
|
28
|
+
total_cost_usd: float
|
|
29
|
+
total_tokens_input: int
|
|
30
|
+
total_tokens_output: int
|
|
31
|
+
total_cache_read_tokens: int
|
|
32
|
+
total_cache_write_tokens: int
|
|
33
|
+
total_tokens_all: int
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TokenAnalytics(TypedDict):
|
|
37
|
+
"""TypedDict for token analytics data."""
|
|
38
|
+
|
|
39
|
+
input_tokens: int
|
|
40
|
+
output_tokens: int
|
|
41
|
+
cache_read_tokens: int
|
|
42
|
+
cache_write_tokens: int
|
|
43
|
+
total_tokens: int
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RequestAnalytics(TypedDict):
|
|
47
|
+
"""TypedDict for request analytics data."""
|
|
48
|
+
|
|
49
|
+
total_requests: int
|
|
50
|
+
successful_requests: int
|
|
51
|
+
error_requests: int
|
|
52
|
+
success_rate: float
|
|
53
|
+
error_rate: float
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ServiceBreakdown(TypedDict):
|
|
57
|
+
"""TypedDict for service type breakdown data."""
|
|
58
|
+
|
|
59
|
+
request_count: int
|
|
60
|
+
successful_requests: int
|
|
61
|
+
error_requests: int
|
|
62
|
+
success_rate: float
|
|
63
|
+
error_rate: float
|
|
64
|
+
avg_duration_ms: float
|
|
65
|
+
total_cost_usd: float
|
|
66
|
+
total_tokens_input: int
|
|
67
|
+
total_tokens_output: int
|
|
68
|
+
total_cache_read_tokens: int
|
|
69
|
+
total_cache_write_tokens: int
|
|
70
|
+
total_tokens_all: int
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class AnalyticsResult(TypedDict):
|
|
74
|
+
"""TypedDict for complete analytics result."""
|
|
75
|
+
|
|
76
|
+
summary: AnalyticsSummary
|
|
77
|
+
token_analytics: TokenAnalytics
|
|
78
|
+
request_analytics: RequestAnalytics
|
|
79
|
+
service_type_breakdown: dict[str, ServiceBreakdown]
|
|
80
|
+
query_time: float
|
|
81
|
+
backend: str
|
|
82
|
+
query_params: dict[str, Any]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# Create separate routers for different concerns
|
|
86
|
+
prometheus_router = APIRouter(tags=["metrics"])
|
|
87
|
+
logs_router = APIRouter(prefix="/logs", tags=["logs"])
|
|
88
|
+
dashboard_router = APIRouter(tags=["dashboard"])
|
|
89
|
+
|
|
90
|
+
# Backward compatibility - keep the old router name pointing to logs for now
|
|
91
|
+
router = logs_router
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@logs_router.get("/status")
|
|
95
|
+
async def logs_status(metrics: ObservabilityMetricsDep) -> dict[str, str]:
|
|
96
|
+
"""Get observability system status."""
|
|
97
|
+
return {
|
|
98
|
+
"status": "healthy",
|
|
99
|
+
"prometheus_enabled": str(metrics.is_enabled()),
|
|
100
|
+
"observability_system": "hybrid_prometheus_structlog",
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dashboard_router.get("/dashboard")
|
|
105
|
+
async def get_metrics_dashboard() -> HTMLResponse:
|
|
106
|
+
"""Serve the metrics dashboard SPA entry point."""
|
|
107
|
+
from pathlib import Path
|
|
108
|
+
|
|
109
|
+
# Get the path to the dashboard folder
|
|
110
|
+
current_file = Path(__file__)
|
|
111
|
+
project_root = (
|
|
112
|
+
current_file.parent.parent.parent.parent
|
|
113
|
+
) # ccproxy/api/routes/metrics.py -> project root
|
|
114
|
+
dashboard_folder = project_root / "ccproxy" / "static" / "dashboard"
|
|
115
|
+
dashboard_index = dashboard_folder / "index.html"
|
|
116
|
+
|
|
117
|
+
# Check if dashboard folder and index.html exist
|
|
118
|
+
if not dashboard_folder.exists():
|
|
119
|
+
raise HTTPException(
|
|
120
|
+
status_code=404,
|
|
121
|
+
detail="Dashboard not found. Please build the dashboard first using 'cd dashboard && bun run build:prod'",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if not dashboard_index.exists():
|
|
125
|
+
raise HTTPException(
|
|
126
|
+
status_code=404,
|
|
127
|
+
detail="Dashboard index.html not found. Please rebuild the dashboard using 'cd dashboard && bun run build:prod'",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Read the HTML content
|
|
131
|
+
try:
|
|
132
|
+
with dashboard_index.open(encoding="utf-8") as f:
|
|
133
|
+
html_content = f.read()
|
|
134
|
+
|
|
135
|
+
return HTMLResponse(
|
|
136
|
+
content=html_content,
|
|
137
|
+
status_code=200,
|
|
138
|
+
headers={
|
|
139
|
+
"Cache-Control": "no-cache, no-store, must-revalidate",
|
|
140
|
+
"Pragma": "no-cache",
|
|
141
|
+
"Expires": "0",
|
|
142
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
143
|
+
},
|
|
144
|
+
)
|
|
145
|
+
except Exception as e:
|
|
146
|
+
raise HTTPException(
|
|
147
|
+
status_code=500, detail=f"Failed to serve dashboard: {str(e)}"
|
|
148
|
+
) from e
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dashboard_router.get("/dashboard/favicon.svg")
|
|
152
|
+
async def get_dashboard_favicon() -> FileResponse:
|
|
153
|
+
"""Serve the dashboard favicon."""
|
|
154
|
+
from pathlib import Path
|
|
155
|
+
|
|
156
|
+
# Get the path to the favicon
|
|
157
|
+
current_file = Path(__file__)
|
|
158
|
+
project_root = (
|
|
159
|
+
current_file.parent.parent.parent.parent
|
|
160
|
+
) # ccproxy/api/routes/metrics.py -> project root
|
|
161
|
+
favicon_path = project_root / "ccproxy" / "static" / "dashboard" / "favicon.svg"
|
|
162
|
+
|
|
163
|
+
if not favicon_path.exists():
|
|
164
|
+
raise HTTPException(status_code=404, detail="Favicon not found")
|
|
165
|
+
|
|
166
|
+
return FileResponse(
|
|
167
|
+
path=str(favicon_path),
|
|
168
|
+
media_type="image/svg+xml",
|
|
169
|
+
headers={"Cache-Control": "public, max-age=3600"},
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@prometheus_router.get("/metrics")
|
|
174
|
+
async def get_prometheus_metrics(metrics: ObservabilityMetricsDep) -> Response:
|
|
175
|
+
"""Export metrics in Prometheus format using native prometheus_client.
|
|
176
|
+
|
|
177
|
+
This endpoint exposes operational metrics collected by the hybrid observability
|
|
178
|
+
system for Prometheus scraping.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
metrics: Observability metrics dependency
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Prometheus-formatted metrics text
|
|
185
|
+
"""
|
|
186
|
+
try:
|
|
187
|
+
# Check if prometheus_client is available
|
|
188
|
+
try:
|
|
189
|
+
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
|
|
190
|
+
except ImportError as err:
|
|
191
|
+
raise HTTPException(
|
|
192
|
+
status_code=503,
|
|
193
|
+
detail="Prometheus client not available. Install with: pip install prometheus-client",
|
|
194
|
+
) from err
|
|
195
|
+
|
|
196
|
+
if not metrics.is_enabled():
|
|
197
|
+
raise HTTPException(
|
|
198
|
+
status_code=503,
|
|
199
|
+
detail="Prometheus metrics not enabled. Ensure prometheus-client is installed.",
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# Generate prometheus format using the registry
|
|
203
|
+
from prometheus_client import REGISTRY, CollectorRegistry
|
|
204
|
+
|
|
205
|
+
# Use the global registry if metrics.registry is None (default behavior)
|
|
206
|
+
registry = metrics.registry if metrics.registry is not None else REGISTRY
|
|
207
|
+
prometheus_data = generate_latest(registry)
|
|
208
|
+
|
|
209
|
+
# Return the metrics data with proper content type
|
|
210
|
+
from fastapi import Response
|
|
211
|
+
|
|
212
|
+
return Response(
|
|
213
|
+
content=prometheus_data,
|
|
214
|
+
media_type=CONTENT_TYPE_LATEST,
|
|
215
|
+
headers={
|
|
216
|
+
"Cache-Control": "no-cache, no-store, must-revalidate",
|
|
217
|
+
"Pragma": "no-cache",
|
|
218
|
+
"Expires": "0",
|
|
219
|
+
},
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
except HTTPException:
|
|
223
|
+
raise
|
|
224
|
+
except Exception as e:
|
|
225
|
+
raise HTTPException(
|
|
226
|
+
status_code=500, detail=f"Failed to generate Prometheus metrics: {str(e)}"
|
|
227
|
+
) from e
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@logs_router.get("/query")
|
|
231
|
+
async def query_logs(
|
|
232
|
+
storage: DuckDBStorageDep,
|
|
233
|
+
settings: SettingsDep,
|
|
234
|
+
limit: int = Query(1000, ge=1, le=10000, description="Maximum number of results"),
|
|
235
|
+
start_time: float | None = Query(None, description="Start timestamp filter"),
|
|
236
|
+
end_time: float | None = Query(None, description="End timestamp filter"),
|
|
237
|
+
model: str | None = Query(None, description="Model filter"),
|
|
238
|
+
service_type: str | None = Query(None, description="Service type filter"),
|
|
239
|
+
) -> dict[str, Any]:
|
|
240
|
+
"""
|
|
241
|
+
Query access logs with filters.
|
|
242
|
+
|
|
243
|
+
Returns access log entries with optional filtering by time range, model, and service type.
|
|
244
|
+
"""
|
|
245
|
+
try:
|
|
246
|
+
if not settings.observability.logs_collection_enabled:
|
|
247
|
+
raise HTTPException(
|
|
248
|
+
status_code=503,
|
|
249
|
+
detail="Logs collection is disabled. Enable with logs_collection_enabled=true",
|
|
250
|
+
)
|
|
251
|
+
if not storage:
|
|
252
|
+
raise HTTPException(
|
|
253
|
+
status_code=503,
|
|
254
|
+
detail="Storage backend not available. Ensure DuckDB is installed and pipeline is running.",
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# Use SQLModel for querying
|
|
258
|
+
if hasattr(storage, "_engine") and storage._engine:
|
|
259
|
+
try:
|
|
260
|
+
with Session(storage._engine) as session:
|
|
261
|
+
# Build base query
|
|
262
|
+
statement = select(AccessLog)
|
|
263
|
+
|
|
264
|
+
# Add filters - convert Unix timestamps to datetime
|
|
265
|
+
start_dt = dt.fromtimestamp(start_time) if start_time else None
|
|
266
|
+
end_dt = dt.fromtimestamp(end_time) if end_time else None
|
|
267
|
+
|
|
268
|
+
if start_dt:
|
|
269
|
+
statement = statement.where(AccessLog.timestamp >= start_dt)
|
|
270
|
+
if end_dt:
|
|
271
|
+
statement = statement.where(AccessLog.timestamp <= end_dt)
|
|
272
|
+
if model:
|
|
273
|
+
statement = statement.where(AccessLog.model == model)
|
|
274
|
+
if service_type:
|
|
275
|
+
statement = statement.where(
|
|
276
|
+
AccessLog.service_type == service_type
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
# Apply limit and order
|
|
280
|
+
statement = statement.order_by(desc(AccessLog.timestamp)).limit(
|
|
281
|
+
limit
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
# Execute query
|
|
285
|
+
results = session.exec(statement).all()
|
|
286
|
+
|
|
287
|
+
# Convert to dict format
|
|
288
|
+
entries = [log.dict() for log in results]
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
"results": entries,
|
|
292
|
+
"count": len(entries),
|
|
293
|
+
"limit": limit,
|
|
294
|
+
"filters": {
|
|
295
|
+
"start_time": start_time,
|
|
296
|
+
"end_time": end_time,
|
|
297
|
+
"model": model,
|
|
298
|
+
"service_type": service_type,
|
|
299
|
+
},
|
|
300
|
+
"timestamp": time.time(),
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
except Exception as e:
|
|
304
|
+
import structlog
|
|
305
|
+
|
|
306
|
+
logger = structlog.get_logger(__name__)
|
|
307
|
+
logger.error("sqlmodel_query_error", error=str(e))
|
|
308
|
+
raise HTTPException(
|
|
309
|
+
status_code=500, detail=f"Query execution failed: {str(e)}"
|
|
310
|
+
) from e
|
|
311
|
+
else:
|
|
312
|
+
raise HTTPException(
|
|
313
|
+
status_code=503,
|
|
314
|
+
detail="Storage engine not available",
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
except HTTPException:
|
|
318
|
+
raise
|
|
319
|
+
except Exception as e:
|
|
320
|
+
raise HTTPException(
|
|
321
|
+
status_code=500, detail=f"Query execution failed: {str(e)}"
|
|
322
|
+
) from e
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@logs_router.get("/analytics")
|
|
326
|
+
async def get_logs_analytics(
|
|
327
|
+
storage: DuckDBStorageDep,
|
|
328
|
+
settings: SettingsDep,
|
|
329
|
+
start_time: float | None = Query(None, description="Start timestamp (Unix time)"),
|
|
330
|
+
end_time: float | None = Query(None, description="End timestamp (Unix time)"),
|
|
331
|
+
model: str | None = Query(None, description="Filter by model name"),
|
|
332
|
+
service_type: str | None = Query(
|
|
333
|
+
None,
|
|
334
|
+
description="Filter by service type. Supports comma-separated values (e.g., 'proxy_service,sdk_service') and negation with ! prefix (e.g., '!access_log,!sdk_service')",
|
|
335
|
+
),
|
|
336
|
+
hours: int | None = Query(
|
|
337
|
+
24, ge=1, le=168, description="Hours of data to analyze (default: 24)"
|
|
338
|
+
),
|
|
339
|
+
) -> AnalyticsResult:
|
|
340
|
+
"""
|
|
341
|
+
Get comprehensive analytics for metrics data.
|
|
342
|
+
|
|
343
|
+
Returns summary statistics, hourly trends, and model breakdowns.
|
|
344
|
+
"""
|
|
345
|
+
try:
|
|
346
|
+
if not settings.observability.logs_collection_enabled:
|
|
347
|
+
raise HTTPException(
|
|
348
|
+
status_code=503,
|
|
349
|
+
detail="Logs collection is disabled. Enable with logs_collection_enabled=true",
|
|
350
|
+
)
|
|
351
|
+
if not storage:
|
|
352
|
+
raise HTTPException(
|
|
353
|
+
status_code=503,
|
|
354
|
+
detail="Storage backend not available. Ensure DuckDB is installed and pipeline is running.",
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
# Default time range if not provided
|
|
358
|
+
if start_time is None and end_time is None and hours:
|
|
359
|
+
end_time = time.time()
|
|
360
|
+
start_time = end_time - (hours * 3600)
|
|
361
|
+
|
|
362
|
+
# Use SQLModel for analytics
|
|
363
|
+
if hasattr(storage, "_engine") and storage._engine:
|
|
364
|
+
try:
|
|
365
|
+
with Session(storage._engine) as session:
|
|
366
|
+
# Build base query
|
|
367
|
+
statement = select(AccessLog)
|
|
368
|
+
|
|
369
|
+
# Add filters - convert Unix timestamps to datetime
|
|
370
|
+
start_dt = dt.fromtimestamp(start_time) if start_time else None
|
|
371
|
+
end_dt = dt.fromtimestamp(end_time) if end_time else None
|
|
372
|
+
|
|
373
|
+
# Helper function to build filter conditions
|
|
374
|
+
def build_filter_conditions() -> list[Any]:
|
|
375
|
+
conditions: list[Any] = []
|
|
376
|
+
if start_dt:
|
|
377
|
+
conditions.append(AccessLog.timestamp >= start_dt)
|
|
378
|
+
if end_dt:
|
|
379
|
+
conditions.append(AccessLog.timestamp <= end_dt)
|
|
380
|
+
if model:
|
|
381
|
+
conditions.append(AccessLog.model == model)
|
|
382
|
+
|
|
383
|
+
# Apply service type filtering with comma-separated values and negation
|
|
384
|
+
if service_type:
|
|
385
|
+
service_filters = [
|
|
386
|
+
s.strip() for s in service_type.split(",")
|
|
387
|
+
]
|
|
388
|
+
include_filters = [
|
|
389
|
+
f for f in service_filters if not f.startswith("!")
|
|
390
|
+
]
|
|
391
|
+
exclude_filters = [
|
|
392
|
+
f[1:] for f in service_filters if f.startswith("!")
|
|
393
|
+
]
|
|
394
|
+
|
|
395
|
+
if include_filters:
|
|
396
|
+
conditions.append(
|
|
397
|
+
col(AccessLog.service_type).in_(include_filters)
|
|
398
|
+
)
|
|
399
|
+
if exclude_filters:
|
|
400
|
+
conditions.append(
|
|
401
|
+
~col(AccessLog.service_type).in_(exclude_filters)
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
return conditions
|
|
405
|
+
|
|
406
|
+
# Get summary statistics using individual queries to avoid overload issues
|
|
407
|
+
# Reuse datetime variables defined above
|
|
408
|
+
|
|
409
|
+
filter_conditions = build_filter_conditions()
|
|
410
|
+
|
|
411
|
+
total_requests = session.exec(
|
|
412
|
+
select(func.count())
|
|
413
|
+
.select_from(AccessLog)
|
|
414
|
+
.where(*filter_conditions)
|
|
415
|
+
).first()
|
|
416
|
+
|
|
417
|
+
avg_duration = session.exec(
|
|
418
|
+
select(func.avg(AccessLog.duration_ms))
|
|
419
|
+
.select_from(AccessLog)
|
|
420
|
+
.where(*filter_conditions)
|
|
421
|
+
).first()
|
|
422
|
+
|
|
423
|
+
total_cost = session.exec(
|
|
424
|
+
select(func.sum(AccessLog.cost_usd))
|
|
425
|
+
.select_from(AccessLog)
|
|
426
|
+
.where(*filter_conditions)
|
|
427
|
+
).first()
|
|
428
|
+
|
|
429
|
+
total_tokens_input = session.exec(
|
|
430
|
+
select(func.sum(AccessLog.tokens_input))
|
|
431
|
+
.select_from(AccessLog)
|
|
432
|
+
.where(*filter_conditions)
|
|
433
|
+
).first()
|
|
434
|
+
|
|
435
|
+
total_tokens_output = session.exec(
|
|
436
|
+
select(func.sum(AccessLog.tokens_output))
|
|
437
|
+
.select_from(AccessLog)
|
|
438
|
+
.where(*filter_conditions)
|
|
439
|
+
).first()
|
|
440
|
+
|
|
441
|
+
# Token analytics - all token types
|
|
442
|
+
total_cache_read_tokens = session.exec(
|
|
443
|
+
select(func.sum(AccessLog.cache_read_tokens))
|
|
444
|
+
.select_from(AccessLog)
|
|
445
|
+
.where(*filter_conditions)
|
|
446
|
+
).first()
|
|
447
|
+
|
|
448
|
+
total_cache_write_tokens = session.exec(
|
|
449
|
+
select(func.sum(AccessLog.cache_write_tokens))
|
|
450
|
+
.select_from(AccessLog)
|
|
451
|
+
.where(*filter_conditions)
|
|
452
|
+
).first()
|
|
453
|
+
|
|
454
|
+
# Success and error request analytics
|
|
455
|
+
success_conditions = filter_conditions + [
|
|
456
|
+
AccessLog.status_code >= 200,
|
|
457
|
+
AccessLog.status_code < 400,
|
|
458
|
+
]
|
|
459
|
+
total_successful_requests = session.exec(
|
|
460
|
+
select(func.count())
|
|
461
|
+
.select_from(AccessLog)
|
|
462
|
+
.where(*success_conditions)
|
|
463
|
+
).first()
|
|
464
|
+
|
|
465
|
+
error_conditions = filter_conditions + [
|
|
466
|
+
AccessLog.status_code >= 400,
|
|
467
|
+
]
|
|
468
|
+
total_error_requests = session.exec(
|
|
469
|
+
select(func.count())
|
|
470
|
+
.select_from(AccessLog)
|
|
471
|
+
.where(*error_conditions)
|
|
472
|
+
).first()
|
|
473
|
+
|
|
474
|
+
# Summary results are already computed individually above
|
|
475
|
+
|
|
476
|
+
# Get service type breakdown - simplified approach
|
|
477
|
+
service_breakdown = {}
|
|
478
|
+
# Get unique service types first
|
|
479
|
+
unique_services = session.exec(
|
|
480
|
+
select(AccessLog.service_type)
|
|
481
|
+
.distinct()
|
|
482
|
+
.where(*filter_conditions)
|
|
483
|
+
).all()
|
|
484
|
+
|
|
485
|
+
# For each service type, get its statistics
|
|
486
|
+
for service in unique_services:
|
|
487
|
+
if service: # Skip None values
|
|
488
|
+
# Build service-specific filter conditions
|
|
489
|
+
service_conditions = []
|
|
490
|
+
if start_dt:
|
|
491
|
+
service_conditions.append(
|
|
492
|
+
AccessLog.timestamp >= start_dt
|
|
493
|
+
)
|
|
494
|
+
if end_dt:
|
|
495
|
+
service_conditions.append(AccessLog.timestamp <= end_dt)
|
|
496
|
+
if model:
|
|
497
|
+
service_conditions.append(AccessLog.model == model)
|
|
498
|
+
service_conditions.append(AccessLog.service_type == service)
|
|
499
|
+
|
|
500
|
+
service_count = session.exec(
|
|
501
|
+
select(func.count())
|
|
502
|
+
.select_from(AccessLog)
|
|
503
|
+
.where(*service_conditions)
|
|
504
|
+
).first()
|
|
505
|
+
|
|
506
|
+
service_avg_duration = session.exec(
|
|
507
|
+
select(func.avg(AccessLog.duration_ms))
|
|
508
|
+
.select_from(AccessLog)
|
|
509
|
+
.where(*service_conditions)
|
|
510
|
+
).first()
|
|
511
|
+
|
|
512
|
+
service_total_cost = session.exec(
|
|
513
|
+
select(func.sum(AccessLog.cost_usd))
|
|
514
|
+
.select_from(AccessLog)
|
|
515
|
+
.where(*service_conditions)
|
|
516
|
+
).first()
|
|
517
|
+
|
|
518
|
+
service_total_tokens_input = session.exec(
|
|
519
|
+
select(func.sum(AccessLog.tokens_input))
|
|
520
|
+
.select_from(AccessLog)
|
|
521
|
+
.where(*service_conditions)
|
|
522
|
+
).first()
|
|
523
|
+
|
|
524
|
+
service_total_tokens_output = session.exec(
|
|
525
|
+
select(func.sum(AccessLog.tokens_output))
|
|
526
|
+
.select_from(AccessLog)
|
|
527
|
+
.where(*service_conditions)
|
|
528
|
+
).first()
|
|
529
|
+
|
|
530
|
+
service_cache_read_tokens = session.exec(
|
|
531
|
+
select(func.sum(AccessLog.cache_read_tokens))
|
|
532
|
+
.select_from(AccessLog)
|
|
533
|
+
.where(*service_conditions)
|
|
534
|
+
).first()
|
|
535
|
+
|
|
536
|
+
service_cache_write_tokens = session.exec(
|
|
537
|
+
select(func.sum(AccessLog.cache_write_tokens))
|
|
538
|
+
.select_from(AccessLog)
|
|
539
|
+
.where(*service_conditions)
|
|
540
|
+
).first()
|
|
541
|
+
|
|
542
|
+
service_success_conditions = service_conditions + [
|
|
543
|
+
AccessLog.status_code >= 200,
|
|
544
|
+
AccessLog.status_code < 400,
|
|
545
|
+
]
|
|
546
|
+
service_success_count = session.exec(
|
|
547
|
+
select(func.count())
|
|
548
|
+
.select_from(AccessLog)
|
|
549
|
+
.where(*service_success_conditions)
|
|
550
|
+
).first()
|
|
551
|
+
|
|
552
|
+
service_error_conditions = service_conditions + [
|
|
553
|
+
AccessLog.status_code >= 400,
|
|
554
|
+
]
|
|
555
|
+
service_error_count = session.exec(
|
|
556
|
+
select(func.count())
|
|
557
|
+
.select_from(AccessLog)
|
|
558
|
+
.where(*service_error_conditions)
|
|
559
|
+
).first()
|
|
560
|
+
|
|
561
|
+
service_breakdown[service] = {
|
|
562
|
+
"request_count": service_count or 0,
|
|
563
|
+
"successful_requests": service_success_count or 0,
|
|
564
|
+
"error_requests": service_error_count or 0,
|
|
565
|
+
"success_rate": (service_success_count or 0)
|
|
566
|
+
/ (service_count or 1)
|
|
567
|
+
* 100
|
|
568
|
+
if service_count
|
|
569
|
+
else 0,
|
|
570
|
+
"error_rate": (service_error_count or 0)
|
|
571
|
+
/ (service_count or 1)
|
|
572
|
+
* 100
|
|
573
|
+
if service_count
|
|
574
|
+
else 0,
|
|
575
|
+
"avg_duration_ms": service_avg_duration or 0,
|
|
576
|
+
"total_cost_usd": service_total_cost or 0,
|
|
577
|
+
"total_tokens_input": service_total_tokens_input or 0,
|
|
578
|
+
"total_tokens_output": service_total_tokens_output or 0,
|
|
579
|
+
"total_cache_read_tokens": service_cache_read_tokens
|
|
580
|
+
or 0,
|
|
581
|
+
"total_cache_write_tokens": service_cache_write_tokens
|
|
582
|
+
or 0,
|
|
583
|
+
"total_tokens_all": (service_total_tokens_input or 0)
|
|
584
|
+
+ (service_total_tokens_output or 0)
|
|
585
|
+
+ (service_cache_read_tokens or 0)
|
|
586
|
+
+ (service_cache_write_tokens or 0),
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
analytics = {
|
|
590
|
+
"summary": {
|
|
591
|
+
"total_requests": total_requests or 0,
|
|
592
|
+
"total_successful_requests": total_successful_requests or 0,
|
|
593
|
+
"total_error_requests": total_error_requests or 0,
|
|
594
|
+
"avg_duration_ms": avg_duration or 0,
|
|
595
|
+
"total_cost_usd": total_cost or 0,
|
|
596
|
+
"total_tokens_input": total_tokens_input or 0,
|
|
597
|
+
"total_tokens_output": total_tokens_output or 0,
|
|
598
|
+
"total_cache_read_tokens": total_cache_read_tokens or 0,
|
|
599
|
+
"total_cache_write_tokens": total_cache_write_tokens or 0,
|
|
600
|
+
"total_tokens_all": (total_tokens_input or 0)
|
|
601
|
+
+ (total_tokens_output or 0)
|
|
602
|
+
+ (total_cache_read_tokens or 0)
|
|
603
|
+
+ (total_cache_write_tokens or 0),
|
|
604
|
+
},
|
|
605
|
+
"token_analytics": {
|
|
606
|
+
"input_tokens": total_tokens_input or 0,
|
|
607
|
+
"output_tokens": total_tokens_output or 0,
|
|
608
|
+
"cache_read_tokens": total_cache_read_tokens or 0,
|
|
609
|
+
"cache_write_tokens": total_cache_write_tokens or 0,
|
|
610
|
+
"total_tokens": (total_tokens_input or 0)
|
|
611
|
+
+ (total_tokens_output or 0)
|
|
612
|
+
+ (total_cache_read_tokens or 0)
|
|
613
|
+
+ (total_cache_write_tokens or 0),
|
|
614
|
+
},
|
|
615
|
+
"request_analytics": {
|
|
616
|
+
"total_requests": total_requests or 0,
|
|
617
|
+
"successful_requests": total_successful_requests or 0,
|
|
618
|
+
"error_requests": total_error_requests or 0,
|
|
619
|
+
"success_rate": (total_successful_requests or 0)
|
|
620
|
+
/ (total_requests or 1)
|
|
621
|
+
* 100
|
|
622
|
+
if total_requests
|
|
623
|
+
else 0,
|
|
624
|
+
"error_rate": (total_error_requests or 0)
|
|
625
|
+
/ (total_requests or 1)
|
|
626
|
+
* 100
|
|
627
|
+
if total_requests
|
|
628
|
+
else 0,
|
|
629
|
+
},
|
|
630
|
+
"service_type_breakdown": service_breakdown,
|
|
631
|
+
"query_time": time.time(),
|
|
632
|
+
"backend": "sqlmodel",
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
# Add metadata
|
|
636
|
+
analytics["query_params"] = {
|
|
637
|
+
"start_time": start_time,
|
|
638
|
+
"end_time": end_time,
|
|
639
|
+
"model": model,
|
|
640
|
+
"service_type": service_type,
|
|
641
|
+
"hours": hours,
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
return cast(AnalyticsResult, analytics)
|
|
645
|
+
|
|
646
|
+
except Exception as e:
|
|
647
|
+
import structlog
|
|
648
|
+
|
|
649
|
+
logger = structlog.get_logger(__name__)
|
|
650
|
+
logger.error("sqlmodel_analytics_error", error=str(e))
|
|
651
|
+
raise HTTPException(
|
|
652
|
+
status_code=500, detail=f"Analytics query failed: {str(e)}"
|
|
653
|
+
) from e
|
|
654
|
+
else:
|
|
655
|
+
raise HTTPException(
|
|
656
|
+
status_code=503,
|
|
657
|
+
detail="Storage engine not available",
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
except HTTPException:
|
|
661
|
+
raise
|
|
662
|
+
except Exception as e:
|
|
663
|
+
raise HTTPException(
|
|
664
|
+
status_code=500, detail=f"Analytics generation failed: {str(e)}"
|
|
665
|
+
) from e
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
@logs_router.get("/stream")
|
|
669
|
+
async def stream_logs(
|
|
670
|
+
request: Request,
|
|
671
|
+
model: str | None = Query(None, description="Filter by model name"),
|
|
672
|
+
service_type: str | None = Query(
|
|
673
|
+
None,
|
|
674
|
+
description="Filter by service type. Supports comma-separated values (e.g., 'proxy_service,sdk_service') and negation with ! prefix (e.g., '!access_log,!sdk_service')",
|
|
675
|
+
),
|
|
676
|
+
min_duration_ms: float | None = Query(
|
|
677
|
+
None, description="Filter by minimum duration in milliseconds"
|
|
678
|
+
),
|
|
679
|
+
max_duration_ms: float | None = Query(
|
|
680
|
+
None, description="Filter by maximum duration in milliseconds"
|
|
681
|
+
),
|
|
682
|
+
status_code_min: int | None = Query(
|
|
683
|
+
None, description="Filter by minimum status code"
|
|
684
|
+
),
|
|
685
|
+
status_code_max: int | None = Query(
|
|
686
|
+
None, description="Filter by maximum status code"
|
|
687
|
+
),
|
|
688
|
+
) -> StreamingResponse:
|
|
689
|
+
"""
|
|
690
|
+
Stream real-time metrics and request logs via Server-Sent Events.
|
|
691
|
+
|
|
692
|
+
Returns a continuous stream of request events using event-driven SSE
|
|
693
|
+
instead of polling. Events are emitted in real-time when requests
|
|
694
|
+
start, complete, or error. Supports filtering similar to analytics and entries endpoints.
|
|
695
|
+
"""
|
|
696
|
+
import asyncio
|
|
697
|
+
import uuid
|
|
698
|
+
from collections.abc import AsyncIterator
|
|
699
|
+
|
|
700
|
+
# Get request ID from request state
|
|
701
|
+
request_id = getattr(request.state, "request_id", None)
|
|
702
|
+
|
|
703
|
+
if request and hasattr(request, "state") and hasattr(request.state, "context"):
|
|
704
|
+
# Use existing context from middleware
|
|
705
|
+
ctx = request.state.context
|
|
706
|
+
# Set streaming flag for access log
|
|
707
|
+
ctx.add_metadata(streaming=True)
|
|
708
|
+
ctx.add_metadata(event_type="streaming_complete")
|
|
709
|
+
|
|
710
|
+
# Build filter criteria for event filtering
|
|
711
|
+
filter_criteria = {
|
|
712
|
+
"model": model,
|
|
713
|
+
"service_type": service_type,
|
|
714
|
+
"min_duration_ms": min_duration_ms,
|
|
715
|
+
"max_duration_ms": max_duration_ms,
|
|
716
|
+
"status_code_min": status_code_min,
|
|
717
|
+
"status_code_max": status_code_max,
|
|
718
|
+
}
|
|
719
|
+
# Remove None values
|
|
720
|
+
filter_criteria = {k: v for k, v in filter_criteria.items() if v is not None}
|
|
721
|
+
|
|
722
|
+
def should_include_event(event_data: dict[str, Any]) -> bool:
|
|
723
|
+
"""Check if event matches filter criteria."""
|
|
724
|
+
if not filter_criteria:
|
|
725
|
+
return True
|
|
726
|
+
|
|
727
|
+
data = event_data.get("data", {})
|
|
728
|
+
|
|
729
|
+
# Model filter
|
|
730
|
+
if "model" in filter_criteria and data.get("model") != filter_criteria["model"]:
|
|
731
|
+
return False
|
|
732
|
+
|
|
733
|
+
# Service type filter with comma-separated and negation support
|
|
734
|
+
if "service_type" in filter_criteria:
|
|
735
|
+
service_type_filter = filter_criteria["service_type"]
|
|
736
|
+
if isinstance(service_type_filter, str):
|
|
737
|
+
service_filters = [s.strip() for s in service_type_filter.split(",")]
|
|
738
|
+
else:
|
|
739
|
+
# Handle non-string types by converting to string
|
|
740
|
+
service_filters = [str(service_type_filter).strip()]
|
|
741
|
+
include_filters = [f for f in service_filters if not f.startswith("!")]
|
|
742
|
+
exclude_filters = [f[1:] for f in service_filters if f.startswith("!")]
|
|
743
|
+
|
|
744
|
+
data_service_type = data.get("service_type")
|
|
745
|
+
if include_filters and data_service_type not in include_filters:
|
|
746
|
+
return False
|
|
747
|
+
if exclude_filters and data_service_type in exclude_filters:
|
|
748
|
+
return False
|
|
749
|
+
|
|
750
|
+
# Duration filters
|
|
751
|
+
duration_ms = data.get("duration_ms")
|
|
752
|
+
if duration_ms is not None:
|
|
753
|
+
if (
|
|
754
|
+
"min_duration_ms" in filter_criteria
|
|
755
|
+
and duration_ms < filter_criteria["min_duration_ms"]
|
|
756
|
+
):
|
|
757
|
+
return False
|
|
758
|
+
if (
|
|
759
|
+
"max_duration_ms" in filter_criteria
|
|
760
|
+
and duration_ms > filter_criteria["max_duration_ms"]
|
|
761
|
+
):
|
|
762
|
+
return False
|
|
763
|
+
|
|
764
|
+
# Status code filters
|
|
765
|
+
status_code = data.get("status_code")
|
|
766
|
+
if status_code is not None:
|
|
767
|
+
if (
|
|
768
|
+
"status_code_min" in filter_criteria
|
|
769
|
+
and status_code < filter_criteria["status_code_min"]
|
|
770
|
+
):
|
|
771
|
+
return False
|
|
772
|
+
if (
|
|
773
|
+
"status_code_max" in filter_criteria
|
|
774
|
+
and status_code > filter_criteria["status_code_max"]
|
|
775
|
+
):
|
|
776
|
+
return False
|
|
777
|
+
|
|
778
|
+
return True
|
|
779
|
+
|
|
780
|
+
async def event_stream() -> AsyncIterator[str]:
|
|
781
|
+
"""Generate Server-Sent Events for real-time metrics."""
|
|
782
|
+
from ccproxy.observability.sse_events import get_sse_manager
|
|
783
|
+
|
|
784
|
+
# Get SSE manager
|
|
785
|
+
sse_manager = get_sse_manager()
|
|
786
|
+
|
|
787
|
+
# Create unique connection ID
|
|
788
|
+
connection_id = str(uuid.uuid4())
|
|
789
|
+
|
|
790
|
+
try:
|
|
791
|
+
# Use SSE manager for event-driven streaming
|
|
792
|
+
async for event_data in sse_manager.add_connection(
|
|
793
|
+
connection_id, request_id
|
|
794
|
+
):
|
|
795
|
+
# Parse event data to check for filtering
|
|
796
|
+
if event_data.startswith("data: "):
|
|
797
|
+
try:
|
|
798
|
+
import json
|
|
799
|
+
|
|
800
|
+
json_str = event_data[6:].strip()
|
|
801
|
+
if json_str:
|
|
802
|
+
event_obj = json.loads(json_str)
|
|
803
|
+
|
|
804
|
+
# Apply filters for data events (not connection/system events)
|
|
805
|
+
if (
|
|
806
|
+
event_obj.get("type")
|
|
807
|
+
in ["request_complete", "request_start"]
|
|
808
|
+
and filter_criteria
|
|
809
|
+
) and not should_include_event(event_obj):
|
|
810
|
+
continue # Skip this event
|
|
811
|
+
|
|
812
|
+
except (json.JSONDecodeError, KeyError):
|
|
813
|
+
# If we can't parse, pass through (system events)
|
|
814
|
+
pass
|
|
815
|
+
|
|
816
|
+
yield event_data
|
|
817
|
+
|
|
818
|
+
except asyncio.CancelledError:
|
|
819
|
+
# Connection was cancelled, cleanup handled by SSE manager
|
|
820
|
+
pass
|
|
821
|
+
except Exception as e:
|
|
822
|
+
# Send error event
|
|
823
|
+
import json
|
|
824
|
+
|
|
825
|
+
error_event = {
|
|
826
|
+
"type": "error",
|
|
827
|
+
"message": str(e),
|
|
828
|
+
"timestamp": time.time(),
|
|
829
|
+
}
|
|
830
|
+
yield f"data: {json.dumps(error_event)}\n\n"
|
|
831
|
+
|
|
832
|
+
return StreamingResponse(
|
|
833
|
+
event_stream(),
|
|
834
|
+
media_type="text/event-stream",
|
|
835
|
+
headers={
|
|
836
|
+
"Cache-Control": "no-cache",
|
|
837
|
+
"Connection": "keep-alive",
|
|
838
|
+
"Access-Control-Allow-Origin": "*",
|
|
839
|
+
"Access-Control-Allow-Headers": "Cache-Control",
|
|
840
|
+
},
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
@logs_router.get("/entries")
|
|
845
|
+
async def get_logs_entries(
|
|
846
|
+
storage: DuckDBStorageDep,
|
|
847
|
+
settings: SettingsDep,
|
|
848
|
+
limit: int = Query(
|
|
849
|
+
50, ge=1, le=1000, description="Maximum number of entries to return"
|
|
850
|
+
),
|
|
851
|
+
offset: int = Query(0, ge=0, description="Number of entries to skip"),
|
|
852
|
+
order_by: str = Query(
|
|
853
|
+
"timestamp",
|
|
854
|
+
description="Column to order by (timestamp, duration_ms, cost_usd, model, service_type, status_code)",
|
|
855
|
+
),
|
|
856
|
+
order_desc: bool = Query(False, description="Order in descending order"),
|
|
857
|
+
service_type: str | None = Query(
|
|
858
|
+
None,
|
|
859
|
+
description="Filter by service type. Supports comma-separated values (e.g., 'proxy_service,sdk_service') and negation with ! prefix (e.g., '!access_log,!sdk_service')",
|
|
860
|
+
),
|
|
861
|
+
) -> dict[str, Any]:
|
|
862
|
+
"""
|
|
863
|
+
Get the last n database entries from the access logs.
|
|
864
|
+
|
|
865
|
+
Returns individual request entries with full details for analysis.
|
|
866
|
+
"""
|
|
867
|
+
try:
|
|
868
|
+
if not settings.observability.logs_collection_enabled:
|
|
869
|
+
raise HTTPException(
|
|
870
|
+
status_code=503,
|
|
871
|
+
detail="Logs collection is disabled. Enable with logs_collection_enabled=true",
|
|
872
|
+
)
|
|
873
|
+
if not storage:
|
|
874
|
+
raise HTTPException(
|
|
875
|
+
status_code=503,
|
|
876
|
+
detail="Storage backend not available. Ensure DuckDB is installed and pipeline is running.",
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
# Use SQLModel for entries
|
|
880
|
+
if hasattr(storage, "_engine") and storage._engine:
|
|
881
|
+
try:
|
|
882
|
+
with Session(storage._engine) as session:
|
|
883
|
+
# Validate order_by parameter using SQLModel
|
|
884
|
+
valid_columns = list(AccessLog.model_fields.keys())
|
|
885
|
+
if order_by not in valid_columns:
|
|
886
|
+
order_by = "timestamp"
|
|
887
|
+
|
|
888
|
+
# Build SQLModel query
|
|
889
|
+
order_attr = getattr(AccessLog, order_by)
|
|
890
|
+
order_clause = order_attr.desc() if order_desc else order_attr.asc()
|
|
891
|
+
|
|
892
|
+
statement = select(AccessLog)
|
|
893
|
+
|
|
894
|
+
# Apply service type filtering with comma-separated values and negation
|
|
895
|
+
if service_type:
|
|
896
|
+
service_filters = [s.strip() for s in service_type.split(",")]
|
|
897
|
+
include_filters = [
|
|
898
|
+
f for f in service_filters if not f.startswith("!")
|
|
899
|
+
]
|
|
900
|
+
exclude_filters = [
|
|
901
|
+
f[1:] for f in service_filters if f.startswith("!")
|
|
902
|
+
]
|
|
903
|
+
|
|
904
|
+
if include_filters:
|
|
905
|
+
statement = statement.where(
|
|
906
|
+
col(AccessLog.service_type).in_(include_filters)
|
|
907
|
+
)
|
|
908
|
+
if exclude_filters:
|
|
909
|
+
statement = statement.where(
|
|
910
|
+
~col(AccessLog.service_type).in_(exclude_filters)
|
|
911
|
+
)
|
|
912
|
+
|
|
913
|
+
statement = (
|
|
914
|
+
statement.order_by(order_clause).offset(offset).limit(limit)
|
|
915
|
+
)
|
|
916
|
+
results = session.exec(statement).all()
|
|
917
|
+
|
|
918
|
+
# Get total count with same filters
|
|
919
|
+
count_statement = select(func.count()).select_from(AccessLog)
|
|
920
|
+
|
|
921
|
+
# Apply same service type filtering to count
|
|
922
|
+
if service_type:
|
|
923
|
+
service_filters = [s.strip() for s in service_type.split(",")]
|
|
924
|
+
include_filters = [
|
|
925
|
+
f for f in service_filters if not f.startswith("!")
|
|
926
|
+
]
|
|
927
|
+
exclude_filters = [
|
|
928
|
+
f[1:] for f in service_filters if f.startswith("!")
|
|
929
|
+
]
|
|
930
|
+
|
|
931
|
+
if include_filters:
|
|
932
|
+
count_statement = count_statement.where(
|
|
933
|
+
col(AccessLog.service_type).in_(include_filters)
|
|
934
|
+
)
|
|
935
|
+
if exclude_filters:
|
|
936
|
+
count_statement = count_statement.where(
|
|
937
|
+
~col(AccessLog.service_type).in_(exclude_filters)
|
|
938
|
+
)
|
|
939
|
+
|
|
940
|
+
total_count = session.exec(count_statement).first()
|
|
941
|
+
|
|
942
|
+
# Convert to dict format
|
|
943
|
+
entries = [log.dict() for log in results]
|
|
944
|
+
|
|
945
|
+
return {
|
|
946
|
+
"entries": entries,
|
|
947
|
+
"total_count": total_count,
|
|
948
|
+
"limit": limit,
|
|
949
|
+
"offset": offset,
|
|
950
|
+
"order_by": order_by,
|
|
951
|
+
"order_desc": order_desc,
|
|
952
|
+
"service_type": service_type,
|
|
953
|
+
"page": (offset // limit) + 1,
|
|
954
|
+
"total_pages": ((total_count or 0) + limit - 1) // limit,
|
|
955
|
+
"backend": "sqlmodel",
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
except Exception as e:
|
|
959
|
+
import structlog
|
|
960
|
+
|
|
961
|
+
logger = structlog.get_logger(__name__)
|
|
962
|
+
logger.error("sqlmodel_entries_error", error=str(e))
|
|
963
|
+
raise HTTPException(
|
|
964
|
+
status_code=500, detail=f"Failed to retrieve entries: {str(e)}"
|
|
965
|
+
) from e
|
|
966
|
+
else:
|
|
967
|
+
raise HTTPException(
|
|
968
|
+
status_code=503,
|
|
969
|
+
detail="Storage engine not available",
|
|
970
|
+
)
|
|
971
|
+
|
|
972
|
+
except HTTPException:
|
|
973
|
+
raise
|
|
974
|
+
except Exception as e:
|
|
975
|
+
raise HTTPException(
|
|
976
|
+
status_code=500, detail=f"Failed to retrieve database entries: {str(e)}"
|
|
977
|
+
) from e
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
@logs_router.post("/reset")
|
|
981
|
+
async def reset_logs_data(
|
|
982
|
+
storage: DuckDBStorageDep, settings: SettingsDep
|
|
983
|
+
) -> dict[str, Any]:
|
|
984
|
+
"""
|
|
985
|
+
Reset all data in the logs storage.
|
|
986
|
+
|
|
987
|
+
This endpoint clears all access logs from the database.
|
|
988
|
+
Use with caution - this action cannot be undone.
|
|
989
|
+
|
|
990
|
+
Returns:
|
|
991
|
+
Dictionary with reset status and timestamp
|
|
992
|
+
"""
|
|
993
|
+
try:
|
|
994
|
+
if not settings.observability.logs_collection_enabled:
|
|
995
|
+
raise HTTPException(
|
|
996
|
+
status_code=503,
|
|
997
|
+
detail="Logs collection is disabled. Enable with logs_collection_enabled=true",
|
|
998
|
+
)
|
|
999
|
+
if not storage:
|
|
1000
|
+
raise HTTPException(
|
|
1001
|
+
status_code=503,
|
|
1002
|
+
detail="Storage backend not available. Ensure DuckDB is installed.",
|
|
1003
|
+
)
|
|
1004
|
+
|
|
1005
|
+
# Check if storage has reset_data method
|
|
1006
|
+
if not hasattr(storage, "reset_data"):
|
|
1007
|
+
raise HTTPException(
|
|
1008
|
+
status_code=501,
|
|
1009
|
+
detail="Reset operation not supported by current storage backend",
|
|
1010
|
+
)
|
|
1011
|
+
|
|
1012
|
+
# Perform the reset
|
|
1013
|
+
success = await storage.reset_data()
|
|
1014
|
+
|
|
1015
|
+
if success:
|
|
1016
|
+
return {
|
|
1017
|
+
"status": "success",
|
|
1018
|
+
"message": "All logs data has been reset",
|
|
1019
|
+
"timestamp": time.time(),
|
|
1020
|
+
"backend": "duckdb",
|
|
1021
|
+
}
|
|
1022
|
+
else:
|
|
1023
|
+
raise HTTPException(
|
|
1024
|
+
status_code=500,
|
|
1025
|
+
detail="Reset operation failed",
|
|
1026
|
+
)
|
|
1027
|
+
|
|
1028
|
+
except HTTPException:
|
|
1029
|
+
raise
|
|
1030
|
+
except Exception as e:
|
|
1031
|
+
raise HTTPException(
|
|
1032
|
+
status_code=500, detail=f"Reset operation failed: {str(e)}"
|
|
1033
|
+
) from e
|