finchvox 0.0.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.
- finchvox/__init__.py +0 -0
- finchvox/__main__.py +81 -0
- finchvox/audio_recorder.py +278 -0
- finchvox/audio_utils.py +123 -0
- finchvox/cli.py +127 -0
- finchvox/collector/__init__.py +0 -0
- finchvox/collector/__main__.py +22 -0
- finchvox/collector/audio_handler.py +146 -0
- finchvox/collector/collector_routes.py +186 -0
- finchvox/collector/config.py +64 -0
- finchvox/collector/server.py +126 -0
- finchvox/collector/service.py +43 -0
- finchvox/collector/writer.py +86 -0
- finchvox/server.py +201 -0
- finchvox/trace.py +115 -0
- finchvox/ui/css/app.css +774 -0
- finchvox/ui/images/favicon.ico +0 -0
- finchvox/ui/images/finchvox-logo.png +0 -0
- finchvox/ui/js/time-utils.js +97 -0
- finchvox/ui/js/trace_detail.js +1228 -0
- finchvox/ui/js/traces_list.js +26 -0
- finchvox/ui/lib/alpine.min.js +5 -0
- finchvox/ui/lib/wavesurfer.min.js +1 -0
- finchvox/ui/trace_detail.html +313 -0
- finchvox/ui/traces_list.html +63 -0
- finchvox/ui_routes.py +362 -0
- finchvox-0.0.1.dist-info/METADATA +189 -0
- finchvox-0.0.1.dist-info/RECORD +31 -0
- finchvox-0.0.1.dist-info/WHEEL +4 -0
- finchvox-0.0.1.dist-info/entry_points.txt +2 -0
- finchvox-0.0.1.dist-info/licenses/LICENSE +24 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Audio handler for storing conversation audio chunks.
|
|
3
|
+
|
|
4
|
+
This module provides the AudioHandler class which manages the storage of
|
|
5
|
+
audio chunks and their associated metadata, organized by trace ID.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
import aiofiles
|
|
13
|
+
from loguru import logger
|
|
14
|
+
from finchvox.collector.config import get_trace_audio_dir
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AudioHandler:
|
|
18
|
+
"""Handles writing audio chunks to organized directory structure."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, data_dir: Path):
|
|
21
|
+
"""
|
|
22
|
+
Initialize audio handler.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
data_dir: Base data directory (e.g., ~/.finchvox)
|
|
26
|
+
"""
|
|
27
|
+
self.data_dir = data_dir
|
|
28
|
+
logger.info(f"AudioHandler initialized with data_dir: {self.data_dir}")
|
|
29
|
+
|
|
30
|
+
async def save_audio_chunk(
|
|
31
|
+
self,
|
|
32
|
+
trace_id: str,
|
|
33
|
+
chunk_number: int,
|
|
34
|
+
audio_data: bytes,
|
|
35
|
+
metadata: dict,
|
|
36
|
+
) -> Optional[Path]:
|
|
37
|
+
"""
|
|
38
|
+
Save audio chunk with metadata.
|
|
39
|
+
|
|
40
|
+
Directory structure:
|
|
41
|
+
data_dir/
|
|
42
|
+
traces/
|
|
43
|
+
{trace_id}/
|
|
44
|
+
audio/
|
|
45
|
+
chunk_0000.wav
|
|
46
|
+
chunk_0000.json # metadata
|
|
47
|
+
chunk_0001.wav
|
|
48
|
+
chunk_0001.json
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
trace_id: OpenTelemetry trace ID (32 hex chars)
|
|
52
|
+
chunk_number: Sequential chunk number
|
|
53
|
+
audio_data: Raw audio file bytes
|
|
54
|
+
metadata: Dictionary containing chunk metadata
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Path to saved audio file, or None if save failed
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
# Validate trace_id format
|
|
61
|
+
if not self._is_valid_trace_id(trace_id):
|
|
62
|
+
logger.error(f"Invalid trace_id format: {trace_id}")
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
# Create trace-specific audio directory
|
|
66
|
+
trace_audio_dir = get_trace_audio_dir(self.data_dir, trace_id)
|
|
67
|
+
trace_audio_dir.mkdir(exist_ok=True, parents=True)
|
|
68
|
+
|
|
69
|
+
# Generate filenames with zero-padded chunk number
|
|
70
|
+
audio_file = trace_audio_dir / f"chunk_{chunk_number:04d}.wav"
|
|
71
|
+
metadata_file = trace_audio_dir / f"chunk_{chunk_number:04d}.json"
|
|
72
|
+
|
|
73
|
+
# Save audio file (async to avoid blocking)
|
|
74
|
+
async with aiofiles.open(audio_file, "wb") as f:
|
|
75
|
+
await f.write(audio_data)
|
|
76
|
+
|
|
77
|
+
# Save metadata
|
|
78
|
+
async with aiofiles.open(metadata_file, "w") as f:
|
|
79
|
+
await f.write(json.dumps(metadata, indent=2))
|
|
80
|
+
|
|
81
|
+
logger.info(
|
|
82
|
+
f"Saved audio chunk {chunk_number} for trace {trace_id[:8]}... "
|
|
83
|
+
f"({len(audio_data)} bytes)"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return audio_file
|
|
87
|
+
|
|
88
|
+
except OSError as e:
|
|
89
|
+
# Disk full, permission denied, etc.
|
|
90
|
+
logger.error(
|
|
91
|
+
f"Failed to save audio chunk {chunk_number} for trace {trace_id[:8]}...: {e}",
|
|
92
|
+
exc_info=True,
|
|
93
|
+
)
|
|
94
|
+
return None
|
|
95
|
+
except Exception as e:
|
|
96
|
+
logger.error(
|
|
97
|
+
f"Unexpected error saving audio chunk {chunk_number} "
|
|
98
|
+
f"for trace {trace_id[:8]}...: {e}",
|
|
99
|
+
exc_info=True,
|
|
100
|
+
)
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
def _is_valid_trace_id(self, trace_id: str) -> bool:
|
|
104
|
+
"""
|
|
105
|
+
Validate trace_id format.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
trace_id: Trace ID to validate
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
True if valid (32 hex chars), False otherwise
|
|
112
|
+
"""
|
|
113
|
+
if len(trace_id) != 32:
|
|
114
|
+
return False
|
|
115
|
+
try:
|
|
116
|
+
int(trace_id, 16) # Validate it's hexadecimal
|
|
117
|
+
return True
|
|
118
|
+
except ValueError:
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
def get_trace_audio_dir(self, trace_id: str) -> Path:
|
|
122
|
+
"""
|
|
123
|
+
Get directory path for a trace's audio files.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
trace_id: OpenTelemetry trace ID
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Path to trace-specific audio directory
|
|
130
|
+
"""
|
|
131
|
+
return get_trace_audio_dir(self.data_dir, trace_id)
|
|
132
|
+
|
|
133
|
+
def list_chunks(self, trace_id: str) -> list[Path]:
|
|
134
|
+
"""
|
|
135
|
+
List all audio chunks for a trace.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
trace_id: OpenTelemetry trace ID
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
Sorted list of audio file paths for the trace
|
|
142
|
+
"""
|
|
143
|
+
trace_dir = self.get_trace_audio_dir(trace_id)
|
|
144
|
+
if not trace_dir.exists():
|
|
145
|
+
return []
|
|
146
|
+
return sorted(trace_dir.glob("chunk_*.wav"))
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Collector routes for audio chunk uploads, logs, and exceptions.
|
|
3
|
+
|
|
4
|
+
This module provides route registration functions for the collector endpoints,
|
|
5
|
+
which handle data ingestion from Pipecat applications.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status
|
|
12
|
+
from fastapi.responses import JSONResponse
|
|
13
|
+
from loguru import logger
|
|
14
|
+
|
|
15
|
+
from .audio_handler import AudioHandler
|
|
16
|
+
from .config import ALLOWED_AUDIO_FORMATS, MAX_AUDIO_FILE_SIZE
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def register_collector_routes(
|
|
20
|
+
app: FastAPI,
|
|
21
|
+
audio_handler: AudioHandler,
|
|
22
|
+
prefix: str = "/collector"
|
|
23
|
+
):
|
|
24
|
+
"""
|
|
25
|
+
Register collector routes on an existing FastAPI app with URL prefix.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
app: Existing FastAPI application to register routes on
|
|
29
|
+
audio_handler: AudioHandler instance for managing audio storage
|
|
30
|
+
prefix: URL prefix for all collector routes (default: "/collector")
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
@app.post(f"{prefix}/audio/{{trace_id}}/chunk")
|
|
34
|
+
async def upload_audio_chunk(
|
|
35
|
+
trace_id: str,
|
|
36
|
+
audio: UploadFile = File(..., description="Audio file (WAV format)"),
|
|
37
|
+
metadata: str = Form(..., description="JSON metadata string"),
|
|
38
|
+
):
|
|
39
|
+
"""
|
|
40
|
+
Upload audio chunk for a trace.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
trace_id: Hex string trace ID (32 chars)
|
|
44
|
+
audio: Audio file (WAV format)
|
|
45
|
+
metadata: JSON string with:
|
|
46
|
+
- chunk_number: int
|
|
47
|
+
- timestamp: ISO format string
|
|
48
|
+
- sample_rate: int
|
|
49
|
+
- num_channels: int
|
|
50
|
+
- timing_events: list[dict] (optional)
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
JSON response with storage path and status
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
HTTPException: For validation errors or server errors
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
# Validate trace_id format
|
|
60
|
+
if len(trace_id) != 32 or not all(
|
|
61
|
+
c in "0123456789abcdef" for c in trace_id
|
|
62
|
+
):
|
|
63
|
+
raise HTTPException(
|
|
64
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
65
|
+
detail=f"Invalid trace_id format: must be 32 hex chars, got {trace_id}",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Parse metadata
|
|
69
|
+
try:
|
|
70
|
+
metadata_dict = json.loads(metadata)
|
|
71
|
+
except json.JSONDecodeError as e:
|
|
72
|
+
raise HTTPException(
|
|
73
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
74
|
+
detail=f"Invalid JSON metadata: {e}",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Validate required metadata fields
|
|
78
|
+
required_fields = ["chunk_number", "timestamp", "sample_rate", "num_channels"]
|
|
79
|
+
missing = [f for f in required_fields if f not in metadata_dict]
|
|
80
|
+
if missing:
|
|
81
|
+
raise HTTPException(
|
|
82
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
83
|
+
detail=f"Missing required metadata fields: {missing}",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Read audio data
|
|
87
|
+
audio_data = await audio.read()
|
|
88
|
+
|
|
89
|
+
# Validate file size
|
|
90
|
+
if len(audio_data) > MAX_AUDIO_FILE_SIZE:
|
|
91
|
+
raise HTTPException(
|
|
92
|
+
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
|
93
|
+
detail=f"Audio file too large: {len(audio_data)} bytes (max {MAX_AUDIO_FILE_SIZE})",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Validate file format (basic check)
|
|
97
|
+
file_ext = Path(audio.filename or "unknown.wav").suffix.lower()
|
|
98
|
+
if file_ext not in ALLOWED_AUDIO_FORMATS:
|
|
99
|
+
raise HTTPException(
|
|
100
|
+
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
|
101
|
+
detail=f"Unsupported audio format: {file_ext} (allowed: {ALLOWED_AUDIO_FORMATS})",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Check if this is a new trace or existing trace (for logging)
|
|
105
|
+
existing_chunks = audio_handler.list_chunks(trace_id)
|
|
106
|
+
is_new_trace = len(existing_chunks) == 0
|
|
107
|
+
|
|
108
|
+
if is_new_trace:
|
|
109
|
+
logger.info(f"New audio trace {trace_id[:8]}... - receiving chunk #{metadata_dict['chunk_number']}")
|
|
110
|
+
else:
|
|
111
|
+
logger.info(f"Audio trace {trace_id[:8]}... - receiving chunk #{metadata_dict['chunk_number']} (total: {len(existing_chunks) + 1})")
|
|
112
|
+
|
|
113
|
+
# Save audio chunk
|
|
114
|
+
saved_path = await audio_handler.save_audio_chunk(
|
|
115
|
+
trace_id=trace_id,
|
|
116
|
+
chunk_number=metadata_dict["chunk_number"],
|
|
117
|
+
audio_data=audio_data,
|
|
118
|
+
metadata=metadata_dict,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
if saved_path is None:
|
|
122
|
+
raise HTTPException(
|
|
123
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
124
|
+
detail="Failed to save audio chunk",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
return JSONResponse(
|
|
128
|
+
status_code=status.HTTP_201_CREATED,
|
|
129
|
+
content={
|
|
130
|
+
"status": "success",
|
|
131
|
+
"trace_id": trace_id,
|
|
132
|
+
"chunk_number": metadata_dict["chunk_number"],
|
|
133
|
+
"file_path": str(saved_path),
|
|
134
|
+
"size_bytes": len(audio_data),
|
|
135
|
+
},
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
except HTTPException:
|
|
139
|
+
raise
|
|
140
|
+
except Exception as e:
|
|
141
|
+
logger.error(
|
|
142
|
+
f"Failed to process audio upload for trace {trace_id}: {e}",
|
|
143
|
+
exc_info=True,
|
|
144
|
+
)
|
|
145
|
+
raise HTTPException(
|
|
146
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
147
|
+
detail="Internal server error processing audio upload",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
@app.get(f"{prefix}/health")
|
|
151
|
+
async def health_check():
|
|
152
|
+
"""
|
|
153
|
+
Health check endpoint for monitoring.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Status information
|
|
157
|
+
"""
|
|
158
|
+
return {"status": "healthy", "service": "finchvox-collector"}
|
|
159
|
+
|
|
160
|
+
@app.get(f"{prefix}/audio/{{trace_id}}/chunks")
|
|
161
|
+
async def list_audio_chunks(trace_id: str):
|
|
162
|
+
"""
|
|
163
|
+
List all audio chunks for a trace.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
trace_id: OpenTelemetry trace ID
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
JSON with list of chunks
|
|
170
|
+
|
|
171
|
+
Raises:
|
|
172
|
+
HTTPException: If listing fails
|
|
173
|
+
"""
|
|
174
|
+
try:
|
|
175
|
+
chunks = audio_handler.list_chunks(trace_id)
|
|
176
|
+
return {
|
|
177
|
+
"trace_id": trace_id,
|
|
178
|
+
"chunk_count": len(chunks),
|
|
179
|
+
"chunks": [{"path": str(p), "name": p.name} for p in chunks],
|
|
180
|
+
}
|
|
181
|
+
except Exception as e:
|
|
182
|
+
logger.error(f"Failed to list chunks for trace {trace_id}: {e}")
|
|
183
|
+
raise HTTPException(
|
|
184
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
185
|
+
detail="Failed to list audio chunks",
|
|
186
|
+
)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
# Server configuration
|
|
4
|
+
GRPC_PORT = 4317 # Standard OTLP gRPC port
|
|
5
|
+
HTTP_PORT = 3000 # Unified HTTP server (collector + UI)
|
|
6
|
+
MAX_WORKERS = 10 # Thread pool size for concurrent requests
|
|
7
|
+
|
|
8
|
+
# Logging configuration
|
|
9
|
+
LOG_LEVEL = "INFO" # Can be overridden via LOGURU_LEVEL env var
|
|
10
|
+
|
|
11
|
+
# Audio upload configuration
|
|
12
|
+
MAX_AUDIO_FILE_SIZE = 10 * 1024 * 1024 # 10MB max per chunk
|
|
13
|
+
ALLOWED_AUDIO_FORMATS = {".wav", ".mp3", ".ogg", ".flac"}
|
|
14
|
+
|
|
15
|
+
# Log batching configuration
|
|
16
|
+
MAX_LOG_BATCH_SIZE = 100 # Max logs per HTTP request
|
|
17
|
+
LOG_FLUSH_INTERVAL = 5.0 # Seconds between batched uploads
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_default_data_dir() -> Path:
|
|
21
|
+
"""Get the default data directory (~/.finchvox)."""
|
|
22
|
+
return Path.home() / ".finchvox"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_traces_base_dir(data_dir: Path) -> Path:
|
|
26
|
+
"""
|
|
27
|
+
Get the base traces directory.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
data_dir: Base data directory (e.g., ~/.finchvox)
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Path to traces directory (e.g., ~/.finchvox/traces)
|
|
34
|
+
"""
|
|
35
|
+
return data_dir / "traces"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_trace_dir(data_dir: Path, trace_id: str) -> Path:
|
|
39
|
+
"""
|
|
40
|
+
Get the directory for a specific trace.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
data_dir: Base data directory
|
|
44
|
+
trace_id: Hex string trace ID
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Path to trace-specific directory (e.g., ~/.finchvox/traces/<trace_id>)
|
|
48
|
+
"""
|
|
49
|
+
return get_traces_base_dir(data_dir) / trace_id
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_trace_logs_dir(data_dir: Path, trace_id: str) -> Path:
|
|
53
|
+
"""Get the logs directory for a specific trace."""
|
|
54
|
+
return get_trace_dir(data_dir, trace_id) / "logs"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_trace_audio_dir(data_dir: Path, trace_id: str) -> Path:
|
|
58
|
+
"""Get the audio directory for a specific trace."""
|
|
59
|
+
return get_trace_dir(data_dir, trace_id) / "audio"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_trace_exceptions_dir(data_dir: Path, trace_id: str) -> Path:
|
|
63
|
+
"""Get the exceptions directory for a specific trace."""
|
|
64
|
+
return get_trace_dir(data_dir, trace_id) / "exceptions"
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import signal
|
|
3
|
+
import grpc
|
|
4
|
+
import uvicorn
|
|
5
|
+
from concurrent import futures
|
|
6
|
+
from loguru import logger
|
|
7
|
+
from opentelemetry.proto.collector.trace.v1.trace_service_pb2_grpc import (
|
|
8
|
+
add_TraceServiceServicer_to_server
|
|
9
|
+
)
|
|
10
|
+
from .service import TraceCollectorServicer
|
|
11
|
+
from .writer import SpanWriter
|
|
12
|
+
from .logs_writer import LogWriter
|
|
13
|
+
from .exceptions_writer import ExceptionsWriter
|
|
14
|
+
from .audio_handler import AudioHandler
|
|
15
|
+
from .http_server import create_app
|
|
16
|
+
from .config import GRPC_PORT, HTTP_PORT, MAX_WORKERS, TRACES_DIR, AUDIO_DIR, LOGS_DIR, EXCEPTIONS_DIR
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CollectorServer:
|
|
20
|
+
"""Manages both gRPC and HTTP server lifecycle."""
|
|
21
|
+
|
|
22
|
+
def __init__(self):
|
|
23
|
+
self.grpc_server = None
|
|
24
|
+
self.http_server = None
|
|
25
|
+
self.span_writer = SpanWriter(TRACES_DIR)
|
|
26
|
+
self.log_writer = LogWriter(LOGS_DIR)
|
|
27
|
+
self.exceptions_writer = ExceptionsWriter(EXCEPTIONS_DIR)
|
|
28
|
+
self.audio_handler = AudioHandler(AUDIO_DIR)
|
|
29
|
+
self.shutdown_event = asyncio.Event()
|
|
30
|
+
|
|
31
|
+
async def start_grpc(self):
|
|
32
|
+
"""Start the gRPC server."""
|
|
33
|
+
logger.info(f"Starting OTLP gRPC collector on port {GRPC_PORT}")
|
|
34
|
+
|
|
35
|
+
# Create gRPC server with thread pool
|
|
36
|
+
self.grpc_server = grpc.server(
|
|
37
|
+
futures.ThreadPoolExecutor(max_workers=MAX_WORKERS)
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Register our service implementation
|
|
41
|
+
servicer = TraceCollectorServicer(self.span_writer)
|
|
42
|
+
add_TraceServiceServicer_to_server(servicer, self.grpc_server)
|
|
43
|
+
|
|
44
|
+
# Bind to port (insecure for PoC - no TLS)
|
|
45
|
+
self.grpc_server.add_insecure_port(f'[::]:{GRPC_PORT}')
|
|
46
|
+
|
|
47
|
+
# Start serving
|
|
48
|
+
self.grpc_server.start()
|
|
49
|
+
logger.info(f"OTLP collector listening on port {GRPC_PORT}")
|
|
50
|
+
logger.info(f"Writing traces to: {TRACES_DIR.absolute()}")
|
|
51
|
+
|
|
52
|
+
async def start_http(self):
|
|
53
|
+
"""Start the HTTP server using uvicorn."""
|
|
54
|
+
logger.info(f"Starting HTTP collector on port {HTTP_PORT}")
|
|
55
|
+
|
|
56
|
+
# Create FastAPI app with injected dependencies
|
|
57
|
+
app = create_app(self.audio_handler, self.log_writer, self.exceptions_writer)
|
|
58
|
+
|
|
59
|
+
# Configure uvicorn server
|
|
60
|
+
config = uvicorn.Config(
|
|
61
|
+
app,
|
|
62
|
+
host="0.0.0.0",
|
|
63
|
+
port=HTTP_PORT,
|
|
64
|
+
log_level="info",
|
|
65
|
+
access_log=True,
|
|
66
|
+
)
|
|
67
|
+
self.http_server = uvicorn.Server(config)
|
|
68
|
+
|
|
69
|
+
logger.info(f"HTTP collector listening on port {HTTP_PORT}")
|
|
70
|
+
logger.info(f"Writing audio to: {AUDIO_DIR.absolute()}")
|
|
71
|
+
logger.info(f"Writing logs to: {LOGS_DIR.absolute()}")
|
|
72
|
+
logger.info(f"Writing exceptions to: {EXCEPTIONS_DIR.absolute()}")
|
|
73
|
+
|
|
74
|
+
# Run server until shutdown event
|
|
75
|
+
await self.http_server.serve()
|
|
76
|
+
|
|
77
|
+
async def start(self):
|
|
78
|
+
"""Start both servers concurrently."""
|
|
79
|
+
# Start gRPC server
|
|
80
|
+
await self.start_grpc()
|
|
81
|
+
|
|
82
|
+
# Start HTTP server (this blocks until shutdown)
|
|
83
|
+
await self.start_http()
|
|
84
|
+
|
|
85
|
+
async def stop(self, grace_period=5):
|
|
86
|
+
"""Gracefully stop both servers."""
|
|
87
|
+
logger.info(f"Shutting down servers (grace period: {grace_period}s)")
|
|
88
|
+
|
|
89
|
+
# Stop HTTP server
|
|
90
|
+
if self.http_server:
|
|
91
|
+
logger.info("Stopping HTTP server...")
|
|
92
|
+
self.http_server.should_exit = True
|
|
93
|
+
await asyncio.sleep(0.1) # Give it time to process shutdown
|
|
94
|
+
|
|
95
|
+
# Stop gRPC server
|
|
96
|
+
if self.grpc_server:
|
|
97
|
+
logger.info("Stopping gRPC server...")
|
|
98
|
+
self.grpc_server.stop(grace_period)
|
|
99
|
+
|
|
100
|
+
logger.info("All servers stopped")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def run_server_async():
|
|
104
|
+
"""Async entry point for running the collector server."""
|
|
105
|
+
server = CollectorServer()
|
|
106
|
+
|
|
107
|
+
# Setup signal handlers
|
|
108
|
+
loop = asyncio.get_running_loop()
|
|
109
|
+
|
|
110
|
+
def handle_shutdown(signum):
|
|
111
|
+
logger.info(f"Received signal {signum}, shutting down...")
|
|
112
|
+
asyncio.create_task(server.stop())
|
|
113
|
+
|
|
114
|
+
# Register signal handlers
|
|
115
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
116
|
+
loop.add_signal_handler(sig, lambda s=sig: handle_shutdown(s))
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
await server.start()
|
|
120
|
+
except KeyboardInterrupt:
|
|
121
|
+
await server.stop()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def run_server():
|
|
125
|
+
"""Entry point for running the collector server (blocks until shutdown)."""
|
|
126
|
+
asyncio.run(run_server_async())
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from loguru import logger
|
|
2
|
+
from opentelemetry.proto.collector.trace.v1.trace_service_pb2_grpc import TraceServiceServicer
|
|
3
|
+
from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
|
|
4
|
+
ExportTraceServiceResponse,
|
|
5
|
+
ExportTracePartialSuccess,
|
|
6
|
+
)
|
|
7
|
+
from .writer import SpanWriter
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TraceCollectorServicer(TraceServiceServicer):
|
|
11
|
+
"""Implements the OTLP TraceService gRPC interface."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, span_writer: SpanWriter):
|
|
14
|
+
self.span_writer = span_writer
|
|
15
|
+
|
|
16
|
+
def Export(self, request, context):
|
|
17
|
+
"""Handle incoming trace export requests."""
|
|
18
|
+
try:
|
|
19
|
+
span_count = 0
|
|
20
|
+
span_names = []
|
|
21
|
+
for resource_spans in request.resource_spans:
|
|
22
|
+
for scope_spans in resource_spans.scope_spans:
|
|
23
|
+
for span in scope_spans.spans:
|
|
24
|
+
self.span_writer.write_span(span, resource_spans, scope_spans)
|
|
25
|
+
span_names.append(span.name)
|
|
26
|
+
span_count += 1
|
|
27
|
+
|
|
28
|
+
logger.info(f"Successfully processed {span_count} spans={span_names}")
|
|
29
|
+
return ExportTraceServiceResponse(
|
|
30
|
+
partial_success=ExportTracePartialSuccess(
|
|
31
|
+
rejected_spans=0,
|
|
32
|
+
error_message=""
|
|
33
|
+
)
|
|
34
|
+
)
|
|
35
|
+
except Exception as e:
|
|
36
|
+
logger.error(f"Error processing spans: {e}", exc_info=True)
|
|
37
|
+
# Continue processing - return partial success
|
|
38
|
+
return ExportTraceServiceResponse(
|
|
39
|
+
partial_success=ExportTracePartialSuccess(
|
|
40
|
+
rejected_spans=0, # Could track actual failures
|
|
41
|
+
error_message=str(e)
|
|
42
|
+
)
|
|
43
|
+
)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from loguru import logger
|
|
4
|
+
from google.protobuf.json_format import MessageToDict
|
|
5
|
+
from finchvox.collector.config import get_trace_dir
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SpanWriter:
|
|
9
|
+
"""Handles writing spans to JSONL files organized by trace_id."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, data_dir: Path):
|
|
12
|
+
"""
|
|
13
|
+
Initialize SpanWriter.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
data_dir: Base data directory (e.g., ~/.finchvox)
|
|
17
|
+
"""
|
|
18
|
+
self.data_dir = data_dir
|
|
19
|
+
|
|
20
|
+
def write_span(self, span, resource_spans, scope_spans):
|
|
21
|
+
"""Write a single span to its trace-specific JSONL file."""
|
|
22
|
+
try:
|
|
23
|
+
# Extract trace_id as hex string
|
|
24
|
+
trace_id_hex = span.trace_id.hex()
|
|
25
|
+
|
|
26
|
+
# Get trace-specific directory
|
|
27
|
+
trace_dir = get_trace_dir(self.data_dir, trace_id_hex)
|
|
28
|
+
trace_dir.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
|
|
30
|
+
# Convert protobuf to dict for JSON serialization
|
|
31
|
+
span_dict = self._convert_span_to_dict(span, resource_spans, scope_spans)
|
|
32
|
+
|
|
33
|
+
# Write to trace file inside trace directory
|
|
34
|
+
trace_file = trace_dir / f"trace_{trace_id_hex}.jsonl"
|
|
35
|
+
|
|
36
|
+
# Check if this is a new trace or existing trace
|
|
37
|
+
is_new_trace = not trace_file.exists()
|
|
38
|
+
|
|
39
|
+
if is_new_trace:
|
|
40
|
+
# Log span type for new traces
|
|
41
|
+
span_name = span.name if span.name else "UNKNOWN"
|
|
42
|
+
logger.info(f"New trace {trace_id_hex[:8]}... - first span type: {span_name}")
|
|
43
|
+
else:
|
|
44
|
+
# Count existing spans in the trace
|
|
45
|
+
with trace_file.open('r') as f:
|
|
46
|
+
span_count = sum(1 for _ in f)
|
|
47
|
+
logger.info(f"Trace {trace_id_hex[:8]}... - adding span #{span_count + 1}")
|
|
48
|
+
|
|
49
|
+
with trace_file.open('a') as f:
|
|
50
|
+
json.dump(span_dict, f)
|
|
51
|
+
f.write('\n')
|
|
52
|
+
|
|
53
|
+
logger.debug(f"Wrote span {span.span_id.hex()} to {trace_file}")
|
|
54
|
+
except Exception as e:
|
|
55
|
+
logger.error(f"Failed to write span: {e}", exc_info=True)
|
|
56
|
+
|
|
57
|
+
def _convert_span_to_dict(self, span, resource_spans, scope_spans):
|
|
58
|
+
"""Convert protobuf span to dictionary, preserving all fields."""
|
|
59
|
+
# Use MessageToDict for automatic conversion
|
|
60
|
+
span_data = MessageToDict(
|
|
61
|
+
span,
|
|
62
|
+
preserving_proto_field_name=True
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Note: MessageToDict converts bytes to base64 by default
|
|
66
|
+
# We'll enhance with hex representation for trace_id/span_id
|
|
67
|
+
span_data['trace_id_hex'] = span.trace_id.hex()
|
|
68
|
+
span_data['span_id_hex'] = span.span_id.hex()
|
|
69
|
+
if span.parent_span_id:
|
|
70
|
+
span_data['parent_span_id_hex'] = span.parent_span_id.hex()
|
|
71
|
+
|
|
72
|
+
# Include resource attributes for context
|
|
73
|
+
if resource_spans.resource:
|
|
74
|
+
span_data['resource'] = MessageToDict(
|
|
75
|
+
resource_spans.resource,
|
|
76
|
+
preserving_proto_field_name=True
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Include instrumentation scope
|
|
80
|
+
if scope_spans.scope:
|
|
81
|
+
span_data['instrumentation_scope'] = MessageToDict(
|
|
82
|
+
scope_spans.scope,
|
|
83
|
+
preserving_proto_field_name=True
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return span_data
|