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.
@@ -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