obsforge 0.1.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.
Files changed (98) hide show
  1. obsforge/__init__.py +44 -0
  2. obsforge/api/__init__.py +5 -0
  3. obsforge/api/context.py +32 -0
  4. obsforge/api/decorators.py +42 -0
  5. obsforge/api/events.py +25 -0
  6. obsforge/api/logger.py +45 -0
  7. obsforge/config/__init__.py +4 -0
  8. obsforge/config/bootstrap.py +190 -0
  9. obsforge/config/settings.py +176 -0
  10. obsforge/config/state.py +53 -0
  11. obsforge/core/__init__.py +3 -0
  12. obsforge/core/contracts.py +52 -0
  13. obsforge/core/errors.py +10 -0
  14. obsforge/core/models.py +572 -0
  15. obsforge/core/taxonomy.py +29 -0
  16. obsforge/encoding/__init__.py +3 -0
  17. obsforge/encoding/json.py +46 -0
  18. obsforge/encoding/serializers.py +7 -0
  19. obsforge/instrumentation/__init__.py +1 -0
  20. obsforge/instrumentation/db/__init__.py +4 -0
  21. obsforge/instrumentation/db/aiomysql.py +106 -0
  22. obsforge/instrumentation/db/asyncpg.py +107 -0
  23. obsforge/instrumentation/db/django.py +70 -0
  24. obsforge/instrumentation/db/engine.py +324 -0
  25. obsforge/instrumentation/db/pool.py +123 -0
  26. obsforge/instrumentation/db/psycopg.py +168 -0
  27. obsforge/instrumentation/db/sql.py +65 -0
  28. obsforge/instrumentation/db/sqlalchemy.py +161 -0
  29. obsforge/instrumentation/db/state.py +28 -0
  30. obsforge/instrumentation/db/transactions.py +73 -0
  31. obsforge/instrumentation/exceptions/__init__.py +15 -0
  32. obsforge/instrumentation/exceptions/classification.py +108 -0
  33. obsforge/instrumentation/exceptions/dedupe.py +66 -0
  34. obsforge/instrumentation/exceptions/engine.py +373 -0
  35. obsforge/instrumentation/exceptions/sanitization.py +64 -0
  36. obsforge/instrumentation/exceptions/state.py +101 -0
  37. obsforge/instrumentation/http/__init__.py +13 -0
  38. obsforge/instrumentation/http/aiohttp.py +57 -0
  39. obsforge/instrumentation/http/classification.py +98 -0
  40. obsforge/instrumentation/http/dependency.py +92 -0
  41. obsforge/instrumentation/http/engine.py +516 -0
  42. obsforge/instrumentation/http/httpx.py +130 -0
  43. obsforge/instrumentation/http/requests.py +98 -0
  44. obsforge/instrumentation/http/sanitization.py +134 -0
  45. obsforge/instrumentation/http/state.py +24 -0
  46. obsforge/integrations/__init__.py +1 -0
  47. obsforge/integrations/asyncio.py +43 -0
  48. obsforge/integrations/celery/__init__.py +3 -0
  49. obsforge/integrations/celery/signals.py +76 -0
  50. obsforge/integrations/django/__init__.py +3 -0
  51. obsforge/integrations/django/middleware.py +105 -0
  52. obsforge/integrations/django/settings.py +1 -0
  53. obsforge/integrations/django/signals.py +5 -0
  54. obsforge/integrations/drf/__init__.py +3 -0
  55. obsforge/integrations/drf/exception_handler.py +31 -0
  56. obsforge/integrations/fastapi/__init__.py +3 -0
  57. obsforge/integrations/fastapi/dependencies.py +7 -0
  58. obsforge/integrations/fastapi/exception_handlers.py +29 -0
  59. obsforge/integrations/fastapi/middleware.py +142 -0
  60. obsforge/integrations/kafka.py +27 -0
  61. obsforge/integrations/logging_bridge.py +122 -0
  62. obsforge/integrations/rabbitmq.py +29 -0
  63. obsforge/integrations/workers.py +60 -0
  64. obsforge/plugins/__init__.py +4 -0
  65. obsforge/plugins/builtin.py +24 -0
  66. obsforge/plugins/manager.py +23 -0
  67. obsforge/plugins/registry.py +35 -0
  68. obsforge/plugins/spec.py +27 -0
  69. obsforge/propagation/__init__.py +26 -0
  70. obsforge/propagation/baggage.py +42 -0
  71. obsforge/propagation/correlation.py +98 -0
  72. obsforge/propagation/distributed.py +389 -0
  73. obsforge/propagation/state.py +24 -0
  74. obsforge/propagation/tracecontext.py +56 -0
  75. obsforge/py.typed +0 -0
  76. obsforge/runtime/__init__.py +3 -0
  77. obsforge/runtime/pii.py +53 -0
  78. obsforge/runtime/pipeline.py +102 -0
  79. obsforge/runtime/policies/__init__.py +0 -0
  80. obsforge/runtime/policies/loki_policy.py +180 -0
  81. obsforge/runtime/processors.py +78 -0
  82. obsforge/runtime/redaction.py +101 -0
  83. obsforge/runtime/sampling.py +21 -0
  84. obsforge/telemetry/__init__.py +5 -0
  85. obsforge/telemetry/mapping.py +67 -0
  86. obsforge/telemetry/otel_logs.py +96 -0
  87. obsforge/telemetry/otel_traces.py +78 -0
  88. obsforge/testing/__init__.py +3 -0
  89. obsforge/testing/capture.py +11 -0
  90. obsforge/testing/fakes.py +40 -0
  91. obsforge/transport/__init__.py +3 -0
  92. obsforge/transport/sink.py +19 -0
  93. obsforge/transport/stdout.py +20 -0
  94. obsforge-0.1.1.dist-info/METADATA +383 -0
  95. obsforge-0.1.1.dist-info/RECORD +98 -0
  96. obsforge-0.1.1.dist-info/WHEEL +4 -0
  97. obsforge-0.1.1.dist-info/entry_points.txt +2 -0
  98. obsforge-0.1.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,516 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+ from urllib.parse import SplitResult, urlsplit
