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,123 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from dataclasses import dataclass
5
+ from threading import Lock
6
+
7
+ from obsforge.config.settings import DatabaseInstrumentationSettings
8
+
9
+
10
+ @dataclass(slots=True)
11
+ class PoolSnapshot:
12
+ pool_name: str
13
+ pool_size: int | None = None
14
+ checked_out: int | None = None
15
+ available: int | None = None
16
+ waiters: int | None = None
17
+ saturated: bool = False
18
+ leak_suspected: bool = False
19
+ checkout_wait_ms: float | None = None
20
+
21
+
22
+ @dataclass(slots=True)
23
+ class CheckoutState:
24
+ checked_out_at: float
25
+ connection_id: str
26
+
27
+
28
+ class PoolTracker:
29
+ def __init__(self, settings: DatabaseInstrumentationSettings) -> None:
30
+ self._settings = settings
31
+ self._states: dict[str, dict[str, CheckoutState]] = {}
32
+ self._lock = Lock()
33
+
34
+ def checkout(
35
+ self,
36
+ *,
37
+ pool_name: str,
38
+ connection_id: str,
39
+ pool_size: int | None = None,
40
+ checked_out: int | None = None,
41
+ available: int | None = None,
42
+ waiters: int | None = None,
43
+ checkout_wait_ms: float | None = None,
44
+ ) -> PoolSnapshot:
45
+ with self._lock:
46
+ pool = self._states.setdefault(pool_name, {})
47
+ pool[connection_id] = CheckoutState(checked_out_at=time.perf_counter(), connection_id=connection_id)
48
+ checked_out_now = checked_out if checked_out is not None else len(pool)
49
+ available_now = available if available is not None else (
50
+ max(pool_size - checked_out_now, 0) if pool_size is not None else None
51
+ )
52
+ waiters_now = waiters
53
+ return PoolSnapshot(
54
+ pool_name=pool_name,
55
+ pool_size=pool_size,
56
+ checked_out=checked_out_now,
57
+ available=available_now,
58
+ waiters=waiters_now,
59
+ saturated=(waiters_now or 0) >= self._settings.pool_exhaustion_waiters_threshold
60
+ or (available_now == 0 and checked_out_now > 0 if available_now is not None else False),
61
+ leak_suspected=self._leak_suspected(pool),
62
+ checkout_wait_ms=checkout_wait_ms,
63
+ )
64
+
65
+ def checkin(
66
+ self,
67
+ *,
68
+ pool_name: str,
69
+ connection_id: str,
70
+ pool_size: int | None = None,
71
+ checked_out: int | None = None,
72
+ available: int | None = None,
73
+ waiters: int | None = None,
74
+ ) -> PoolSnapshot:
75
+ with self._lock:
76
+ pool = self._states.setdefault(pool_name, {})
77
+ pool.pop(connection_id, None)
78
+ checked_out_now = checked_out if checked_out is not None else len(pool)
79
+ available_now = available if available is not None else (
80
+ max(pool_size - checked_out_now, 0) if pool_size is not None else None
81
+ )
82
+ return PoolSnapshot(
83
+ pool_name=pool_name,
84
+ pool_size=pool_size,
85
+ checked_out=checked_out_now,
86
+ available=available_now,
87
+ waiters=waiters,
88
+ saturated=(waiters or 0) >= self._settings.pool_exhaustion_waiters_threshold,
89
+ leak_suspected=self._leak_suspected(pool),
90
+ )
91
+
92
+ def snapshot(
93
+ self,
94
+ *,
95
+ pool_name: str,
96
+ pool_size: int | None = None,
97
+ checked_out: int | None = None,
98
+ available: int | None = None,
99
+ waiters: int | None = None,
100
+ ) -> PoolSnapshot:
101
+ with self._lock:
102
+ pool = self._states.setdefault(pool_name, {})
103
+ checked_out_now = checked_out if checked_out is not None else len(pool)
104
+ available_now = available if available is not None else (
105
+ max(pool_size - checked_out_now, 0) if pool_size is not None else None
106
+ )
107
+ return PoolSnapshot(
108
+ pool_name=pool_name,
109
+ pool_size=pool_size,
110
+ checked_out=checked_out_now,
111
+ available=available_now,
112
+ waiters=waiters,
113
+ saturated=(waiters or 0) >= self._settings.pool_exhaustion_waiters_threshold
114
+ or (available_now == 0 and checked_out_now > 0 if available_now is not None else False),
115
+ leak_suspected=self._leak_suspected(pool),
116
+ )
117
+
118
+ def _leak_suspected(self, pool: dict[str, CheckoutState]) -> bool:
119
+ now = time.perf_counter()
120
+ for state in pool.values():
121
+ if (now - state.checked_out_at) * 1000 >= self._settings.leak_checkout_threshold_ms:
122
+ return True
123
+ return False
@@ -0,0 +1,168 @@
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
+ psycopg: ModuleType | None
9
+ try: # pragma: no cover - optional dependency
10
+ import psycopg
11
+ except ImportError: # pragma: no cover - optional dependency
12
+ psycopg = None
13
+
14
+
15
+ class PsycopgCursorProxy:
16
+ def __init__(self, cursor: Any, connection_proxy: PsycopgConnectionProxy) -> None:
17
+ self._cursor = cursor
18
+ self._connection_proxy = connection_proxy
19
+
20
+ def execute(self, query: str, params: Any = None) -> Any:
21
+ capture = self._connection_proxy._db_engine.start_query(
22
+ system="postgresql",
23
+ sql=query,
24
+ database_name=getattr(self._connection_proxy._connection.info, "dbname", None),
25
+ host=getattr(self._connection_proxy._connection.info, "host", None),
26
+ port=getattr(self._connection_proxy._connection.info, "port", None),
27
+ role="primary",
28
+ connection_id=str(id(self._connection_proxy._connection)),
29
+ many=False,
30
+ namespace=getattr(self._connection_proxy._connection.info, "dbname", None),
31
+ )
32
+ try:
33
+ result = self._cursor.execute(query, params)
34
+ except BaseException as exc:
35
+ self._connection_proxy._db_engine.emit_query_sync(capture, exception=exc)
36
+ raise
37
+ self._connection_proxy._db_engine.emit_query_sync(
38
+ capture,
39
+ rows_affected=getattr(self._cursor, "rowcount", None),
40
+ transaction_state=self._connection_proxy._db_engine.transaction_state(
41
+ str(id(self._connection_proxy._connection))
42
+ ),
43
+ )
44
+ return result
45
+
46
+ def executemany(self, query: str, params_seq: Any) -> Any:
47
+ capture = self._connection_proxy._db_engine.start_query(
48
+ system="postgresql",
49
+ sql=query,
50
+ database_name=getattr(self._connection_proxy._connection.info, "dbname", None),
51
+ host=getattr(self._connection_proxy._connection.info, "host", None),
52
+ port=getattr(self._connection_proxy._connection.info, "port", None),
53
+ role="primary",
54
+ connection_id=str(id(self._connection_proxy._connection)),
55
+ many=True,
56
+ namespace=getattr(self._connection_proxy._connection.info, "dbname", None),
57
+ )
58
+ try:
59
+ result = self._cursor.executemany(query, params_seq)
60
+ except BaseException as exc:
61
+ self._connection_proxy._db_engine.emit_query_sync(capture, exception=exc)
62
+ raise
63
+ self._connection_proxy._db_engine.emit_query_sync(
64
+ capture,
65
+ rows_affected=getattr(self._cursor, "rowcount", None),
66
+ transaction_state=self._connection_proxy._db_engine.transaction_state(
67
+ str(id(self._connection_proxy._connection))
68
+ ),
69
+ )
70
+ return result
71
+
72
+ def __getattr__(self, name: str) -> Any:
73
+ return getattr(self._cursor, name)
74
+
75
+ def __enter__(self) -> PsycopgCursorProxy:
76
+ self._cursor.__enter__()
77
+ return self
78
+
79
+ def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> Any:
80
+ return self._cursor.__exit__(exc_type, exc, tb)
81
+
82
+
83
+ class PsycopgConnectionProxy:
84
+ def __init__(self, connection: Any, db_engine: DBObservabilityEngine) -> None:
85
+ self._connection = connection
86
+ self._db_engine = db_engine
87
+
88
+ def cursor(self, *args: Any, **kwargs: Any) -> PsycopgCursorProxy:
89
+ return PsycopgCursorProxy(self._connection.cursor(*args, **kwargs), self)
90
+
91
+ def execute(self, query: str, params: Any = None) -> Any:
92
+ with self.cursor() as cursor:
93
+ return cursor.execute(query, params)
94
+
95
+ def commit(self) -> Any:
96
+ self._db_engine.transaction_commit(str(id(self._connection)))
97
+ return self._connection.commit()
98
+
99
+ def rollback(self) -> Any:
100
+ self._db_engine.transaction_rollback(str(id(self._connection)))
101
+ return self._connection.rollback()
102
+
103
+ def transaction_begin(self) -> None:
104
+ self._db_engine.transaction_begin(
105
+ str(id(self._connection)),
106
+ isolation_level=getattr(self._connection, "isolation_level", None),
107
+ autocommit=getattr(self._connection, "autocommit", None),
108
+ )
109
+
110
+ def __getattr__(self, name: str) -> Any:
111
+ return getattr(self._connection, name)
112
+
113
+
114
+ def instrument_psycopg_connection(connection: Any, db_engine: DBObservabilityEngine) -> PsycopgConnectionProxy:
115
+ return PsycopgConnectionProxy(connection, db_engine)
116
+
117
+
118
+ def instrument_psycopg_pool(pool: Any, db_engine: DBObservabilityEngine) -> Any:
119
+ original_connection = pool.connection
120
+
121
+ def connection(*args: Any, **kwargs: Any) -> Any:
122
+ context_manager = original_connection(*args, **kwargs)
123
+
124
+ class ConnectionContextProxy:
125
+ def __enter__(self) -> PsycopgConnectionProxy:
126
+ raw = context_manager.__enter__()
127
+ stats = _pool_stats(pool)
128
+ db_engine.pool_checkout(
129
+ pool_name=type(pool).__name__,
130
+ connection_id=str(id(raw)),
131
+ pool_size=stats.get("pool_size"),
132
+ available=stats.get("pool_available"),
133
+ waiters=stats.get("requests_waiting"),
134
+ )
135
+ return instrument_psycopg_connection(raw, db_engine)
136
+
137
+ def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> Any:
138
+ result = context_manager.__exit__(exc_type, exc, tb)
139
+ stats = _pool_stats(pool)
140
+ raw_id = None
141
+ if hasattr(context_manager, "__dict__"):
142
+ raw = context_manager.__dict__.get("_conn")
143
+ raw_id = str(id(raw)) if raw is not None else None
144
+ if raw_id is not None:
145
+ db_engine.pool_checkin(
146
+ pool_name=type(pool).__name__,
147
+ connection_id=raw_id,
148
+ pool_size=stats.get("pool_size"),
149
+ available=stats.get("pool_available"),
150
+ waiters=stats.get("requests_waiting"),
151
+ )
152
+ return result
153
+
154
+ return ConnectionContextProxy()
155
+
156
+ pool.connection = connection
157
+ return pool
158
+
159
+
160
+ def _pool_stats(pool: Any) -> dict[str, Any]:
161
+ getter = getattr(pool, "get_stats", None)
162
+ if callable(getter):
163
+ try:
164
+ stats: dict[str, Any] = getter()
165
+ return stats
166
+ except Exception:
167
+ return {}
168
+ return {}
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from hashlib import sha1
5
+
6
+ from obsforge.config.settings import DatabaseInstrumentationSettings
7
+
8
+ STRING_RE = re.compile(r"'(?:''|[^'])*'")
9
+ NUMBER_RE = re.compile(r"\b\d+(?:\.\d+)?\b")
10
+ UUID_RE = re.compile(
11
+ r"\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b",
12
+ re.IGNORECASE,
13
+ )
14
+ WS_RE = re.compile(r"\s+")
15
+ IN_RE = re.compile(r"\bin\s*\(([^)]+)\)", re.IGNORECASE)
16
+
17
+
18
+ class SQLNormalizer:
19
+ def __init__(self, settings: DatabaseInstrumentationSettings) -> None:
20
+ self._settings = settings
21
+
22
+ def normalize(self, sql: str) -> str:
23
+ normalized = UUID_RE.sub(self._settings.redacted_literals_placeholder, sql)
24
+ normalized = STRING_RE.sub(self._settings.redacted_literals_placeholder, normalized)
25
+ normalized = NUMBER_RE.sub(self._settings.redacted_literals_placeholder, normalized)
26
+ normalized = IN_RE.sub("IN (...)", normalized)
27
+ normalized = WS_RE.sub(" ", normalized).strip()
28
+ if len(normalized) > self._settings.max_normalized_query_chars:
29
+ normalized = normalized[: self._settings.max_normalized_query_chars - 3] + "..."
30
+ return normalized
31
+
32
+ def fingerprint(self, sql: str) -> str:
33
+ return sha1(self.normalize(sql).lower().encode("utf-8")).hexdigest()
34
+
35
+ def summary(self, sql: str) -> str:
36
+ normalized = self.normalize(sql)
37
+ parts = normalized.split(" ", 3)
38
+ if not parts:
39
+ return "unknown query"
40
+ operation = parts[0].upper()
41
+ if operation == "SELECT":
42
+ return self._select_summary(normalized)
43
+ if operation in {"UPDATE", "INSERT", "DELETE"}:
44
+ target = parts[1] if len(parts) > 1 else "unknown"
45
+ return f"{operation} {target}"
46
+ if operation in {"BEGIN", "COMMIT", "ROLLBACK", "SAVEPOINT", "RELEASE"}:
47
+ return operation
48
+ return " ".join(parts[:2])
49
+
50
+ @staticmethod
51
+ def operation(sql: str) -> str:
52
+ stripped = sql.lstrip()
53
+ if not stripped:
54
+ return "UNKNOWN"
55
+ return stripped.split(" ", 1)[0].upper()
56
+
57
+ @staticmethod
58
+ def _select_summary(sql: str) -> str:
59
+ lower = sql.lower()
60
+ from_index = lower.find(" from ")
61
+ if from_index == -1:
62
+ return "SELECT"
63
+ after_from = sql[from_index + 6 :]
64
+ table = after_from.split(" ", 1)[0]
65
+ return f"SELECT {table}"
@@ -0,0 +1,161 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any, TypeVar, cast
5
+
6
+ from obsforge.instrumentation.db.engine import DBObservabilityEngine
7
+
8
+ event: Any
9
+ try: # pragma: no cover - optional dependency
10
+ from sqlalchemy import event
11
+ except ImportError: # pragma: no cover - optional dependency
12
+ event = None
13
+
14
+
15
+ _F = TypeVar("_F", bound=Callable[..., Any])
16
+
17
+
18
+ def _listens_for(target: Any, identifier: str) -> Callable[[_F], _F]:
19
+ """Typed wrapper around ``sqlalchemy.event.listens_for``.
20
+
21
+ SQLAlchemy returns the decorated function unchanged, so this preserves
22
+ runtime behavior while giving mypy a typed decorator.
23
+ """
24
+
25
+ return cast("Callable[[_F], _F]", event.listens_for(target, identifier))
26
+
27
+
28
+ if event is not None:
29
+ def instrument_sqlalchemy_engine(engine: Any, db_engine: DBObservabilityEngine) -> Any:
30
+ target = getattr(engine, "sync_engine", engine)
31
+ sa_pool = getattr(target, "pool", None)
32
+
33
+ @_listens_for(target, "before_cursor_execute")
34
+ def before_cursor_execute(
35
+ conn: Any,
36
+ cursor: Any,
37
+ statement: Any,
38
+ parameters: Any,
39
+ context: Any,
40
+ executemany: Any,
41
+ ) -> None:
42
+ conn.info["_obsforge_db_capture"] = db_engine.start_query(
43
+ system=target.dialect.name,
44
+ sql=statement,
45
+ alias=getattr(target.url, "database", None),
46
+ database_name=getattr(target.url, "database", None),
47
+ host=getattr(target.url, "host", None),
48
+ port=getattr(target.url, "port", None),
49
+ role="primary",
50
+ connection_id=str(id(conn.connection)),
51
+ pool_name=type(sa_pool).__name__ if sa_pool is not None else None,
52
+ many=executemany,
53
+ read_only=getattr(context, "isinsert", False) is False and getattr(context, "isupdate", False) is False,
54
+ namespace=getattr(target.url, "database", None),
55
+ )
56
+
57
+ @_listens_for(target, "after_cursor_execute")
58
+ def after_cursor_execute(
59
+ conn: Any,
60
+ cursor: Any,
61
+ statement: Any,
62
+ parameters: Any,
63
+ context: Any,
64
+ executemany: Any,
65
+ ) -> None:
66
+ capture = conn.info.pop("_obsforge_db_capture", None)
67
+ if capture is None:
68
+ return
69
+ db_engine.emit_query_sync(
70
+ capture,
71
+ rows_affected=getattr(cursor, "rowcount", None),
72
+ pool_snapshot=_pool_snapshot(db_engine, sa_pool),
73
+ transaction_state=db_engine.transaction_state(str(id(conn.connection))),
74
+ )
75
+
76
+ @_listens_for(target, "handle_error")
77
+ def handle_error(exception_context: Any) -> None:
78
+ conn = exception_context.connection
79
+ capture = conn.info.pop("_obsforge_db_capture", None) if conn is not None else None
80
+ if capture is None:
81
+ return
82
+ db_engine.emit_query_sync(
83
+ capture,
84
+ exception=exception_context.original_exception,
85
+ pool_snapshot=_pool_snapshot(db_engine, sa_pool),
86
+ transaction_state=(
87
+ db_engine.transaction_state(str(id(conn.connection))) if conn is not None else None
88
+ ),
89
+ )
90
+
91
+ @_listens_for(target, "begin")
92
+ def begin(conn: Any) -> None:
93
+ db_engine.transaction_begin(
94
+ str(id(conn.connection)),
95
+ isolation_level=_safe_call(conn, "get_isolation_level"),
96
+ autocommit=_safe_getattr(conn.connection, "autocommit"),
97
+ )
98
+
99
+ @_listens_for(target, "commit")
100
+ def commit(conn: Any) -> None:
101
+ db_engine.transaction_commit(str(id(conn.connection)))
102
+
103
+ @_listens_for(target, "rollback")
104
+ def rollback(conn: Any) -> None:
105
+ db_engine.transaction_rollback(str(id(conn.connection)))
106
+
107
+ if sa_pool is not None:
108
+ @_listens_for(sa_pool, "checkout")
109
+ def checkout(
110
+ dbapi_connection: Any,
111
+ connection_record: Any,
112
+ connection_proxy: Any,
113
+ ) -> None:
114
+ db_engine.pool_checkout(
115
+ pool_name=type(sa_pool).__name__,
116
+ connection_id=str(id(dbapi_connection)),
117
+ pool_size=_safe_call(sa_pool, "size"),
118
+ checked_out=_safe_call(sa_pool, "checkedout"),
119
+ )
120
+
121
+ @_listens_for(sa_pool, "checkin")
122
+ def checkin(dbapi_connection: Any, connection_record: Any) -> None:
123
+ db_engine.pool_checkin(
124
+ pool_name=type(sa_pool).__name__,
125
+ connection_id=str(id(dbapi_connection)),
126
+ pool_size=_safe_call(sa_pool, "size"),
127
+ checked_out=_safe_call(sa_pool, "checkedout"),
128
+ )
129
+
130
+ return engine
131
+
132
+
133
+ def _pool_snapshot(db_engine: DBObservabilityEngine, pool: Any) -> Any:
134
+ if pool is None:
135
+ return None
136
+ return db_engine.pool_snapshot(
137
+ pool_name=type(pool).__name__,
138
+ pool_size=_safe_call(pool, "size"),
139
+ checked_out=_safe_call(pool, "checkedout"),
140
+ )
141
+
142
+
143
+ def _safe_call(obj: Any, name: str) -> Any:
144
+ method = getattr(obj, name, None)
145
+ if callable(method):
146
+ try:
147
+ return method()
148
+ except Exception:
149
+ return None
150
+ return None
151
+
152
+
153
+ def _safe_getattr(obj: Any, name: str) -> Any:
154
+ try:
155
+ return getattr(obj, name)
156
+ except Exception:
157
+ return None
158
+
159
+ else:
160
+ def instrument_sqlalchemy_engine(engine: Any, db_engine: DBObservabilityEngine) -> Any: # pragma: no cover
161
+ raise RuntimeError("sqlalchemy is not installed")
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from obsforge.core.errors import ConfigurationError
6
+
7
+ if TYPE_CHECKING:
8
+ from obsforge.instrumentation.db.engine import DBObservabilityEngine
9
+
10
+ _default_db_engine: DBObservabilityEngine | None = None
11
+
12
+
13
+ def set_default_db_engine(engine: DBObservabilityEngine) -> None:
14
+ global _default_db_engine
15
+ _default_db_engine = engine
16
+
17
+
18
+ def get_default_db_engine() -> DBObservabilityEngine | None:
19
+ return _default_db_engine
20
+
21
+
22
+ def require_default_db_engine() -> DBObservabilityEngine:
23
+ if _default_db_engine is None:
24
+ raise ConfigurationError(
25
+ "obsforge is not initialized: call obsforge.bootstrap() before using the "
26
+ "database instrumentation, or pass db_engine= explicitly."
27
+ )
28
+ return _default_db_engine
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from threading import Lock
5
+ from uuid import uuid4
6
+
7
+
8
+ @dataclass(slots=True)
9
+ class TransactionState:
10
+ transaction_id: str
11
+ depth: int
12
+ isolation_level: str | None
13
+ autocommit: bool | None
14
+ status: str
15
+
16
+
17
+ class TransactionTracker:
18
+ def __init__(self) -> None:
19
+ self._states: dict[str, TransactionState] = {}
20
+ self._lock = Lock()
21
+
22
+ def begin(
23
+ self,
24
+ connection_id: str,
25
+ *,
26
+ isolation_level: str | None = None,
27
+ autocommit: bool | None = None,
28
+ ) -> TransactionState:
29
+ with self._lock:
30
+ current = self._states.get(connection_id)
31
+ if current is None:
32
+ current = TransactionState(
33
+ transaction_id=str(uuid4()),
34
+ depth=1,
35
+ isolation_level=isolation_level,
36
+ autocommit=autocommit,
37
+ status="active",
38
+ )
39
+ else:
40
+ current.depth += 1
41
+ current.status = "active"
42
+ if isolation_level is not None:
43
+ current.isolation_level = isolation_level
44
+ if autocommit is not None:
45
+ current.autocommit = autocommit
46
+ self._states[connection_id] = current
47
+ return current
48
+
49
+ def commit(self, connection_id: str) -> TransactionState | None:
50
+ with self._lock:
51
+ current = self._states.get(connection_id)
52
+ if current is None:
53
+ return None
54
+ current.depth = max(current.depth - 1, 0)
55
+ current.status = "committed"
56
+ if current.depth == 0:
57
+ self._states.pop(connection_id, None)
58
+ return current
59
+
60
+ def rollback(self, connection_id: str) -> TransactionState | None:
61
+ with self._lock:
62
+ current = self._states.get(connection_id)
63
+ if current is None:
64
+ return None
65
+ current.depth = max(current.depth - 1, 0)
66
+ current.status = "rolled_back"
67
+ if current.depth == 0:
68
+ self._states.pop(connection_id, None)
69
+ return current
70
+
71
+ def state(self, connection_id: str) -> TransactionState | None:
72
+ with self._lock:
73
+ return self._states.get(connection_id)
@@ -0,0 +1,15 @@
1
+ from obsforge.instrumentation.exceptions.engine import ExceptionIntelligenceEngine
2
+ from obsforge.instrumentation.exceptions.state import (
3
+ ExceptionContextSnapshot,
4
+ ExceptionStateManager,
5
+ get_default_exception_engine,
6
+ set_default_exception_engine,
7
+ )
8
+
9
+ __all__ = [
10
+ "ExceptionContextSnapshot",
11
+ "ExceptionIntelligenceEngine",
12
+ "ExceptionStateManager",
13
+ "get_default_exception_engine",
14
+ "set_default_exception_engine",
15
+ ]