dory-processor-sdk 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.
- dory/__init__.py +101 -0
- dory/auth/__init__.py +10 -0
- dory/auth/oauth2.py +153 -0
- dory/auto_instrument.py +142 -0
- dory/cli/__init__.py +5 -0
- dory/cli/main.py +137 -0
- dory/cli/templates.py +123 -0
- dory/config/__init__.py +23 -0
- dory/config/defaults.py +24 -0
- dory/config/loader.py +430 -0
- dory/config/presets.py +73 -0
- dory/config/schema.py +84 -0
- dory/core/__init__.py +27 -0
- dory/core/app.py +434 -0
- dory/core/context.py +209 -0
- dory/core/lifecycle.py +214 -0
- dory/core/meta.py +121 -0
- dory/core/modes.py +479 -0
- dory/core/processor.py +564 -0
- dory/core/signals.py +122 -0
- dory/decorators.py +142 -0
- dory/edge/__init__.py +88 -0
- dory/edge/adaptive.py +644 -0
- dory/edge/detector.py +546 -0
- dory/edge/fencing.py +488 -0
- dory/edge/heartbeat.py +598 -0
- dory/edge/role.py +419 -0
- dory/errors/__init__.py +139 -0
- dory/errors/classification.py +362 -0
- dory/errors/codes.py +498 -0
- dory/geo/__init__.py +40 -0
- dory/geo/geolocalizer.py +1034 -0
- dory/health/__init__.py +12 -0
- dory/health/probes.py +210 -0
- dory/health/server.py +635 -0
- dory/k8s/__init__.py +80 -0
- dory/k8s/annotation_watcher.py +184 -0
- dory/k8s/client.py +251 -0
- dory/k8s/labels.py +505 -0
- dory/k8s/pod_metadata.py +182 -0
- dory/logging/__init__.py +9 -0
- dory/logging/logger.py +148 -0
- dory/metrics/__init__.py +7 -0
- dory/metrics/collector.py +301 -0
- dory/middleware/__init__.py +46 -0
- dory/middleware/connection_tracker.py +608 -0
- dory/middleware/request_id.py +325 -0
- dory/middleware/request_tracker.py +511 -0
- dory/migration/__init__.py +33 -0
- dory/migration/configmap.py +232 -0
- dory/migration/s3_store.py +594 -0
- dory/migration/serialization.py +135 -0
- dory/migration/state_manager.py +286 -0
- dory/migration/transfer.py +382 -0
- dory/monitoring/__init__.py +29 -0
- dory/monitoring/opentelemetry.py +489 -0
- dory/output/__init__.py +31 -0
- dory/output/envelope.py +137 -0
- dory/output/formatter.py +113 -0
- dory/output/rabbitmq.py +632 -0
- dory/output/routing.py +318 -0
- dory/output/validator.py +199 -0
- dory/py.typed +2 -0
- dory/recovery/__init__.py +60 -0
- dory/recovery/golden_image.py +487 -0
- dory/recovery/golden_snapshot.py +713 -0
- dory/recovery/golden_validator.py +518 -0
- dory/recovery/partial_recovery.py +482 -0
- dory/recovery/recovery_decision.py +242 -0
- dory/recovery/restart_detector.py +142 -0
- dory/recovery/state_validator.py +183 -0
- dory/resilience/__init__.py +45 -0
- dory/resilience/circuit_breaker.py +457 -0
- dory/resilience/retry.py +389 -0
- dory/simple.py +342 -0
- dory/types.py +68 -0
- dory/utils/__init__.py +31 -0
- dory/utils/errors.py +59 -0
- dory/utils/retry.py +115 -0
- dory/utils/timeout.py +80 -0
- dory_processor_sdk-0.0.1.dist-info/METADATA +424 -0
- dory_processor_sdk-0.0.1.dist-info/RECORD +86 -0
- dory_processor_sdk-0.0.1.dist-info/WHEEL +5 -0
- dory_processor_sdk-0.0.1.dist-info/entry_points.txt +2 -0
- dory_processor_sdk-0.0.1.dist-info/licenses/LICENSE +201 -0
- dory_processor_sdk-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenTelemetry Integration
|
|
3
|
+
|
|
4
|
+
Provides full OpenTelemetry support for distributed tracing and metrics.
|
|
5
|
+
|
|
6
|
+
Features:
|
|
7
|
+
- Automatic span creation
|
|
8
|
+
- Trace propagation
|
|
9
|
+
- Metrics collection
|
|
10
|
+
- Context management
|
|
11
|
+
- Integration with request IDs
|
|
12
|
+
|
|
13
|
+
Note: Requires opentelemetry packages:
|
|
14
|
+
pip install opentelemetry-api opentelemetry-sdk
|
|
15
|
+
pip install opentelemetry-exporter-otlp
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
from contextlib import contextmanager
|
|
22
|
+
from functools import wraps
|
|
23
|
+
from typing import Any, Dict, Optional, Callable
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
# Try to import OpenTelemetry (optional dependency)
|
|
28
|
+
try:
|
|
29
|
+
from opentelemetry import trace
|
|
30
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
31
|
+
from opentelemetry.sdk.trace.export import (
|
|
32
|
+
BatchSpanProcessor,
|
|
33
|
+
ConsoleSpanExporter,
|
|
34
|
+
)
|
|
35
|
+
from opentelemetry.sdk.resources import Resource, SERVICE_NAME
|
|
36
|
+
from opentelemetry.trace import Status, StatusCode, SpanKind
|
|
37
|
+
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
|
|
38
|
+
|
|
39
|
+
OTEL_AVAILABLE = True
|
|
40
|
+
except ImportError:
|
|
41
|
+
OTEL_AVAILABLE = False
|
|
42
|
+
# Create stub types for when OpenTelemetry is not available
|
|
43
|
+
# This allows the module to be imported without OpenTelemetry installed
|
|
44
|
+
from enum import Enum
|
|
45
|
+
|
|
46
|
+
class SpanKind(Enum):
|
|
47
|
+
"""Stub SpanKind enum when OpenTelemetry is not installed."""
|
|
48
|
+
INTERNAL = 0
|
|
49
|
+
SERVER = 1
|
|
50
|
+
CLIENT = 2
|
|
51
|
+
PRODUCER = 3
|
|
52
|
+
CONSUMER = 4
|
|
53
|
+
|
|
54
|
+
trace = None
|
|
55
|
+
TracerProvider = None
|
|
56
|
+
|
|
57
|
+
logger.warning(
|
|
58
|
+
"OpenTelemetry not available. Install with: "
|
|
59
|
+
"pip install opentelemetry-api opentelemetry-sdk opentelemetry-exporter-otlp"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class OpenTelemetryManager:
|
|
64
|
+
"""
|
|
65
|
+
Manages OpenTelemetry tracing and metrics.
|
|
66
|
+
|
|
67
|
+
Features:
|
|
68
|
+
- Automatic tracer setup
|
|
69
|
+
- Span creation and management
|
|
70
|
+
- Context propagation
|
|
71
|
+
- Metrics collection
|
|
72
|
+
- Export to OTLP endpoints
|
|
73
|
+
|
|
74
|
+
Usage:
|
|
75
|
+
# Initialize
|
|
76
|
+
otel = OpenTelemetryManager(service_name="my-service")
|
|
77
|
+
otel.initialize()
|
|
78
|
+
|
|
79
|
+
# Use decorator
|
|
80
|
+
@otel.trace("process_item")
|
|
81
|
+
async def process_item(item):
|
|
82
|
+
# Automatically traced
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
# Or context manager
|
|
86
|
+
with otel.create_span("operation"):
|
|
87
|
+
# Operations here are traced
|
|
88
|
+
pass
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
service_name: str = "dory-processor",
|
|
94
|
+
service_version: str = "1.0.0",
|
|
95
|
+
environment: str = "production",
|
|
96
|
+
otlp_endpoint: Optional[str] = None,
|
|
97
|
+
console_export: bool = False,
|
|
98
|
+
enable_metrics: bool = True,
|
|
99
|
+
):
|
|
100
|
+
"""
|
|
101
|
+
Initialize OpenTelemetry manager.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
service_name: Name of the service
|
|
105
|
+
service_version: Version of the service
|
|
106
|
+
environment: Environment (production, staging, dev)
|
|
107
|
+
otlp_endpoint: Optional OTLP endpoint URL
|
|
108
|
+
console_export: Export spans to console (for debugging)
|
|
109
|
+
enable_metrics: Enable metrics collection
|
|
110
|
+
"""
|
|
111
|
+
if not OTEL_AVAILABLE:
|
|
112
|
+
raise ImportError(
|
|
113
|
+
"OpenTelemetry not available. Install with: "
|
|
114
|
+
"pip install opentelemetry-api opentelemetry-sdk"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
self.service_name = service_name
|
|
118
|
+
self.service_version = service_version
|
|
119
|
+
self.environment = environment
|
|
120
|
+
self.otlp_endpoint = otlp_endpoint
|
|
121
|
+
self.console_export = console_export
|
|
122
|
+
self.enable_metrics = enable_metrics
|
|
123
|
+
|
|
124
|
+
self._tracer_provider: Optional[TracerProvider] = None
|
|
125
|
+
self._tracer: Optional[trace.Tracer] = None
|
|
126
|
+
self._initialized = False
|
|
127
|
+
|
|
128
|
+
logger.info(
|
|
129
|
+
f"OpenTelemetryManager created: service={service_name}, "
|
|
130
|
+
f"version={service_version}, env={environment}"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def initialize(self) -> None:
|
|
134
|
+
"""
|
|
135
|
+
Initialize OpenTelemetry.
|
|
136
|
+
|
|
137
|
+
Sets up tracer provider, exporters, and processors.
|
|
138
|
+
"""
|
|
139
|
+
if self._initialized:
|
|
140
|
+
logger.warning("OpenTelemetry already initialized")
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
# Create resource
|
|
144
|
+
resource = Resource.create({
|
|
145
|
+
SERVICE_NAME: self.service_name,
|
|
146
|
+
"service.version": self.service_version,
|
|
147
|
+
"deployment.environment": self.environment,
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
# Create tracer provider
|
|
151
|
+
self._tracer_provider = TracerProvider(resource=resource)
|
|
152
|
+
|
|
153
|
+
# Add console exporter if enabled
|
|
154
|
+
if self.console_export:
|
|
155
|
+
console_exporter = ConsoleSpanExporter()
|
|
156
|
+
console_processor = BatchSpanProcessor(console_exporter)
|
|
157
|
+
self._tracer_provider.add_span_processor(console_processor)
|
|
158
|
+
logger.info("Console span exporter added")
|
|
159
|
+
|
|
160
|
+
# Add OTLP exporter if endpoint provided
|
|
161
|
+
if self.otlp_endpoint:
|
|
162
|
+
try:
|
|
163
|
+
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
|
164
|
+
|
|
165
|
+
otlp_exporter = OTLPSpanExporter(endpoint=self.otlp_endpoint)
|
|
166
|
+
otlp_processor = BatchSpanProcessor(otlp_exporter)
|
|
167
|
+
self._tracer_provider.add_span_processor(otlp_processor)
|
|
168
|
+
logger.info(f"OTLP span exporter added: {self.otlp_endpoint}")
|
|
169
|
+
except ImportError:
|
|
170
|
+
logger.warning(
|
|
171
|
+
"OTLP exporter not available. Install with: "
|
|
172
|
+
"pip install opentelemetry-exporter-otlp"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# Set global tracer provider
|
|
176
|
+
trace.set_tracer_provider(self._tracer_provider)
|
|
177
|
+
|
|
178
|
+
# Get tracer
|
|
179
|
+
self._tracer = trace.get_tracer(
|
|
180
|
+
instrumenting_module_name=__name__,
|
|
181
|
+
instrumenting_library_version=self.service_version,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
self._initialized = True
|
|
185
|
+
logger.info("OpenTelemetry initialized successfully")
|
|
186
|
+
|
|
187
|
+
def get_tracer(self) -> trace.Tracer:
|
|
188
|
+
"""
|
|
189
|
+
Get the tracer instance.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
OpenTelemetry tracer
|
|
193
|
+
"""
|
|
194
|
+
if not self._initialized:
|
|
195
|
+
self.initialize()
|
|
196
|
+
return self._tracer
|
|
197
|
+
|
|
198
|
+
@contextmanager
|
|
199
|
+
def create_span(
|
|
200
|
+
self,
|
|
201
|
+
name: str,
|
|
202
|
+
attributes: Optional[Dict[str, Any]] = None,
|
|
203
|
+
kind: SpanKind = SpanKind.INTERNAL,
|
|
204
|
+
):
|
|
205
|
+
"""
|
|
206
|
+
Create a span context manager.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
name: Span name
|
|
210
|
+
attributes: Optional span attributes
|
|
211
|
+
kind: Span kind (INTERNAL, SERVER, CLIENT, etc.)
|
|
212
|
+
|
|
213
|
+
Example:
|
|
214
|
+
with otel.create_span("database_query", {"query": "SELECT ...")):
|
|
215
|
+
result = await db.execute(query)
|
|
216
|
+
"""
|
|
217
|
+
if not self._initialized:
|
|
218
|
+
# If not initialized, just yield without tracing
|
|
219
|
+
yield None
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
tracer = self.get_tracer()
|
|
223
|
+
|
|
224
|
+
with tracer.start_as_current_span(name, kind=kind) as span:
|
|
225
|
+
# Add attributes
|
|
226
|
+
if attributes:
|
|
227
|
+
for key, value in attributes.items():
|
|
228
|
+
span.set_attribute(key, value)
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
yield span
|
|
232
|
+
except Exception as e:
|
|
233
|
+
# Record exception
|
|
234
|
+
span.record_exception(e)
|
|
235
|
+
span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
236
|
+
raise
|
|
237
|
+
else:
|
|
238
|
+
span.set_status(Status(StatusCode.OK))
|
|
239
|
+
|
|
240
|
+
def trace(
|
|
241
|
+
self,
|
|
242
|
+
name: Optional[str] = None,
|
|
243
|
+
attributes: Optional[Dict[str, Any]] = None,
|
|
244
|
+
kind: SpanKind = SpanKind.INTERNAL,
|
|
245
|
+
):
|
|
246
|
+
"""
|
|
247
|
+
Decorator to automatically trace a function.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
name: Span name (uses function name if None)
|
|
251
|
+
attributes: Optional span attributes
|
|
252
|
+
kind: Span kind
|
|
253
|
+
|
|
254
|
+
Example:
|
|
255
|
+
@otel.trace("process_item")
|
|
256
|
+
async def process_item(item):
|
|
257
|
+
# Automatically traced
|
|
258
|
+
pass
|
|
259
|
+
"""
|
|
260
|
+
def decorator(func):
|
|
261
|
+
span_name = name or func.__name__
|
|
262
|
+
|
|
263
|
+
@wraps(func)
|
|
264
|
+
async def async_wrapper(*args, **kwargs):
|
|
265
|
+
with self.create_span(span_name, attributes, kind):
|
|
266
|
+
return await func(*args, **kwargs)
|
|
267
|
+
|
|
268
|
+
@wraps(func)
|
|
269
|
+
def sync_wrapper(*args, **kwargs):
|
|
270
|
+
with self.create_span(span_name, attributes, kind):
|
|
271
|
+
return func(*args, **kwargs)
|
|
272
|
+
|
|
273
|
+
# Return appropriate wrapper
|
|
274
|
+
import asyncio
|
|
275
|
+
if asyncio.iscoroutinefunction(func):
|
|
276
|
+
return async_wrapper
|
|
277
|
+
else:
|
|
278
|
+
return sync_wrapper
|
|
279
|
+
|
|
280
|
+
return decorator
|
|
281
|
+
|
|
282
|
+
def add_span_attributes(self, attributes: Dict[str, Any]) -> None:
|
|
283
|
+
"""
|
|
284
|
+
Add attributes to current span.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
attributes: Attributes to add
|
|
288
|
+
"""
|
|
289
|
+
if not self._initialized:
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
span = trace.get_current_span()
|
|
293
|
+
if span:
|
|
294
|
+
for key, value in attributes.items():
|
|
295
|
+
span.set_attribute(key, value)
|
|
296
|
+
|
|
297
|
+
def record_exception(self, exception: Exception) -> None:
|
|
298
|
+
"""
|
|
299
|
+
Record an exception in current span.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
exception: Exception to record
|
|
303
|
+
"""
|
|
304
|
+
if not self._initialized:
|
|
305
|
+
return
|
|
306
|
+
|
|
307
|
+
span = trace.get_current_span()
|
|
308
|
+
if span:
|
|
309
|
+
span.record_exception(exception)
|
|
310
|
+
span.set_status(Status(StatusCode.ERROR, str(exception)))
|
|
311
|
+
|
|
312
|
+
def inject_context(self, headers: Dict[str, str]) -> Dict[str, str]:
|
|
313
|
+
"""
|
|
314
|
+
Inject trace context into headers for propagation.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
headers: HTTP headers or similar dict
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
Headers with trace context injected
|
|
321
|
+
"""
|
|
322
|
+
if not self._initialized:
|
|
323
|
+
return headers
|
|
324
|
+
|
|
325
|
+
propagator = TraceContextTextMapPropagator()
|
|
326
|
+
headers = dict(headers)
|
|
327
|
+
propagator.inject(headers)
|
|
328
|
+
return headers
|
|
329
|
+
|
|
330
|
+
def extract_context(self, headers: Dict[str, str]) -> None:
|
|
331
|
+
"""
|
|
332
|
+
Extract trace context from headers.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
headers: HTTP headers or similar dict
|
|
336
|
+
"""
|
|
337
|
+
if not self._initialized:
|
|
338
|
+
return
|
|
339
|
+
|
|
340
|
+
propagator = TraceContextTextMapPropagator()
|
|
341
|
+
context = propagator.extract(headers)
|
|
342
|
+
# Context is automatically set as current
|
|
343
|
+
|
|
344
|
+
def shutdown(self) -> None:
|
|
345
|
+
"""Shutdown OpenTelemetry and flush spans."""
|
|
346
|
+
if self._tracer_provider:
|
|
347
|
+
self._tracer_provider.shutdown()
|
|
348
|
+
logger.info("OpenTelemetry shut down")
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# Global instance
|
|
352
|
+
_global_otel: Optional[OpenTelemetryManager] = None
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def get_global_otel() -> Optional[OpenTelemetryManager]:
|
|
356
|
+
"""Get global OpenTelemetry manager."""
|
|
357
|
+
return _global_otel
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def initialize_otel(
|
|
361
|
+
service_name: str = "dory-processor",
|
|
362
|
+
**kwargs
|
|
363
|
+
) -> OpenTelemetryManager:
|
|
364
|
+
"""
|
|
365
|
+
Initialize global OpenTelemetry manager.
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
service_name: Service name
|
|
369
|
+
**kwargs: Additional arguments for OpenTelemetryManager
|
|
370
|
+
|
|
371
|
+
Returns:
|
|
372
|
+
Initialized OpenTelemetryManager
|
|
373
|
+
"""
|
|
374
|
+
global _global_otel
|
|
375
|
+
|
|
376
|
+
if _global_otel:
|
|
377
|
+
logger.warning("Global OpenTelemetry already initialized")
|
|
378
|
+
return _global_otel
|
|
379
|
+
|
|
380
|
+
_global_otel = OpenTelemetryManager(service_name=service_name, **kwargs)
|
|
381
|
+
_global_otel.initialize()
|
|
382
|
+
|
|
383
|
+
logger.info(f"Global OpenTelemetry initialized for service: {service_name}")
|
|
384
|
+
return _global_otel
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
# Convenience functions using global instance
|
|
388
|
+
|
|
389
|
+
def get_tracer() -> Optional[trace.Tracer]:
|
|
390
|
+
"""Get tracer from global instance."""
|
|
391
|
+
otel = get_global_otel()
|
|
392
|
+
return otel.get_tracer() if otel else None
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def create_span(
|
|
396
|
+
name: str,
|
|
397
|
+
attributes: Optional[Dict[str, Any]] = None,
|
|
398
|
+
kind: Optional[SpanKind] = None,
|
|
399
|
+
):
|
|
400
|
+
"""Create span using global instance."""
|
|
401
|
+
otel = get_global_otel()
|
|
402
|
+
if otel:
|
|
403
|
+
# Default to INTERNAL kind when OpenTelemetry is available
|
|
404
|
+
if kind is None and OTEL_AVAILABLE:
|
|
405
|
+
kind = SpanKind.INTERNAL
|
|
406
|
+
return otel.create_span(name, attributes, kind)
|
|
407
|
+
else:
|
|
408
|
+
# Return no-op context manager
|
|
409
|
+
@contextmanager
|
|
410
|
+
def noop():
|
|
411
|
+
yield None
|
|
412
|
+
return noop()
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def trace_function(
|
|
416
|
+
name: Optional[str] = None,
|
|
417
|
+
attributes: Optional[Dict[str, Any]] = None,
|
|
418
|
+
kind: Optional[SpanKind] = None,
|
|
419
|
+
):
|
|
420
|
+
"""Trace function using global instance."""
|
|
421
|
+
otel = get_global_otel()
|
|
422
|
+
if otel:
|
|
423
|
+
# Default to INTERNAL kind when OpenTelemetry is available
|
|
424
|
+
if kind is None and OTEL_AVAILABLE:
|
|
425
|
+
kind = SpanKind.INTERNAL
|
|
426
|
+
return otel.trace(name, attributes, kind)
|
|
427
|
+
else:
|
|
428
|
+
# Return no-op decorator
|
|
429
|
+
def decorator(func):
|
|
430
|
+
return func
|
|
431
|
+
return decorator
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def add_span_attributes(attributes: Dict[str, Any]) -> None:
|
|
435
|
+
"""Add attributes to current span using global instance."""
|
|
436
|
+
otel = get_global_otel()
|
|
437
|
+
if otel:
|
|
438
|
+
otel.add_span_attributes(attributes)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def record_exception(exception: Exception) -> None:
|
|
442
|
+
"""Record exception in current span using global instance."""
|
|
443
|
+
otel = get_global_otel()
|
|
444
|
+
if otel:
|
|
445
|
+
otel.record_exception(exception)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
# Integration with request IDs
|
|
449
|
+
|
|
450
|
+
def integrate_with_request_id():
|
|
451
|
+
"""
|
|
452
|
+
Integrate OpenTelemetry with request ID middleware.
|
|
453
|
+
|
|
454
|
+
Automatically adds request IDs as span attributes.
|
|
455
|
+
"""
|
|
456
|
+
try:
|
|
457
|
+
from dory.middleware.request_id import get_current_request_id
|
|
458
|
+
|
|
459
|
+
otel = get_global_otel()
|
|
460
|
+
if not otel:
|
|
461
|
+
logger.warning("OpenTelemetry not initialized")
|
|
462
|
+
return
|
|
463
|
+
|
|
464
|
+
# Monkey patch create_span to add request ID
|
|
465
|
+
original_create_span = otel.create_span
|
|
466
|
+
|
|
467
|
+
@contextmanager
|
|
468
|
+
def create_span_with_request_id(name, attributes=None, kind=None):
|
|
469
|
+
# Get current request ID
|
|
470
|
+
request_id = get_current_request_id()
|
|
471
|
+
|
|
472
|
+
# Default to INTERNAL kind
|
|
473
|
+
if kind is None:
|
|
474
|
+
kind = SpanKind.INTERNAL
|
|
475
|
+
|
|
476
|
+
# Add to attributes
|
|
477
|
+
attrs = attributes or {}
|
|
478
|
+
if request_id:
|
|
479
|
+
attrs["request.id"] = request_id
|
|
480
|
+
|
|
481
|
+
with original_create_span(name, attrs, kind) as span:
|
|
482
|
+
yield span
|
|
483
|
+
|
|
484
|
+
otel.create_span = create_span_with_request_id
|
|
485
|
+
|
|
486
|
+
logger.info("OpenTelemetry integrated with request ID middleware")
|
|
487
|
+
|
|
488
|
+
except ImportError:
|
|
489
|
+
logger.warning("Request ID middleware not available for integration")
|
dory/output/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Output formatting and publishing for processor results."""
|
|
2
|
+
|
|
3
|
+
from dory.output.formatter import OutputFormatter, JSONFormatter
|
|
4
|
+
from dory.output.routing import build_routing_key, get_geohash
|
|
5
|
+
from dory.output.envelope import (
|
|
6
|
+
MessageEnvelope,
|
|
7
|
+
EnvelopeFormatter,
|
|
8
|
+
ENVELOPE_SCHEMA_VERSION,
|
|
9
|
+
)
|
|
10
|
+
from dory.output.validator import (
|
|
11
|
+
EnvelopeValidator,
|
|
12
|
+
UnsupportedVersionError,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
# RabbitMQ requires optional dependency
|
|
16
|
+
try:
|
|
17
|
+
from dory.output.rabbitmq import RabbitMQPublisher, PublisherConfig, PublishError
|
|
18
|
+
except ImportError:
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"OutputFormatter",
|
|
23
|
+
"JSONFormatter",
|
|
24
|
+
"MessageEnvelope",
|
|
25
|
+
"EnvelopeFormatter",
|
|
26
|
+
"EnvelopeValidator",
|
|
27
|
+
"UnsupportedVersionError",
|
|
28
|
+
"ENVELOPE_SCHEMA_VERSION",
|
|
29
|
+
"build_routing_key",
|
|
30
|
+
"get_geohash",
|
|
31
|
+
]
|
dory/output/envelope.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MessageEnvelope for unified publisher/subscriber contract.
|
|
3
|
+
|
|
4
|
+
Provides a versioned envelope that wraps payload data with metadata,
|
|
5
|
+
enabling schema evolution between publisher and subscriber SDKs.
|
|
6
|
+
|
|
7
|
+
The envelope contains:
|
|
8
|
+
- schema_version: MAJOR.MINOR version string
|
|
9
|
+
- message_id: Unique UUID for each message
|
|
10
|
+
- timestamp: ISO 8601 creation timestamp
|
|
11
|
+
- payload: The actual business data
|
|
12
|
+
|
|
13
|
+
Schema versioning rules:
|
|
14
|
+
- MINOR bump (0.1 -> 0.2): Additive change (new optional fields only)
|
|
15
|
+
- MAJOR bump (0.1 -> 1.0): Breaking change (fields removed, types changed)
|
|
16
|
+
- Subscribers should use major version matching for backward compatibility
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
from dataclasses import dataclass, field, asdict
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from typing import Any
|
|
23
|
+
from uuid import uuid4
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
# Current envelope schema version
|
|
28
|
+
ENVELOPE_SCHEMA_VERSION = "0.1"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# =========================================================================
|
|
32
|
+
# MessageEnvelope
|
|
33
|
+
# =========================================================================
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class MessageEnvelope:
|
|
37
|
+
"""Versioned envelope wrapping payload data.
|
|
38
|
+
|
|
39
|
+
Attributes:
|
|
40
|
+
schema_version: Envelope schema version (MAJOR.MINOR).
|
|
41
|
+
message_id: Unique message identifier.
|
|
42
|
+
timestamp: ISO 8601 timestamp of message creation.
|
|
43
|
+
payload: The actual message data.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
schema_version: str = ENVELOPE_SCHEMA_VERSION
|
|
47
|
+
message_id: str = field(default_factory=lambda: str(uuid4()))
|
|
48
|
+
timestamp: str = field(
|
|
49
|
+
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
|
50
|
+
)
|
|
51
|
+
payload: Any = field(default_factory=dict)
|
|
52
|
+
|
|
53
|
+
def to_dict(self) -> dict[str, Any]:
|
|
54
|
+
"""Convert envelope to a plain dictionary."""
|
|
55
|
+
return asdict(self)
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_dict(cls, data: dict[str, Any]) -> "MessageEnvelope":
|
|
59
|
+
"""Reconstruct an envelope from a dictionary.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
data: Dictionary with envelope fields.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
MessageEnvelope instance.
|
|
66
|
+
"""
|
|
67
|
+
return cls(
|
|
68
|
+
schema_version=data.get("schema_version", ENVELOPE_SCHEMA_VERSION),
|
|
69
|
+
message_id=data.get("message_id", str(uuid4())),
|
|
70
|
+
timestamp=data.get("timestamp", ""),
|
|
71
|
+
payload=data.get("payload", {}),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# =========================================================================
|
|
76
|
+
# EnvelopeFormatter
|
|
77
|
+
# =========================================================================
|
|
78
|
+
|
|
79
|
+
class EnvelopeFormatter:
|
|
80
|
+
"""Wraps payload data in a MessageEnvelope before formatting.
|
|
81
|
+
|
|
82
|
+
Composes with an existing OutputFormatter: the EnvelopeFormatter builds
|
|
83
|
+
the envelope dict, then delegates serialization to the inner formatter.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
formatter: Inner OutputFormatter for serialization (defaults to JSONFormatter).
|
|
87
|
+
schema_version: Envelope schema version to use.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
formatter: Any = None,
|
|
93
|
+
schema_version: str = ENVELOPE_SCHEMA_VERSION,
|
|
94
|
+
**kwargs: Any,
|
|
95
|
+
):
|
|
96
|
+
from dory.output.formatter import JSONFormatter, OutputFormatter
|
|
97
|
+
|
|
98
|
+
self._formatter: OutputFormatter = formatter or JSONFormatter()
|
|
99
|
+
self._schema_version = schema_version
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def content_type(self) -> str:
|
|
103
|
+
return self._formatter.content_type
|
|
104
|
+
|
|
105
|
+
def wrap(
|
|
106
|
+
self,
|
|
107
|
+
data: Any,
|
|
108
|
+
**kwargs: Any,
|
|
109
|
+
) -> MessageEnvelope:
|
|
110
|
+
"""Wrap data in a MessageEnvelope.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
data: Payload data.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
MessageEnvelope with payload.
|
|
117
|
+
"""
|
|
118
|
+
return MessageEnvelope(
|
|
119
|
+
schema_version=self._schema_version,
|
|
120
|
+
payload=data,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def format(
|
|
124
|
+
self,
|
|
125
|
+
data: Any,
|
|
126
|
+
**kwargs: Any,
|
|
127
|
+
) -> bytes:
|
|
128
|
+
"""Wrap data in an envelope and serialize to bytes.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
data: Payload data.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Serialized envelope as bytes.
|
|
135
|
+
"""
|
|
136
|
+
envelope = self.wrap(data)
|
|
137
|
+
return self._formatter.format(envelope.to_dict())
|