Skip to content

Commit f2184d3

Browse files
gmathy2104claude
andcommitted
feat(v2.8.1): add system logs API endpoints with real-time streaming
Add comprehensive logging endpoints to the API: - GET /v1/system/logs: Retrieve service logs with filtering * Query params: lines (1-10000, default 100), level (INFO/WARNING/ERROR), search (pattern) * Returns JSON with log lines, count, and service name * Examples: - ?lines=50 - Get last 50 lines - ?level=ERROR - Filter by ERROR level - ?search=resolution - Search for pattern - ?lines=200&level=ERROR&search=camera - Combined filters - GET /v1/system/logs/stream: Real-time log streaming via SSE * Server-Sent Events (SSE) for continuous log monitoring * Query params: level, search (same filtering as above) * Auto-cleanup of subprocess on client disconnect * Usage: EventSource API in JavaScript or SSE clients Implementation details: - Uses journalctl to read systemd service logs - No sudo required (service can read its own logs) - Async subprocess management for streaming endpoint - Proper cleanup to prevent orphaned processes - Added LogsResponse Pydantic model - Updated API version to 2.8.1 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent c4aa1c1 commit f2184d3

File tree

1 file changed

+210
-3
lines changed

1 file changed

+210
-3
lines changed

camera_service/api.py

Lines changed: 210 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,15 @@
77

88
from __future__ import annotations
99

10+
import asyncio
1011
import logging
12+
import subprocess
1113
from contextlib import asynccontextmanager
1214
from threading import RLock
1315
from typing import Annotated, AsyncGenerator, Optional
1416

15-
from fastapi import Depends, FastAPI, HTTPException, Security, status
17+
from fastapi import Depends, FastAPI, HTTPException, Query, Security, status
18+
from fastapi.responses import StreamingResponse as FastAPIStreamingResponse
1619
from fastapi.security import APIKeyHeader
1720
from pydantic import BaseModel, Field
1821

@@ -192,7 +195,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
192195
app = FastAPI(
193196
title="Pi Camera Service",
194197
description="API for controlling Raspberry Pi camera and streaming to MediaMTX via RTSP",
195-
version="2.5.0",
198+
version="2.8.1",
196199
lifespan=lifespan,
197200
)
198201

@@ -489,6 +492,13 @@ class SystemStatusResponse(BaseModel):
489492
throttled: Optional[dict] = Field(None, description="Throttling status (Pi-specific)")
490493

491494

495+
class LogsResponse(BaseModel):
496+
"""Logs response model."""
497+
logs: list[str] = Field(..., description="Log lines")
498+
total_lines: int = Field(..., description="Total number of lines returned")
499+
service: str = Field("pi-camera-service", description="Service name")
500+
501+
492502
# ========== Exception Handlers ==========
493503

494504
@app.exception_handler(InvalidParameterError)
@@ -548,7 +558,7 @@ def health_check() -> HealthResponse:
548558
status="healthy" if camera_controller is not None else "initializing",
549559
camera_configured=camera_controller._configured if camera_controller else False,
550560
streaming_active=streaming_manager.is_streaming() if streaming_manager else False,
551-
version="2.5.0",
561+
version="2.8.1",
552562
)
553563

554564

