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.
- obsforge/__init__.py +44 -0
- obsforge/api/__init__.py +5 -0
- obsforge/api/context.py +32 -0
- obsforge/api/decorators.py +42 -0
- obsforge/api/events.py +25 -0
- obsforge/api/logger.py +45 -0
- obsforge/config/__init__.py +4 -0
- obsforge/config/bootstrap.py +190 -0
- obsforge/config/settings.py +176 -0
- obsforge/config/state.py +53 -0
- obsforge/core/__init__.py +3 -0
- obsforge/core/contracts.py +52 -0
- obsforge/core/errors.py +10 -0
- obsforge/core/models.py +572 -0
- obsforge/core/taxonomy.py +29 -0
- obsforge/encoding/__init__.py +3 -0
- obsforge/encoding/json.py +46 -0
- obsforge/encoding/serializers.py +7 -0
- obsforge/instrumentation/__init__.py +1 -0
- obsforge/instrumentation/db/__init__.py +4 -0
- obsforge/instrumentation/db/aiomysql.py +106 -0
- obsforge/instrumentation/db/asyncpg.py +107 -0
- obsforge/instrumentation/db/django.py +70 -0
- obsforge/instrumentation/db/engine.py +324 -0
- obsforge/instrumentation/db/pool.py +123 -0
- obsforge/instrumentation/db/psycopg.py +168 -0
- obsforge/instrumentation/db/sql.py +65 -0
- obsforge/instrumentation/db/sqlalchemy.py +161 -0
- obsforge/instrumentation/db/state.py +28 -0
- obsforge/instrumentation/db/transactions.py +73 -0
- obsforge/instrumentation/exceptions/__init__.py +15 -0
- obsforge/instrumentation/exceptions/classification.py +108 -0
- obsforge/instrumentation/exceptions/dedupe.py +66 -0
- obsforge/instrumentation/exceptions/engine.py +373 -0
- obsforge/instrumentation/exceptions/sanitization.py +64 -0
- obsforge/instrumentation/exceptions/state.py +101 -0
- obsforge/instrumentation/http/__init__.py +13 -0
- obsforge/instrumentation/http/aiohttp.py +57 -0
- obsforge/instrumentation/http/classification.py +98 -0
- obsforge/instrumentation/http/dependency.py +92 -0
- obsforge/instrumentation/http/engine.py +516 -0
- obsforge/instrumentation/http/httpx.py +130 -0
- obsforge/instrumentation/http/requests.py +98 -0
- obsforge/instrumentation/http/sanitization.py +134 -0
- obsforge/instrumentation/http/state.py +24 -0
- obsforge/integrations/__init__.py +1 -0
- obsforge/integrations/asyncio.py +43 -0
- obsforge/integrations/celery/__init__.py +3 -0
- obsforge/integrations/celery/signals.py +76 -0
- obsforge/integrations/django/__init__.py +3 -0
- obsforge/integrations/django/middleware.py +105 -0
- obsforge/integrations/django/settings.py +1 -0
- obsforge/integrations/django/signals.py +5 -0
- obsforge/integrations/drf/__init__.py +3 -0
- obsforge/integrations/drf/exception_handler.py +31 -0
- obsforge/integrations/fastapi/__init__.py +3 -0
- obsforge/integrations/fastapi/dependencies.py +7 -0
- obsforge/integrations/fastapi/exception_handlers.py +29 -0
- obsforge/integrations/fastapi/middleware.py +142 -0
- obsforge/integrations/kafka.py +27 -0
- obsforge/integrations/logging_bridge.py +122 -0
- obsforge/integrations/rabbitmq.py +29 -0
- obsforge/integrations/workers.py +60 -0
- obsforge/plugins/__init__.py +4 -0
- obsforge/plugins/builtin.py +24 -0
- obsforge/plugins/manager.py +23 -0
- obsforge/plugins/registry.py +35 -0
- obsforge/plugins/spec.py +27 -0
- obsforge/propagation/__init__.py +26 -0
- obsforge/propagation/baggage.py +42 -0
- obsforge/propagation/correlation.py +98 -0
- obsforge/propagation/distributed.py +389 -0
- obsforge/propagation/state.py +24 -0
- obsforge/propagation/tracecontext.py +56 -0
- obsforge/py.typed +0 -0
- obsforge/runtime/__init__.py +3 -0
- obsforge/runtime/pii.py +53 -0
- obsforge/runtime/pipeline.py +102 -0
- obsforge/runtime/policies/__init__.py +0 -0
- obsforge/runtime/policies/loki_policy.py +180 -0
- obsforge/runtime/processors.py +78 -0
- obsforge/runtime/redaction.py +101 -0
- obsforge/runtime/sampling.py +21 -0
- obsforge/telemetry/__init__.py +5 -0
- obsforge/telemetry/mapping.py +67 -0
- obsforge/telemetry/otel_logs.py +96 -0
- obsforge/telemetry/otel_traces.py +78 -0
- obsforge/testing/__init__.py +3 -0
- obsforge/testing/capture.py +11 -0
- obsforge/testing/fakes.py +40 -0
- obsforge/transport/__init__.py +3 -0
- obsforge/transport/sink.py +19 -0
- obsforge/transport/stdout.py +20 -0
- obsforge-0.1.1.dist-info/METADATA +383 -0
- obsforge-0.1.1.dist-info/RECORD +98 -0
- obsforge-0.1.1.dist-info/WHEEL +4 -0
- obsforge-0.1.1.dist-info/entry_points.txt +2 -0
- 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
|