Skip to content

Commit 8104c85

Browse files
author
Dylan Huang
committed
validate logs response
1 parent c01c854 commit 8104c85

File tree

2 files changed

+77
-15
lines changed

2 files changed

+77
-15
lines changed

eval_protocol/utils/logs_models.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""
2+
Pydantic models for the logs server API.
3+
4+
This module contains data models that match the TypeScript schemas in eval-protocol.ts
5+
to ensure consistent data structure between Python backend and TypeScript frontend.
6+
"""
7+
8+
from typing import Any, List, Optional
9+
from pydantic import BaseModel, ConfigDict, Field
10+
11+
12+
class LogEntry(BaseModel):
13+
"""
14+
Represents a single log entry from Elasticsearch.
15+
16+
This model matches the LogEntrySchema in eval-protocol.ts to ensure
17+
consistent data structure between Python backend and TypeScript frontend.
18+
"""
19+
20+
timestamp: str = Field(..., alias="@timestamp", description="ISO 8601 timestamp of the log entry")
21+
level: str = Field(..., description="Log level (DEBUG, INFO, WARNING, ERROR)")
22+
message: str = Field(..., description="The log message")
23+
logger_name: str = Field(..., description="Name of the logger that created this entry")
24+
rollout_id: str = Field(..., description="ID of the rollout this log belongs to")
25+
status_code: Optional[int] = Field(None, description="Optional status code")
26+
status_message: Optional[str] = Field(None, description="Optional status message")
27+
status_details: Optional[List[Any]] = Field(None, description="Optional status details")
28+
29+
model_config = ConfigDict(populate_by_name=True)
30+
31+
32+
class LogsResponse(BaseModel):
33+
"""
34+
Response model for the get_logs endpoint.
35+
36+
This model matches the LogsResponseSchema in eval-protocol.ts to ensure
37+
consistent data structure between Python backend and TypeScript frontend.
38+
"""
39+
40+
logs: List[LogEntry] = Field(..., description="Array of log entries")
41+
total: int = Field(..., description="Total number of logs available")
42+
rollout_id: str = Field(..., description="The rollout ID these logs belong to")
43+
filtered_by_level: Optional[str] = Field(None, description="Log level filter applied")
44+
45+
model_config = ConfigDict()

eval_protocol/utils/logs_server.py

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from eval_protocol.utils.vite_server import ViteServer
2121
from eval_protocol.logging.elasticsearch_client import ElasticsearchClient
2222
from eval_protocol.types.remote_rollout_processor import ElasticsearchConfig
23+
from eval_protocol.utils.logs_models import LogEntry, LogsResponse
2324

2425
if TYPE_CHECKING:
2526
from eval_protocol.models import EvaluationRow
@@ -339,12 +340,12 @@ async def status():
339340
"elasticsearch_enabled": self.elasticsearch_client is not None,
340341
}
341342

342-
@self.app.get("/api/logs/{rollout_id}")
343+
@self.app.get("/api/logs/{rollout_id}", response_model=LogsResponse, response_model_exclude_none=True)
343344
async def get_logs(
344345
rollout_id: str,
345346
level: Optional[str] = Query(None, description="Filter by log level (DEBUG, INFO, WARNING, ERROR)"),
346347
limit: int = Query(100, description="Maximum number of log entries to return"),
347-
):
348+
) -> LogsResponse:
348349
"""Get logs for a specific rollout ID from Elasticsearch."""
349350
if not self.elasticsearch_client:
350351
raise HTTPException(status_code=503, detail="Elasticsearch is not configured for this logs server")
@@ -354,20 +355,35 @@ async def get_logs(
354355
search_results = self.elasticsearch_client.search_by_match("rollout_id", rollout_id, size=limit)
355356

356357
if not search_results or "hits" not in search_results:
357-
return {"logs": [], "total": 0}
358-
359-
logs = []
358+
# Return empty response using Pydantic model
359+
return LogsResponse(
360+
logs=[],
361+
total=0,
362+
rollout_id=rollout_id,
363+
filtered_by_level=level,
364+
)
365+
366+
log_entries = []
360367
for hit in search_results["hits"]["hits"]:
361-
log_entry = hit["_source"]
368+
log_data = hit["_source"]
362369

363370
# Filter by level if specified
364-
if level and log_entry.get("level") != level:
371+
if level and log_data.get("level") != level:
365372
continue
366373

367-
logs.append(log_entry)
374+
# Create LogEntry using Pydantic model for validation
375+
try:
376+
log_entry = LogEntry(
377+
**log_data # Use ** to unpack the dict, Pydantic will handle field mapping
378+
)
379+
log_entries.append(log_entry)
380+
except Exception as e:
381+
# Log the error but continue processing other entries
382+
logger.warning(f"Failed to parse log entry: {e}, data: {log_data}")
383+
continue
368384

369385
# Sort by timestamp (most recent first)
370-
logs.sort(key=lambda x: x.get("@timestamp", ""), reverse=True)
386+
log_entries.sort(key=lambda x: x.timestamp, reverse=True)
371387

372388
# Get total count
373389
total_hits = search_results["hits"]["total"]
@@ -378,12 +394,13 @@ async def get_logs(
378394
# Elasticsearch 6 format
379395
total_count = total_hits
380396

381-
return {
382-
"logs": logs,
383-
"total": total_count,
384-
"rollout_id": rollout_id,
385-
"filtered_by_level": level,
386-
}
397+
# Return response using Pydantic model
398+
return LogsResponse(
399+
logs=log_entries,
400+
total=total_count,
401+
rollout_id=rollout_id,
402+
filtered_by_level=level,
403+
)
387404

388405
except Exception as e:
389406
logger.error(f"Error retrieving logs for rollout {rollout_id}: {e}")

0 commit comments

Comments
 (0)