@@ -1981,3 +1991,200 @@ def get_system_status(
19811991
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
19821992
detail="Failed to get system status",
19831993
)
1994+
1995+
1996+
@app.get(
1997+
"/v1/system/logs",
1998+
response_model=LogsResponse,
1999+
summary="Get service logs",
2000+
description="Get logs from the pi-camera-service with optional filtering by lines, level, and search pattern",
2001+
tags=["System"],
2002+
)
2003+
def get_system_logs(
2004+
lines: Annotated[int, Query(ge=1, le=10000, description="Number of log lines to retrieve")] = 100,
2005+
level: Annotated[Optional[str], Query(description="Filter by log level (INFO, WARNING, ERROR)")] = None,
2006+
search: Annotated[Optional[str], Query(description="Search pattern to filter logs")] = None,
2007+
_: Annotated[None, Depends(verify_api_key)] = None,
2008+
) -> LogsResponse:
2009+
"""
2010+
Get service logs with optional filtering.
2011+
2012+
Retrieves logs from the pi-camera-service systemd service using journalctl.
2013+
2014+
Query Parameters:
2015+
- lines: Number of log lines to retrieve (1-10000, default: 100)
2016+
- level: Filter by log level (INFO, WARNING, ERROR)
2017+
- search: Search for specific pattern in logs
2018+
2019+
Examples:
2020+
- /v1/system/logs?lines=50 - Get last 50 log lines
2021+
- /v1/system/logs?level=ERROR - Get only ERROR level logs
2022+
- /v1/system/logs?search=resolution - Search for "resolution" in logs
2023+
- /v1/system/logs?lines=200&level=ERROR&search=camera - Combined filters
2024+
2025+
Returns:
2026+
LogsResponse: Log lines and metadata
2027+
2028+
Raises:
2029+
HTTPException: If log retrieval fails
2030+
"""
2031+
logger.debug(f"Getting system logs: lines={lines}, level={level}, search={search}")
2032+
2033+
try:
2034+
# Build journalctl command (no sudo needed - journalctl allows reading own service logs)
2035+
cmd = ["journalctl", "-u", "pi-camera-service", "-n", str(lines), "--no-pager"]
2036+
2037+
# Execute journalctl
2038+
result = subprocess.run(
2039+
cmd,
2040+
capture_output=True,
2041+
text=True,
2042+
timeout=10
2043+
)
2044+
2045+
if result.returncode != 0:
2046+
raise HTTPException(
2047+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
2048+
detail=f"Failed to retrieve logs: {result.stderr}",
2049+
)
2050+
2051+
# Split logs into lines
2052+
log_lines = result.stdout.strip().split('\n') if result.stdout else []
2053+
2054+
# Apply level filter if specified
2055+
if level:
2056+
level_upper = level.upper()
2057+
log_lines = [line for line in log_lines if level_upper in line]
2058+
2059+
# Apply search filter if specified
2060+
if search:
2061+
log_lines = [line for line in log_lines if search in line]
2062+
2063+
return LogsResponse(
2064+
logs=log_lines,
2065+
total_lines=len(log_lines),
2066+
service="pi-camera-service"
2067+
)
2068+
2069+
except subprocess.TimeoutExpired:
2070+
logger.error("Timeout while retrieving logs")
2071+
raise HTTPException(
2072+
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
2073+
detail="Log retrieval timed out",
2074+
)
2075+
except Exception as e:
2076+
logger.error(f"Error getting system logs: {e}")
2077+
raise HTTPException(
2078+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
2079+
detail=f"Failed to retrieve logs: {str(e)}",
2080+
)
2081+
2082+
2083+
@app.get(
2084+
"/v1/system/logs/stream",
2085+
summary="Stream service logs in real-time",
2086+
description="Stream logs from the pi-camera-service in real-time using Server-Sent Events (SSE)",
2087+
tags=["System"],
2088+
)
2089+
async def stream_system_logs(
2090+
level: Annotated[Optional[str], Query(description="Filter by log level (INFO, WARNING, ERROR)")] = None,
2091+
search: Annotated[Optional[str], Query(description="Search pattern to filter logs")] = None,
2092+
_: Annotated[None, Depends(verify_api_key)] = None,
2093+
):
2094+
"""
2095+
Stream service logs in real-time using Server-Sent Events (SSE).
2096+
2097+
This endpoint provides a continuous stream of log entries as they are generated.
2098+
The connection will remain open until the client disconnects.
2099+
2100+
Query Parameters:
2101+
- level: Filter by log level (INFO, WARNING, ERROR)
2102+
- search: Search for specific pattern in logs
2103+
2104+
Usage:
2105+
- Use EventSource in JavaScript or SSE client libraries
2106+
- Each event contains a single log line
2107+
- Connection stays open for continuous streaming
2108+
2109+
Example (JavaScript):
2110+
```javascript
2111+
const eventSource = new EventSource('/v1/system/logs/stream?level=ERROR');
2112+
eventSource.onmessage = (event) => {
2113+
console.log('Log:', event.data);
2114+
};
2115+
```
2116+
2117+
Returns:
2118+
StreamingResponse: SSE stream of log entries
2119+
2120+
Raises:
2121+
HTTPException: If log streaming fails
2122+
"""
2123+
logger.debug(f"Starting log stream: level={level}, search={search}")
2124+
2125+
async def log_generator():
2126+
"""Generate log events as they arrive."""
2127+
process = None
2128+
try:
2129+
# Start journalctl in follow mode (no sudo needed - journalctl allows reading own service logs)
2130+
cmd = ["journalctl", "-u", "pi-camera-service", "-f", "--no-pager", "-n", "0"]
2131+
2132+
# Use asyncio subprocess for non-blocking I/O
2133+
process = await asyncio.create_subprocess_exec(
2134+
*cmd,
2135+
stdout=asyncio.subprocess.PIPE,
2136+
stderr=asyncio.subprocess.PIPE
2137+
)
2138+
2139+
# Read lines as they arrive
2140+
while True:
2141+
# Read line asynchronously
2142+
line_bytes = await process.stdout.readline()
2143+
2144+
if not line_bytes:
2145+
# Process ended
2146+
break
2147+
2148+
line = line_bytes.decode('utf-8').strip()
2149+
2150+
if not line:
2151+
continue
2152+
2153+
# Apply level filter if specified
2154+
if level and level.upper() not in line:
2155+
continue
2156+
2157+
# Apply search filter if specified
2158+
if search and search not in line:
2159+
continue
2160+
2161+
# Send as SSE event
2162+
yield f"data: {line}\n\n"
2163+
2164+
except asyncio.CancelledError:
2165+
# Client disconnected
2166+
logger.debug("Log stream cancelled by client")
2167+
except Exception as e:
2168+
logger.error(f"Error in log stream: {e}")
2169+
yield f"data: ERROR: {str(e)}\n\n"
2170+
finally:
2171+
# Clean up process
2172+
if process:
2173+
try:
2174+
process.terminate()
2175+
await asyncio.wait_for(process.wait(), timeout=2.0)
2176+
except asyncio.TimeoutError:
2177+
process.kill()
2178+
await process.wait()
2179+
except Exception as e:
2180+
logger.error(f"Error cleaning up log stream process: {e}")
2181+
2182+
return FastAPIStreamingResponse(
2183+
log_generator(),
2184+
media_type="text/event-stream",
2185+
headers={
2186+
"Cache-Control": "no-cache",
2187+
"Connection": "keep-alive",
2188+
"X-Accel-Buffering": "no", # Disable nginx buffering
2189+
}
2190+
)

0 commit comments

Comments
 (0)