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,106 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib.util import find_spec
4
+ from typing import Any
5
+
6
+ from obsforge.instrumentation.db.engine import DBObservabilityEngine
7
+
8
+ _HAS_AIOMYSQL = find_spec("aiomysql") is not None
9
+
10
+
11
+ class AioMySQLCursorProxy:
12
+ def __init__(self, cursor: Any, connection_proxy: AioMySQLConnectionProxy) -> None:
13
+ self._cursor = cursor
14
+ self._connection_proxy = connection_proxy
15
+
16
+ async def execute(self, query: str, args: Any = None) -> Any:
17
+ return await self._run("execute", query, args, many=False)
18
+
19
+ async def executemany(self, query: str, args: Any) -> Any:
20
+ return await self._run("executemany", query, args, many=True)
21
+
22
+ async def _run(self, method_name: str, query: str, args: Any, *, many: bool) -> Any:
23
+ capture = self._connection_proxy._db_engine.start_query(
24
+ system="mysql",
25
+ sql=query,
26
+ database_name=getattr(self._connection_proxy._connection, "db", None),
27
+ host=getattr(self._connection_proxy._connection, "host", None),
28
+ port=getattr(self._connection_proxy._connection, "port", None),
29
+ role="primary",
30
+ connection_id=str(id(self._connection_proxy._connection)),
31
+ pool_name="aiomysql",
32
+ many=many,
33
+ namespace=getattr(self._connection_proxy._connection, "db", None),
34
+ )
35
+ try:
36
+ result = await getattr(self._cursor, method_name)(query, args)
37
+ except BaseException as exc:
38
+ await self._connection_proxy._db_engine.emit_query(capture, exception=exc)
39
+ raise
40
+ await self._connection_proxy._db_engine.emit_query(
41
+ capture,
42
+ rows_affected=getattr(self._cursor, "rowcount", None),
43
+ transaction_state=self._connection_proxy._db_engine.transaction_state(
44
+ str(id(self._connection_proxy._connection))
45
+ ),
46
+ )
47
+ return result
48
+
49
+ def __getattr__(self, name: str) -> Any:
50
+ return getattr(self._cursor, name)
51
+
52
+
53
+ class AioMySQLConnectionProxy:
54
+ def __init__(self, connection: Any, db_engine: DBObservabilityEngine) -> None:
55
+ self._connection = connection
56
+ self._db_engine = db_engine
57
+
58
+ async def cursor(self, *args: Any, **kwargs: Any) -> AioMySQLCursorProxy:
59
+ cursor = await self._connection.cursor(*args, **kwargs)
60
+ return AioMySQLCursorProxy(cursor, self)
61
+
62
+ def __getattr__(self, name: str) -> Any:
63
+ return getattr(self._connection, name)
64
+
65
+
66
+ def instrument_aiomysql_connection(connection: Any, db_engine: DBObservabilityEngine) -> AioMySQLConnectionProxy:
67
+ return AioMySQLConnectionProxy(connection, db_engine)
68
+
69
+
70
+ def instrument_aiomysql_pool(pool: Any, db_engine: DBObservabilityEngine) -> Any:
71
+ if not _HAS_AIOMYSQL: # pragma: no cover
72
+ raise RuntimeError("aiomysql is not installed")
73
+
74
+ original_internal_acquire = getattr(pool, "_acquire", None)
75
+ original_acquire = pool.acquire
76
+ original_release = pool.release
77
+
78
+ async def _acquire(*args: Any, **kwargs: Any) -> AioMySQLConnectionProxy:
79
+ if callable(original_internal_acquire):
80
+ connection = await original_internal_acquire(*args, **kwargs)
81
+ else:
82
+ connection = await original_acquire(*args, **kwargs)
83
+ db_engine.pool_checkout(
84
+ pool_name="aiomysql",
85
+ connection_id=str(id(connection)),
86
+ pool_size=getattr(pool, "maxsize", None),
87
+ available=getattr(pool, "freesize", None),
88
+ )
89
+ return instrument_aiomysql_connection(connection, db_engine)
90
+
91
+ def release(connection: Any, *args: Any, **kwargs: Any) -> Any:
92
+ raw = getattr(connection, "_connection", connection)
93
+ db_engine.pool_checkin(
94
+ pool_name="aiomysql",
95
+ connection_id=str(id(raw)),
96
+ pool_size=getattr(pool, "maxsize", None),
97
+ available=getattr(pool, "freesize", None),
98
+ )
99
+ return original_release(raw, *args, **kwargs)
100
+
101
+ if callable(original_internal_acquire):
102
+ pool._acquire = _acquire
103
+ else:
104
+ pool.acquire = _acquire
105
+ pool.release = release
106
+ return pool
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ from types import ModuleType
4
+ from typing import Any
5
+
6
+ from obsforge.instrumentation.db.engine import DBObservabilityEngine
7
+
8
+ try: # pragma: no cover - optional dependency
9
+ import asyncpg as _asyncpg
10
+
11
+ asyncpg: ModuleType | None = _asyncpg
12
+ except ImportError: # pragma: no cover - optional dependency
13
+ asyncpg = None
14
+
15
+
16
+ class AsyncPGConnectionProxy:
17
+ def __init__(self, connection: Any, db_engine: DBObservabilityEngine) -> None:
18
+ self._connection = connection
19
+ self._db_engine = db_engine
20
+
21
+ async def execute(self, query: str, *args: Any) -> Any:
22
+ return await self._run_query("execute", query, *args)
23
+
24
+ async def executemany(self, query: str, args: Any) -> Any:
25
+ return await self._run_query("executemany", query, args, many=True)
26
+
27
+ async def fetch(self, query: str, *args: Any) -> Any:
28
+ return await self._run_query("fetch", query, *args)
29
+
30
+ async def fetchrow(self, query: str, *args: Any) -> Any:
31
+ return await self._run_query("fetchrow", query, *args)
32
+
33
+ async def fetchval(self, query: str, *args: Any) -> Any:
34
+ return await self._run_query("fetchval", query, *args)
35
+
36
+ async def _run_query(self, method_name: str, query: str, *args: Any, many: bool = False) -> Any:
37
+ capture = self._db_engine.start_query(
38
+ system="postgresql",
39
+ sql=query,
40
+ database_name=getattr(self._connection._params, "database", None),
41
+ host=getattr(self._connection._params, "host", None),
42
+ port=getattr(self._connection._params, "port", None),
43
+ role="primary",
44
+ connection_id=str(id(self._connection)),
45
+ pool_name="asyncpg",
46
+ many=many,
47
+ namespace=getattr(self._connection._params, "database", None),
48
+ )
49
+ try:
50
+ result = await getattr(self._connection, method_name)(query, *args)
51
+ except BaseException as exc:
52
+ await self._db_engine.emit_query(capture, exception=exc)
53
+ raise
54
+ rows_affected = len(result) if isinstance(result, list) else None
55
+ await self._db_engine.emit_query(
56
+ capture,
57
+ rows_affected=rows_affected,
58
+ transaction_state=self._db_engine.transaction_state(str(id(self._connection))),
59
+ )
60
+ return result
61
+
62
+ def __getattr__(self, name: str) -> Any:
63
+ return getattr(self._connection, name)
64
+
65
+
66
+ def instrument_asyncpg_connection(connection: Any, db_engine: DBObservabilityEngine) -> AsyncPGConnectionProxy:
67
+ return AsyncPGConnectionProxy(connection, db_engine)
68
+
69
+
70
+ if asyncpg is not None:
71
+ def instrument_asyncpg_pool(pool: Any, db_engine: DBObservabilityEngine) -> Any:
72
+ original_internal_acquire = getattr(pool, "_acquire", None)
73
+ original_acquire = pool.acquire
74
+ original_release = pool.release
75
+
76
+ async def _acquire(*args: Any, **kwargs: Any) -> Any:
77
+ if callable(original_internal_acquire):
78
+ connection = await original_internal_acquire(*args, **kwargs)
79
+ else:
80
+ connection = await original_acquire(*args, **kwargs)
81
+ db_engine.pool_checkout(
82
+ pool_name="asyncpg",
83
+ connection_id=str(id(connection)),
84
+ pool_size=getattr(pool, "_maxsize", None),
85
+ checked_out=len(getattr(pool, "_holders", [])) if hasattr(pool, "_holders") else None,
86
+ )
87
+ return instrument_asyncpg_connection(connection, db_engine)
88
+
89
+ async def release(connection: Any, *args: Any, **kwargs: Any) -> Any:
90
+ raw = getattr(connection, "_connection", connection)
91
+ db_engine.pool_checkin(
92
+ pool_name="asyncpg",
93
+ connection_id=str(id(raw)),
94
+ pool_size=getattr(pool, "_maxsize", None),
95
+ )
96
+ return await original_release(raw, *args, **kwargs)
97
+
98
+ if callable(original_internal_acquire):
99
+ pool._acquire = _acquire
100
+ else:
101
+ pool.acquire = _acquire
102
+ pool.release = release
103
+ return pool
104
+
105
+ else:
106
+ def instrument_asyncpg_pool(pool: Any, db_engine: DBObservabilityEngine) -> Any: # pragma: no cover
107
+ raise RuntimeError("asyncpg is not installed")
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Iterator
4
+ from contextlib import ExitStack, contextmanager
5
+ from typing import Any
6
+
7
+ from obsforge.instrumentation.db.engine import DBObservabilityEngine
8
+
9
+
10
+ class DjangoQueryWrapper:
11
+ def __init__(self, db_engine: DBObservabilityEngine, alias: str) -> None:
12
+ self._engine = db_engine
13
+ self._alias = alias
14
+
15
+ def __call__(
16
+ self,
17
+ execute: Callable[[str, Any, bool, dict[str, Any]], Any],
18
+ sql: str,
19
+ params: Any,
20
+ many: bool,
21
+ context: dict[str, Any],
22
+ ) -> Any:
23
+ connection = context["connection"]
24
+ capture = self._engine.start_query(
25
+ system=connection.vendor,
26
+ sql=sql,
27
+ alias=self._alias,
28
+ database_name=_settings_dict(connection, "NAME"),
29
+ host=_settings_dict(connection, "HOST"),
30
+ port=_settings_dict(connection, "PORT"),
31
+ role="primary",
32
+ connection_id=str(id(connection.connection)),
33
+ pool_name=f"django:{self._alias}",
34
+ many=many,
35
+ read_only=sql.lstrip().upper().startswith("SELECT"),
36
+ namespace=_settings_dict(connection, "NAME"),
37
+ )
38
+ try:
39
+ result = execute(sql, params, many, context)
40
+ except BaseException as exc:
41
+ self._engine.emit_query_sync(
42
+ capture,
43
+ exception=exc,
44
+ transaction_state=self._engine.transaction_state(str(id(connection.connection))),
45
+ )
46
+ raise
47
+
48
+ cursor = context.get("cursor")
49
+ self._engine.emit_query_sync(
50
+ capture,
51
+ rows_affected=getattr(cursor, "rowcount", None),
52
+ transaction_state=self._engine.transaction_state(str(id(connection.connection))),
53
+ )
54
+ return result
55
+
56
+
57
+ @contextmanager
58
+ def instrument_django_connections(
59
+ connections: Any, db_engine: DBObservabilityEngine
60
+ ) -> Iterator[None]:
61
+ with ExitStack() as stack:
62
+ for alias in connections:
63
+ connection = connections[alias]
64
+ stack.enter_context(connection.execute_wrapper(DjangoQueryWrapper(db_engine, alias)))
65
+ yield
66
+
67
+
68
+ def _settings_dict(connection: Any, key: str) -> Any:
69
+ settings_dict = getattr(connection, "settings_dict", {})
70
+ return settings_dict.get(key)
@@ -0,0 +1,324 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ from obsforge.api.events import EventClient
8
+ from obsforge.config.settings import DatabaseInstrumentationSettings, ObsforgeSettings
9
+ from obsforge.core.models import (
10
+ BusinessContext,
11
+ CanonicalEvent,
12
+ CorrelationContext,
13
+ DatabaseContext,
14
+ DatabaseRole,
15
+ DependencyContext,
16
+ Event,
17
+ EventKind,
18
+ Outcome,
19
+ PerformanceContext,
20
+ Severity,
21
+ SpanKind,
22
+ TelemetryContext,
23
+ )
24
+ from obsforge.instrumentation.db.pool import PoolSnapshot, PoolTracker
25
+ from obsforge.instrumentation.db.sql import SQLNormalizer
26
+ from obsforge.instrumentation.db.transactions import TransactionState, TransactionTracker
27
+ from obsforge.instrumentation.exceptions.engine import ExceptionIntelligenceEngine
28
+ from obsforge.instrumentation.exceptions.state import ExceptionContextSnapshot
29
+ from obsforge.propagation.correlation import CorrelationManager
30
+
31
+
32
+ @dataclass(slots=True)
33
+ class QueryCapture:
34
+ started_at: float
35
+ database: DatabaseContext
36
+ normalized_sql: str
37
+ prior_exception_snapshot: ExceptionContextSnapshot | None
38
+
39
+
40
+ class DBObservabilityEngine:
41
+ def __init__(
42
+ self,
43
+ *,
44
+ event_client: EventClient,
45
+ correlation_manager: CorrelationManager,
46
+ settings: ObsforgeSettings,
47
+ exception_engine: ExceptionIntelligenceEngine | None = None,
48
+ ) -> None:
49
+ self._event_client = event_client
50
+ self._correlation_manager = correlation_manager
51
+ self._settings = settings
52
+ self._db_settings: DatabaseInstrumentationSettings = settings.database
53
+ self._normalizer = SQLNormalizer(settings.database)
54
+ self._pool = PoolTracker(settings.database)
55
+ self._transactions = TransactionTracker()
56
+ self._exception_engine = exception_engine
57
+
58
+ def start_query(
59
+ self,
60
+ *,
61
+ system: str,
62
+ sql: str,
63
+ alias: str | None = None,
64
+ database_name: str | None = None,
65
+ host: str | None = None,
66
+ port: int | None = None,
67
+ role: str | None = None,
68
+ connection_id: str | None = None,
69
+ pool_name: str | None = None,
70
+ timeout_ms: float | None = None,
71
+ many: bool | None = None,
72
+ read_only: bool | None = None,
73
+ namespace: str | None = None,
74
+ ) -> QueryCapture:
75
+ normalized = self._normalizer.normalize(sql)
76
+ database = DatabaseContext(
77
+ system=system,
78
+ namespace=namespace,
79
+ database_name=database_name,
80
+ host=host,
81
+ port=port,
82
+ role=(
83
+ DatabaseRole(role)
84
+ if role is not None and role in DatabaseRole._value2member_map_
85
+ else DatabaseRole.UNKNOWN
86
+ ),
87
+ alias=alias,
88
+ operation_name=self._normalizer.operation(sql),
89
+ query_summary=self._normalizer.summary(sql),
90
+ query_fingerprint=self._normalizer.fingerprint(sql),
91
+ query_text_redacted=normalized,
92
+ connection_id=connection_id,
93
+ pool_name=pool_name,
94
+ timeout_ms=timeout_ms,
95
+ many=many,
96
+ read_only=read_only,
97
+ )
98
+ transaction = self._transactions.state(connection_id or "default")
99
+ if transaction is not None:
100
+ database = self._merge_transaction(database, transaction)
101
+
102
+ prior_snapshot: ExceptionContextSnapshot | None = None
103
+ if self._exception_engine is not None:
104
+ prior_snapshot = self._exception_engine.snapshot_context()
105
+ self._exception_engine.bind_database_context(database)
106
+ return QueryCapture(
107
+ started_at=time.perf_counter(),
108
+ database=database,
109
+ normalized_sql=normalized,
110
+ prior_exception_snapshot=prior_snapshot,
111
+ )
112
+
113
+ def finish_query(
114
+ self,
115
+ capture: QueryCapture,
116
+ *,
117
+ rows_affected: int | None = None,
118
+ retry_count: int | None = None,
119
+ exception: BaseException | None = None,
120
+ pool_snapshot: PoolSnapshot | None = None,
121
+ transaction_state: TransactionState | None = None,
122
+ lock_contended: bool = False,
123
+ deadlock_detected: bool = False,
124
+ ) -> Event:
125
+ duration_ms = (time.perf_counter() - capture.started_at) * 1000
126
+ database = capture.database.model_copy()
127
+ database.rows_affected = rows_affected
128
+ database.retry_count = retry_count
129
+ database.retry_storm = (retry_count or 0) >= self._db_settings.retry_storm_threshold
130
+ database.deadlock_detected = deadlock_detected or _detect_deadlock(exception)
131
+ database.lock_contended = lock_contended or _detect_lock_contention(exception)
132
+ if pool_snapshot is not None:
133
+ database.pool_name = pool_snapshot.pool_name
134
+ database.pool_size = pool_snapshot.pool_size
135
+ database.pool_checked_out = pool_snapshot.checked_out
136
+ database.pool_available = pool_snapshot.available
137
+ database.pool_waiters = pool_snapshot.waiters
138
+ database.pool_saturated = pool_snapshot.saturated
139
+ database.connection_leak_suspected = pool_snapshot.leak_suspected
140
+ database.checkout_wait_ms = pool_snapshot.checkout_wait_ms
141
+ if transaction_state is not None:
142
+ database = self._merge_transaction(database, transaction_state)
143
+
144
+ performance = PerformanceContext(
145
+ duration_ms=duration_ms,
146
+ slow_threshold_ms=self._db_settings.slow_query_threshold_ms,
147
+ sampled=True,
148
+ )
149
+ outcome = Outcome.SUCCEEDED
150
+ severity = Severity.INFO
151
+ ai_hints: list[str] = []
152
+ if duration_ms >= self._db_settings.slow_query_threshold_ms:
153
+ ai_hints.append("slow_query")
154
+ severity = Severity.WARNING
155
+ outcome = Outcome.DEGRADED
156
+ if database.deadlock_detected:
157
+ ai_hints.append("deadlock")
158
+ severity = Severity.ERROR
159
+ outcome = Outcome.FAILED
160
+ if database.pool_saturated:
161
+ ai_hints.append("pool_exhaustion")
162
+ severity = Severity.WARNING if severity == Severity.INFO else severity
163
+ if outcome is Outcome.SUCCEEDED:
164
+ outcome = Outcome.DEGRADED
165
+ if database.connection_leak_suspected:
166
+ ai_hints.append("leaked_connection")
167
+ severity = Severity.WARNING if severity == Severity.INFO else severity
168
+ if database.retry_storm:
169
+ ai_hints.append("retry_storm")
170
+ severity = Severity.WARNING if severity == Severity.INFO else severity
171
+ if database.lock_contended:
172
+ ai_hints.append("lock_contention")
173
+ severity = Severity.WARNING if severity == Severity.INFO else severity
174
+ exception_context = None
175
+ if exception is not None:
176
+ outcome = Outcome.TIMEOUT if "timeout" in str(exception).lower() else Outcome.FAILED
177
+ severity = Severity.ERROR
178
+ if self._exception_engine is not None:
179
+ exception_context = self._exception_engine.capture_exception_context(
180
+ exception,
181
+ handled=True,
182
+ escaped=False,
183
+ database=database,
184
+ )
185
+ for hint in exception_context.ai_hints:
186
+ if hint not in ai_hints:
187
+ ai_hints.append(hint)
188
+
189
+ event = CanonicalEvent(
190
+ severity=severity,
191
+ event_name="db.query.completed",
192
+ event_kind=EventKind.DB,
193
+ event_domain="db",
194
+ operation=database.operation_name.lower() if database.operation_name else "query",
195
+ outcome=outcome,
196
+ message=f"{database.operation_name or 'QUERY'} {database.query_summary or database.system or 'db'}",
197
+ correlation=self._correlation_manager.get_correlation()
198
+ or CorrelationContext(correlation_id=str(time.time_ns())),
199
+ telemetry=self._telemetry_context(outcome),
200
+ database=database,
201
+ business=self._bound_business_context(),
202
+ dependency=self._bound_dependency_context(),
203
+ exception=exception_context,
204
+ performance=performance,
205
+ tags={"source_kind": "database"},
206
+ attributes={
207
+ "source.service.name": self._settings.service_name,
208
+ "db.host": database.host,
209
+ "db.role": database.role.value if database.role is not None else DatabaseRole.UNKNOWN.value,
210
+ },
211
+ investigation_keys=_investigation_keys(database),
212
+ ai_hints=ai_hints,
213
+ )
214
+
215
+ if self._exception_engine is not None and capture.prior_exception_snapshot is not None:
216
+ self._exception_engine.restore_context(capture.prior_exception_snapshot)
217
+ return event
218
+
219
+ async def emit_query(self, capture: QueryCapture, **kwargs: Any) -> Event:
220
+ event = self.finish_query(capture, **kwargs)
221
+ await self._event_client.emit(event)
222
+ return event
223
+
224
+ def emit_query_sync(self, capture: QueryCapture, **kwargs: Any) -> Event:
225
+ event = self.finish_query(capture, **kwargs)
226
+ self._event_client.emit_sync(event)
227
+ return event
228
+
229
+ def pool_checkout(self, *, pool_name: str, connection_id: str, **kwargs: Any) -> PoolSnapshot:
230
+ return self._pool.checkout(pool_name=pool_name, connection_id=connection_id, **kwargs)
231
+
232
+ def pool_checkin(self, *, pool_name: str, connection_id: str, **kwargs: Any) -> PoolSnapshot:
233
+ return self._pool.checkin(pool_name=pool_name, connection_id=connection_id, **kwargs)
234
+
235
+ def pool_snapshot(self, *, pool_name: str, **kwargs: Any) -> PoolSnapshot:
236
+ return self._pool.snapshot(pool_name=pool_name, **kwargs)
237
+
238
+ def transaction_begin(
239
+ self,
240
+ connection_id: str,
241
+ *,
242
+ isolation_level: str | None = None,
243
+ autocommit: object = None,
244
+ ) -> TransactionState:
245
+ return self._transactions.begin(
246
+ connection_id,
247
+ isolation_level=isolation_level,
248
+ autocommit=_coerce_autocommit(autocommit),
249
+ )
250
+
251
+ def transaction_commit(self, connection_id: str) -> TransactionState | None:
252
+ return self._transactions.commit(connection_id)
253
+
254
+ def transaction_rollback(self, connection_id: str) -> TransactionState | None:
255
+ return self._transactions.rollback(connection_id)
256
+
257
+ def transaction_state(self, connection_id: str) -> TransactionState | None:
258
+ return self._transactions.state(connection_id)
259
+
260
+ def bind_business_context(self, business: BusinessContext | None) -> None:
261
+ if self._exception_engine is not None:
262
+ self._exception_engine.bind_business_context(business)
263
+
264
+ def _merge_transaction(self, database: DatabaseContext, tx: TransactionState) -> DatabaseContext:
265
+ database.transaction_id = tx.transaction_id
266
+ database.transaction_depth = tx.depth
267
+ database.transaction_active = tx.depth > 0
268
+ database.transaction_status = tx.status
269
+ database.transaction_isolation_level = tx.isolation_level
270
+ database.autocommit = tx.autocommit
271
+ return database
272
+
273
+ def _telemetry_context(self, outcome: Outcome) -> TelemetryContext:
274
+ return TelemetryContext(
275
+ trace=self._correlation_manager.get_trace(),
276
+ span_kind=SpanKind.INTERNAL,
277
+ otel_status_code="ERROR" if outcome in {Outcome.FAILED, Outcome.TIMEOUT} else "OK",
278
+ telemetry_sdk_version=self._settings.telemetry_sdk_version,
279
+ )
280
+
281
+ def _bound_business_context(self) -> BusinessContext | None:
282
+ if self._exception_engine is None:
283
+ return None
284
+ return self._exception_engine.state.get_business_context()
285
+
286
+ def _bound_dependency_context(self) -> DependencyContext | None:
287
+ if self._exception_engine is None:
288
+ return None
289
+ return self._exception_engine.state.get_dependency_context()
290
+
291
+
292
+ def _detect_deadlock(exc: BaseException | None) -> bool:
293
+ if exc is None:
294
+ return False
295
+ text = str(exc).lower()
296
+ return "deadlock" in text
297
+
298
+
299
+ def _detect_lock_contention(exc: BaseException | None) -> bool:
300
+ if exc is None:
301
+ return False
302
+ text = str(exc).lower()
303
+ return "lock wait" in text or "could not obtain lock" in text or "database is locked" in text
304
+
305
+
306
+ def _investigation_keys(database: DatabaseContext) -> list[str]:
307
+ keys = [database.system or "unknown-db"]
308
+ if database.operation_name is not None:
309
+ keys.append(database.operation_name)
310
+ if database.query_fingerprint is not None:
311
+ keys.append(database.query_fingerprint)
312
+ if database.host is not None:
313
+ keys.append(database.host)
314
+ return keys
315
+
316
+
317
+ def _coerce_autocommit(value: object) -> bool | None:
318
+ # Drivers may report legacy/sentinel ints (e.g. sqlite's -1). Normalize so
319
+ # the model never serializes a non-bool into the bool-typed field.
320
+ if value is None or isinstance(value, bool):
321
+ return value
322
+ if isinstance(value, int) and value in (0, 1):
323
+ return bool(value)
324
+ return None