7
+
8
+ from obsforge.api.events import EventClient
9
+ from obsforge.config.settings import HTTPInstrumentationSettings, ObsforgeSettings
10
+ from obsforge.core.models import (
11
+ BusinessContext,
12
+ CanonicalEvent,
13
+ CorrelationContext,
14
+ DatabaseContext,
15
+ DependencyContext,
16
+ Event,
17
+ EventKind,
18
+ Outcome,
19
+ PerformanceContext,
20
+ RequestContext,
21
+ ResponseContext,
22
+ RetryContext,
23
+ Severity,
24
+ SpanKind,
25
+ TelemetryContext,
26
+ TraceContext,
27
+ )
28
+ from obsforge.instrumentation.exceptions.engine import ExceptionIntelligenceEngine
29
+ from obsforge.instrumentation.http.classification import (
30
+ classify_response,
31
+ merge_anomalies,
32
+ status_class,
33
+ )
34
+ from obsforge.instrumentation.http.dependency import DependencyHealthTracker
35
+ from obsforge.instrumentation.http.sanitization import PayloadSanitizer
36
+ from obsforge.propagation.correlation import CorrelationManager, CorrelationSnapshot
37
+ from obsforge.propagation.distributed import DistributedCorrelationEngine
38
+
39
+
40
+ @dataclass(slots=True)
41
+ class RequestCapture:
42
+ started_at: float
43
+ request: RequestContext
44
+ dependency: DependencyContext | None
45
+ trace: TraceContext
46
+ correlation: CorrelationContext
47
+ baggage: dict[str, str]
48
+ restore_snapshot: CorrelationSnapshot | None = None
49
+ exception_snapshot: Any | None = None
50
+
51
+
52
+ class APIRuntimeEngine:
53
+ def __init__(
54
+ self,
55
+ *,
56
+ event_client: EventClient,
57
+ correlation_manager: CorrelationManager,
58
+ settings: ObsforgeSettings,
59
+ exception_engine: ExceptionIntelligenceEngine | None = None,
60
+ distributed_engine: DistributedCorrelationEngine | None = None,
61
+ ) -> None:
62
+ self._event_client = event_client
63
+ self._correlation_manager = correlation_manager
64
+ self._settings = settings
65
+ self._http_settings: HTTPInstrumentationSettings = settings.http
66
+ self._sanitizer = PayloadSanitizer(settings.http)
67
+ self._dependencies = DependencyHealthTracker(settings.http)
68
+ self._exception_engine = exception_engine
69
+ self._distributed = distributed_engine or DistributedCorrelationEngine(
70
+ correlation_manager=correlation_manager,
71
+ settings=settings,
72
+ )
73
+
74
+ @property
75
+ def settings(self) -> ObsforgeSettings:
76
+ return self._settings
77
+
78
+ def inject_distributed_headers(self, headers: dict[str, str]) -> TraceContext:
79
+ envelope = self._distributed.inject_http_headers(headers)
80
+ return envelope.trace
81
+
82
+ def capture_server_request(
83
+ self,
84
+ *,
85
+ method: str,
86
+ url: str,
87
+ headers: dict[str, str],
88
+ route_template: str | None,
89
+ client_address: str | None,
90
+ request_body: bytes | str | dict[str, object] | None,
91
+ ) -> RequestCapture:
92
+ parsed = urlsplit(url)
93
+ envelope = self._distributed.extract_http_headers(headers)
94
+ correlation = envelope.correlation
95
+ trace = envelope.trace
96
+ request = RequestContext(
97
+ protocol="http",
98
+ method=method.upper(),
99
+ scheme=parsed.scheme or "http",
100
+ host=parsed.hostname,
101
+ server_address=parsed.hostname,
102
+ server_port=parsed.port,
103
+ route_template=route_template,
104
+ path=parsed.path or "/",
105
+ query_redacted=self._sanitizer.sanitize_query(parsed.query),
106
+ client_address=client_address,
107
+ user_agent=headers.get("user-agent"),
108
+ content_type=headers.get("content-type"),
109
+ body_size_bytes=_payload_size(request_body),
110
+ headers_allowlist=self._sanitizer.sanitize_headers(headers),
111
+ payload_summary=self._sanitizer.summarize_payload(
112
+ request_body,
113
+ content_type=headers.get("content-type"),
114
+ ),
115
+ )
116
+ self._bind_correlation_inheritance(correlation)
117
+ if self._exception_engine is not None:
118
+ self._exception_engine.state.bind_request_context(request)
119
+ return RequestCapture(
120
+ started_at=time.perf_counter(),
121
+ request=request,
122
+ dependency=None,
123
+ trace=trace,
124
+ correlation=correlation,
125
+ baggage=envelope.baggage,
126
+ )
127
+
128
+ def capture_client_request(
129
+ self,
130
+ *,
131
+ method: str,
132
+ url: str,
133
+ headers: dict[str, str],
134
+ request_body: bytes | str | dict[str, object] | None,
135
+ timeout_ms: float | None = None,
136
+ destination_service: str | None = None,
137
+ ) -> RequestCapture:
138
+ parsed = urlsplit(url)
139
+ parent_snapshot = self._distributed.snapshot()
140
+ exception_snapshot = None
141
+ if self._exception_engine is not None:
142
+ exception_snapshot = self._exception_engine.snapshot_context()
143
+ envelope = self._distributed.inject_http_headers(headers)
144
+ trace = envelope.trace
145
+ correlation = envelope.correlation
146
+ request = RequestContext(
147
+ protocol="http",
148
+ method=method.upper(),
149
+ scheme=parsed.scheme or "http",
150
+ host=parsed.hostname,
151
+ server_address=parsed.hostname,
152
+ server_port=parsed.port,
153
+ path=parsed.path or "/",
154
+ query_redacted=self._sanitizer.sanitize_query(parsed.query),
155
+ content_type=headers.get("content-type"),
156
+ body_size_bytes=_payload_size(request_body),
157
+ timeout_ms=timeout_ms,
158
+ headers_allowlist=self._sanitizer.sanitize_headers(headers),
159
+ payload_summary=self._sanitizer.summarize_payload(
160
+ request_body,
161
+ content_type=headers.get("content-type"),
162
+ ),
163
+ )
164
+ dependency = self._dependency_from_url(parsed, destination_service=destination_service)
165
+ self._bind_correlation_inheritance(correlation)
166
+ if self._exception_engine is not None:
167
+ self._exception_engine.state.bind_request_context(request)
168
+ self._exception_engine.state.bind_dependency_context(dependency)
169
+ return RequestCapture(
170
+ started_at=time.perf_counter(),
171
+ request=request,
172
+ dependency=dependency,
173
+ trace=trace,
174
+ correlation=correlation,
175
+ baggage=envelope.baggage,
176
+ restore_snapshot=parent_snapshot,
177
+ exception_snapshot=exception_snapshot,
178
+ )
179
+
180
+ async def emit_client_event(
181
+ self,
182
+ capture: RequestCapture,
183
+ *,
184
+ response_status_code: int | None = None,
185
+ response_headers: dict[str, str] | None = None,
186
+ response_body: bytes | str | dict[str, object] | None = None,
187
+ exception: BaseException | None = None,
188
+ retry_attempt: int | None = None,
189
+ max_attempts: int | None = None,
190
+ malformed_response: bool = False,
191
+ ) -> Event:
192
+ event = self._finalize_event(
193
+ capture,
194
+ response_status_code=response_status_code,
195
+ response_headers=response_headers,
196
+ response_body=response_body,
197
+ exception=exception,
198
+ retry_attempt=retry_attempt,
199
+ max_attempts=max_attempts,
200
+ malformed_response=malformed_response,
201
+ source_kind="client",
202
+ )
203
+ try:
204
+ await self._event_client.emit(event)
205
+ return event
206
+ finally:
207
+ self._restore_client_context(capture)
208
+
209
+ def emit_client_event_sync(
210
+ self,
211
+ capture: RequestCapture,
212
+ *,
213
+ response_status_code: int | None = None,
214
+ response_headers: dict[str, str] | None = None,
215
+ response_body: bytes | str | dict[str, object] | None = None,
216
+ exception: BaseException | None = None,
217
+ retry_attempt: int | None = None,
218
+ max_attempts: int | None = None,
219
+ malformed_response: bool = False,
220
+ ) -> Event:
221
+ event = self._finalize_event(
222
+ capture,
223
+ response_status_code=response_status_code,
224
+ response_headers=response_headers,
225
+ response_body=response_body,
226
+ exception=exception,
227
+ retry_attempt=retry_attempt,
228
+ max_attempts=max_attempts,
229
+ malformed_response=malformed_response,
230
+ source_kind="client",
231
+ )
232
+ try:
233
+ self._event_client.emit_sync(event)
234
+ return event
235
+ finally:
236
+ self._restore_client_context(capture)
237
+
238
+ async def emit_server_event(
239
+ self,
240
+ capture: RequestCapture,
241
+ *,
242
+ response_status_code: int | None = None,
243
+ response_headers: dict[str, str] | None = None,
244
+ response_body: bytes | str | dict[str, object] | None = None,
245
+ exception: BaseException | None = None,
246
+ malformed_response: bool = False,
247
+ ) -> Event:
248
+ event = self._finalize_event(
249
+ capture,
250
+ response_status_code=response_status_code,
251
+ response_headers=response_headers,
252
+ response_body=response_body,
253
+ exception=exception,
254
+ malformed_response=malformed_response,
255
+ source_kind="server",
256
+ )
257
+ await self._event_client.emit(event)
258
+ return event
259
+
260
+ def emit_server_event_sync(
261
+ self,
262
+ capture: RequestCapture,
263
+ *,
264
+ response_status_code: int | None = None,
265
+ response_headers: dict[str, str] | None = None,
266
+ response_body: bytes | str | dict[str, object] | None = None,
267
+ exception: BaseException | None = None,
268
+ malformed_response: bool = False,
269
+ ) -> Event:
270
+ event = self._finalize_event(
271
+ capture,
272
+ response_status_code=response_status_code,
273
+ response_headers=response_headers,
274
+ response_body=response_body,
275
+ exception=exception,
276
+ malformed_response=malformed_response,
277
+ source_kind="server",
278
+ )
279
+ self._event_client.emit_sync(event)
280
+ return event
281
+
282
+ def clear_context(self) -> None:
283
+ self._distributed.clear()
284
+ if self._exception_engine is not None:
285
+ self._exception_engine.clear_context()
286
+
287
+ def response_propagation_headers(self, capture: RequestCapture) -> dict[str, str]:
288
+ return {
289
+ key.lower(): value
290
+ for key, value in self._distributed.response_headers().items()
291
+ }
292
+
293
+ def update_capture_request_body(
294
+ self,
295
+ capture: RequestCapture,
296
+ body: bytes | str | dict[str, object] | None,
297
+ ) -> None:
298
+ if body is None:
299
+ return
300
+ capture.request.body_size_bytes = _payload_size(body)
301
+ capture.request.payload_summary = self._sanitizer.summarize_payload(
302
+ body,
303
+ content_type=capture.request.content_type,
304
+ )
305
+
306
+ def _finalize_event(
307
+ self,
308
+ capture: RequestCapture,
309
+ *,
310
+ response_status_code: int | None,
311
+ response_headers: dict[str, str] | None,
312
+ response_body: bytes | str | dict[str, object] | None,
313
+ exception: BaseException | None,
314
+ retry_attempt: int | None = None,
315
+ max_attempts: int | None = None,
316
+ malformed_response: bool = False,
317
+ source_kind: str,
318
+ ) -> Event:
319
+ duration_ms = (time.perf_counter() - capture.started_at) * 1000
320
+ response = ResponseContext(
321
+ status_code=response_status_code,
322
+ status_class=status_class(response_status_code),
323
+ content_type=(response_headers or {}).get("content-type"),
324
+ body_size_bytes=_payload_size(response_body),
325
+ malformed=malformed_response,
326
+ headers_allowlist=self._sanitizer.sanitize_headers(response_headers),
327
+ payload_summary=self._sanitizer.summarize_payload(
328
+ response_body,
329
+ content_type=(response_headers or {}).get("content-type"),
330
+ ),
331
+ )
332
+ if (
333
+ not response.malformed
334
+ and response.content_type is not None
335
+ and "json" in response.content_type.lower()
336
+ and response.payload_summary is not None
337
+ and response.payload_summary.kind != "json"
338
+ ):
339
+ response.malformed = True
340
+ outcome, response_anomalies = classify_response(response)
341
+ exception_context = None
342
+ if exception is not None:
343
+ if self._exception_engine is not None:
344
+ exception_context = self._exception_engine.capture_exception_context(
345
+ exception,
346
+ request=capture.request,
347
+ dependency=capture.dependency,
348
+ handled=source_kind == "client",
349
+ escaped=source_kind == "server",
350
+ )
351
+ else:
352
+ from obsforge.instrumentation.http.classification import classify_exception
353
+
354
+ exception_context = classify_exception(exception)
355
+ if exception_context is not None and exception_context.failure_class is not None:
356
+ if exception_context.failure_class.value == "timeout":
357
+ outcome = Outcome.TIMEOUT
358
+ else:
359
+ outcome = Outcome.FAILED
360
+
361
+ retry = RetryContext(
362
+ attempt=retry_attempt,
363
+ max_attempts=max_attempts,
364
+ retriable=(retry_attempt or 1) < (max_attempts or retry_attempt or 1),
365
+ exhausted=(retry_attempt or 1) >= (max_attempts or retry_attempt or 1),
366
+ storm_detected=(retry_attempt or 1) >= self._http_settings.retry_storm_threshold,
367
+ )
368
+
369
+ performance = PerformanceContext(
370
+ duration_ms=duration_ms,
371
+ payload_in_bytes=capture.request.body_size_bytes,
372
+ payload_out_bytes=response.body_size_bytes,
373
+ slow_threshold_ms=self._http_settings.slow_request_threshold_ms,
374
+ sampled=True,
375
+ )
376
+
377
+ event_name = "api.request.completed" if source_kind == "server" else "dependency.http.completed"
378
+ severity = Severity.INFO
379
+ if outcome in {Outcome.FAILED, Outcome.TIMEOUT}:
380
+ severity = Severity.ERROR
381
+ elif outcome in {Outcome.REJECTED, Outcome.DEGRADED}:
382
+ severity = Severity.WARNING
383
+
384
+ event = CanonicalEvent(
385
+ severity=severity,
386
+ event_name=event_name,
387
+ event_kind=EventKind.API if source_kind == "server" else EventKind.DEPENDENCY,
388
+ event_domain="http" if source_kind == "server" else "dependency",
389
+ operation=capture.request.method.lower() if capture.request.method else "request",
390
+ outcome=outcome,
391
+ message=self._message_for(source_kind, capture.request, response_status_code, exception_context),
392
+ correlation=capture.correlation,
393
+ telemetry=TelemetryContext(
394
+ trace=capture.trace,
395
+ baggage=dict(capture.baggage),
396
+ span_kind=_span_kind_for(source_kind),
397
+ otel_status_code="ERROR" if outcome in {Outcome.FAILED, Outcome.TIMEOUT} else "OK",
398
+ ),
399
+ request=capture.request,
400
+ response=response,
401
+ exception=exception_context,
402
+ database=self._bound_database_context(),
403
+ business=self._bound_business_context(),
404
+ dependency=capture.dependency,
405
+ retry=retry,
406
+ performance=performance,
407
+ tags={"source_kind": source_kind},
408
+ attributes={
409
+ "source.service.name": self._settings.service_name,
410
+ "source.service.namespace": self._settings.namespace,
411
+ "destination.service.name": (
412
+ capture.dependency.remote_service_name
413
+ if capture.dependency is not None
414
+ else self._settings.service_name
415
+ ),
416
+ "dependency.graph.edge": (
417
+ f"{self._settings.service_name}->{capture.dependency.remote_service_name}"
418
+ if capture.dependency is not None and capture.dependency.remote_service_name is not None
419
+ else f"{self._settings.service_name}->{self._settings.service_name}"
420
+ ),
421
+ },
422
+ )
423
+
424
+ dependency_anomalies = self._dependencies.record(event)
425
+ anomalies = merge_anomalies(response_anomalies, dependency_anomalies)
426
+ if malformed_response:
427
+ anomalies = merge_anomalies(anomalies, ["malformed_response"])
428
+ if anomalies:
429
+ event.ai_hints.extend(anomalies)
430
+ event.investigation_keys.extend(
431
+ [
432
+ event.runtime.service_name,
433
+ event.event_name,
434
+ capture.request.route_template or capture.request.host or "unknown",
435
+ ]
436
+ )
437
+ if "upstream_degradation" in anomalies or "dependency_saturation" in anomalies:
438
+ event.outcome = Outcome.DEGRADED
439
+ if event.severity == Severity.INFO:
440
+ event.severity = Severity.WARNING
441
+ if exception_context is not None:
442
+ event.ai_hints.extend(
443
+ hint for hint in exception_context.ai_hints if hint not in event.ai_hints
444
+ )
445
+ return event
446
+
447
+ @staticmethod
448
+ def _dependency_from_url(parsed: SplitResult, *, destination_service: str | None) -> DependencyContext:
449
+ remote_name = destination_service or parsed.hostname or "unknown-remote"
450
+ return DependencyContext(
451
+ dependency_type="http",
452
+ dependency_name=remote_name,
453
+ operation=parsed.path or "/",
454
+ target_namespace=parsed.hostname,
455
+ remote_service_name=remote_name,
456
+ upstream=True,
457
+ )
458
+
459
+ @staticmethod
460
+ def _message_for(
461
+ source_kind: str,
462
+ request: RequestContext,
463
+ status_code: int | None,
464
+ exception: object | None,
465
+ ) -> str:
466
+ target = request.route_template or request.path or request.host or "unknown"
467
+ if exception is not None:
468
+ return f"{source_kind} {request.method} {target} failed"
469
+ return f"{source_kind} {request.method} {target} -> {status_code}"
470
+
471
+ def _bound_business_context(self) -> BusinessContext | None:
472
+ if self._exception_engine is None:
473
+ return None
474
+ return self._exception_engine.state.get_business_context()
475
+
476
+ def _bound_database_context(self) -> DatabaseContext | None:
477
+ if self._exception_engine is None:
478
+ return None
479
+ return self._exception_engine.state.get_database_context()
480
+
481
+ def _bind_correlation_inheritance(self, correlation: CorrelationContext) -> None:
482
+ if self._exception_engine is None:
483
+ return
484
+ current = self._exception_engine.state.get_business_context()
485
+ if (
486
+ current is not None
487
+ and current.tenant_id == correlation.tenant_id
488
+ and current.transaction_id == correlation.transaction_id
489
+ ):
490
+ return
491
+ merged = current.model_copy() if current is not None else BusinessContext()
492
+ if correlation.tenant_id is not None and merged.tenant_id is None:
493
+ merged.tenant_id = correlation.tenant_id
494
+ if correlation.transaction_id is not None and merged.transaction_id is None:
495
+ merged.transaction_id = correlation.transaction_id
496
+ self._exception_engine.state.bind_business_context(merged)
497
+
498
+ def _restore_client_context(self, capture: RequestCapture) -> None:
499
+ if capture.restore_snapshot is not None:
500
+ self._distributed.restore(capture.restore_snapshot)
501
+ if self._exception_engine is not None and capture.exception_snapshot is not None:
502
+ self._exception_engine.restore_context(capture.exception_snapshot)
503
+
504
+
505
+ def _payload_size(payload: bytes | str | dict[str, object] | None) -> int | None:
506
+ if payload is None:
507
+ return None
508
+ if isinstance(payload, bytes):
509
+ return len(payload)
510
+ if isinstance(payload, str):
511
+ return len(payload.encode("utf-8", errors="replace"))
512
+ return len(str(payload).encode("utf-8", errors="replace"))
513
+
514
+
515
+ def _span_kind_for(source_kind: str) -> SpanKind:
516
+ return SpanKind.SERVER if source_kind == "server" else SpanKind.CLIENT
@@ -0,0 +1,130 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from obsforge.instrumentation.http.engine import APIRuntimeEngine
6
+
7
+ if TYPE_CHECKING:
8
+ import httpx
9
+
10
+ try: # pragma: no cover - optional dependency
11
+ import httpx as _httpx
12
+ except ImportError: # pragma: no cover - optional dependency
13
+ _httpx = None # type: ignore[assignment]
14
+
15
+ _HAS_HTTPX = _httpx is not None
16
+
17
+ if _HAS_HTTPX:
18
+ _BaseTransport: Any = _httpx.BaseTransport
19
+ _AsyncBaseTransport: Any = _httpx.AsyncBaseTransport
20
+ else: # pragma: no cover - optional dependency
21
+ _BaseTransport = object
22
+ _AsyncBaseTransport = object
23
+
24
+
25
+ class ObsforgeHTTPXTransport(_BaseTransport): # type: ignore[misc]
26
+ def __init__(
27
+ self,
28
+ engine: APIRuntimeEngine,
29
+ transport: httpx.BaseTransport | None = None,
30
+ ) -> None:
31
+ if not _HAS_HTTPX: # pragma: no cover - optional dependency
32
+ raise RuntimeError("httpx is not installed")
33
+ self._engine = engine
34
+ self._transport = transport or _httpx.HTTPTransport()
35
+
36
+ def handle_request(self, request: httpx.Request) -> httpx.Response:
37
+ headers = dict(request.headers)
38
+ body = request.content if request.content else None
39
+ timeout_ms = _timeout_ext_to_ms(request.extensions.get("timeout"))
40
+ capture = self._engine.capture_client_request(
41
+ method=request.method,
42
+ url=str(request.url),
43
+ headers=headers,
44
+ request_body=body,
45
+ timeout_ms=timeout_ms,
46
+ )
47
+ request.headers.update(headers)
48
+
49
+ try:
50
+ response = self._transport.handle_request(request)
51
+ except BaseException as exc:
52
+ self._engine.emit_client_event_sync(capture, exception=exc)
53
+ raise
54
+
55
+ self._engine.emit_client_event_sync(
56
+ capture,
57
+ response_status_code=response.status_code,
58
+ response_headers=dict(response.headers),
59
+ # read() first: the transport returns an unread streaming response, so
60
+ # accessing .content directly raises ResponseNotRead (mirrors the async
61
+ # path's aread()). read() buffers the body so the caller can still use it.
62
+ response_body=response.read(),
63
+ )
64
+ return response
65
+
66
+
67
+ class ObsforgeHTTPXAsyncTransport(_AsyncBaseTransport): # type: ignore[misc]
68
+ def __init__(
69
+ self,
70
+ engine: APIRuntimeEngine,
71
+ transport: httpx.AsyncBaseTransport | None = None,
72
+ ) -> None:
73
+ if not _HAS_HTTPX: # pragma: no cover - optional dependency
74
+ raise RuntimeError("httpx is not installed")
75
+ self._engine = engine
76
+ self._transport = transport or _httpx.AsyncHTTPTransport()
77
+
78
+ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
79
+ headers = dict(request.headers)
80
+ body = request.content if request.content else None
81
+ timeout_ms = _timeout_ext_to_ms(request.extensions.get("timeout"))
82
+ capture = self._engine.capture_client_request(
83
+ method=request.method,
84
+ url=str(request.url),
85
+ headers=headers,
86
+ request_body=body,
87
+ timeout_ms=timeout_ms,
88
+ )
89
+ request.headers.update(headers)
90
+
91
+ try:
92
+ response = await self._transport.handle_async_request(request)
93
+ except BaseException as exc:
94
+ await self._engine.emit_client_event(capture, exception=exc)
95
+ raise
96
+
97
+ await self._engine.emit_client_event(
98
+ capture,
99
+ response_status_code=response.status_code,
100
+ response_headers=dict(response.headers),
101
+ response_body=await response.aread(),
102
+ )
103
+ return response
104
+
105
+
106
+ def instrument_httpx_client(
107
+ client: httpx.Client, engine: APIRuntimeEngine
108
+ ) -> httpx.Client:
109
+ if not _HAS_HTTPX: # pragma: no cover - optional dependency
110
+ raise RuntimeError("httpx is not installed")
111
+ client._transport = ObsforgeHTTPXTransport(engine, client._transport)
112
+ return client
113
+
114
+
115
+ def instrument_httpx_async_client(
116
+ client: httpx.AsyncClient, engine: APIRuntimeEngine
117
+ ) -> httpx.AsyncClient:
118
+ if not _HAS_HTTPX: # pragma: no cover - optional dependency
119
+ raise RuntimeError("httpx is not installed")
120
+ client._transport = ObsforgeHTTPXAsyncTransport(engine, client._transport)
121
+ return client
122
+
123
+
124
+ def _timeout_ext_to_ms(timeout: Any) -> float | None:
125
+ if timeout is None:
126
+ return None
127
+ if isinstance(timeout, dict):
128
+ values = [value for value in timeout.values() if isinstance(value, (int, float))]
129
+ return max(values) * 1000 if values else None
130
+ return None