Skip to content

Commit 3735d4f

Browse files
committed
Added opentelemetry instrumentation and reworked signals
1 parent f1e5154 commit 3735d4f

File tree

8 files changed

+424
-44
lines changed

8 files changed

+424
-44
lines changed

docs/instrumentation.md

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Instrumentation
2+
3+
SQLORM can be instrumented using OpenTelemetry.
4+
5+
It supports automatic instrumentation or can be enabled manually:
6+
7+
```py
8+
from sqlorm.opentelemetry import SQLORMInstrumentor
9+
10+
SQLORMInstrumentor().instrument(engine=engine)
11+
```
12+
13+
You can optionally configure SQLAlchemy instrumentation to enable sqlcommenter which enriches the query with contextual information.
14+
15+
```py
16+
SQLORMInstrumentor().instrument(enable_commenter=True, commenter_options={})
17+
```

docs/signals.md

+12-4
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ Signals allow you to listen and react to events emitted by sqlorm. The [blinker]
66

77
The following signals exist on the `Engine` class. The sender is always the engine instance.
88

9-
- `connected`: receive `conn` (connection instance) and `from_pool` (bool)
10-
- `disconnected`: receive `conn` (connection instance) and `close_conn` (bool). This signal may not indicate a connection has been closed but only returned to the pool. Use the `close_conn` kwargs to distinguish.
9+
- `connected`: receive `conn` (connection instance)
10+
- `pool_checkout`: connection checked out of the pool, receive `conn`
11+
- `pool_checkin`: connection returned to the pool, receive `conn`
12+
- `disconnected`: receive `conn` (connection instance)
1113

1214
Example:
1315

@@ -44,8 +46,14 @@ def on_before_commit(session):
4446

4547
The following signals exist on the `Transaction` class. The sender is always the transaction instance.
4648

47-
- `before_execute`: receive `stmt` and `params`. Returning a cursor will stop sqlorm execute() and return the cursor directly
48-
- `before_executemany`: receive `stmt` and `seq_of_parameters`. Returning false will stop sqlorm executemany()
49+
- `before_execute`: receive `stmt`, `params` and `many` (to distinguish between execute and executemany)
50+
- when called from execute: returning a cursor will stop sqlorm execution and return the cursor directly
51+
- when called from executemany: returning False will stop sqlorm execution
52+
- in both case, returning a tuple `(stmt, params)` will override stmt and params
53+
- `after_execute`: receive `cursor`, `stmt`, `params` and `many`
54+
- `handle_error`: receive `cursor`, `stmt`, `params`, `many` and `exc`:
55+
- when handling for execute: return a cursor to prevent raising the exception
56+
- when handling for executemany: return True to prevent raising
4957

5058
## Model
5159

mkdocs.yml

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ nav:
1515
- signals.md
1616
- schema.md
1717
- drivers.md
18+
- instrumentation.md
1819

1920
theme:
2021
name: material

pyproject.toml

+5-6
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,8 @@ packages = [{include = "sqlorm"}]
1111
[tool.poetry.dependencies]
1212
python = "^3.10"
1313
blinker = "^1.8.2"
14-
15-
[tool.poetry.group.postgresql.dependencies]
16-
psycopg = {extras = ["binary"], version = "^3.1.18"}
17-
18-
[tool.poetry.group.mysql.dependencies]
19-
mysql-connector-python = "^8.3.0"
14+
psycopg = { extras = ["binary"], version = "^3.1.18", optional = true }
15+
mysql-connector-python = { version = "^8.3.0", optional = true }
2016

2117
[tool.poetry.group.dev.dependencies]
2218
pytest = "^8.0.0"
@@ -25,6 +21,9 @@ ruff = "^0.4.3"
2521
mkdocs-material = "^9.5.24"
2622
mkdocs-callouts = "^1.13.2"
2723

24+
[tool.poetry.plugins."opentelemetry_instrumentor"]
25+
sqlorm = "sqlorm.opentelemetry:SQLORMInstrumentor"
26+
2827
[tool.ruff]
2928
include = ["sqlorm/**/*.py"]
3029
line-length = 100

sqlorm/__init__.py

+3
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,6 @@
1616
from .model import BaseModel, Model, Column, ColumnExpr, Relationship, flag_dirty_attr, is_dirty
1717
from .types import *
1818
from .schema import create_all, create_table, init_db, migrate
19+
20+
21+
__version__ = "0.2.0"

sqlorm/engine.py

+77-34
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import logging
55
import inspect
66
import urllib.parse
7+
import functools
78
from blinker import Namespace
89
from .sql import render, ParametrizedStmt
910
from .resultset import ResultSet, CompositeResultSet, CompositionMap
@@ -32,6 +33,8 @@ class Engine:
3233
"""
3334

3435
connected = _signals.signal("connected")
36+
pool_checkin = _signals.signal("pool-checkin")
37+
pool_checkout = _signals.signal("pool-checkout")
3538
disconnected = _signals.signal("disconnected")
3639

3740
@classmethod
@@ -82,46 +85,45 @@ def __init__(
8285

8386
def connect(self, from_pool=True):
8487
if not from_pool or self.pool is False:
85-
if self.logger:
86-
getattr(self.logger, self.logger_level)("New connection established")
87-
return self.connection_factory(self.dbapi)
88+
return self._connect()
8889

8990
if self.pool:
9091
conn = self.pool.pop(0)
9192
if self.logger:
9293
getattr(self.logger, self.logger_level)("Re-using connection from pool")
93-
self.connected.send(self, conn=conn, from_pool=True)
94+
self.pool_checkout.send(self, conn=conn)
9495
elif not self.max_pool_conns or len(self.active_conns) < self.max_pool_conns:
95-
if self.logger:
96-
getattr(self.logger, self.logger_level)("New connection established")
97-
conn = self.connection_factory(self.dbapi)
98-
self.connected.send(self, conn=conn, from_pool=False)
96+
conn = self._connect()
9997
else:
10098
raise EngineError("Max number of connections reached")
10199

102100
self.active_conns.append(conn)
103101
return conn
102+
103+
def _connect(self):
104+
if self.logger:
105+
getattr(self.logger, self.logger_level)("Creating new connection")
106+
conn = self.connection_factory(self.dbapi)
107+
self.connected.send(self, conn=conn)
108+
return conn
104109

105110
def disconnect(self, conn, force=False):
106-
if conn in self.active_conns:
111+
if force or self.pool is False:
112+
self._close(conn)
113+
elif conn in self.active_conns:
107114
self.active_conns.remove(conn)
108-
if force:
109-
if self.logger:
110-
getattr(self.logger, self.logger_level)("Closing connection (forced)")
111-
conn.close()
112-
self.disconnected.send(self, conn=conn, close_conn=True)
113-
else:
114-
if self.logger:
115-
getattr(self.logger, self.logger_level)("Connection returned to pool")
116-
self.pool.append(conn)
117-
self.disconnected.send(self, conn=conn, close_conn=False)
118-
elif self.pool is False or force:
119115
if self.logger:
120-
getattr(self.logger, self.logger_level)("Closing connection")
121-
conn.close()
122-
self.disconnected.send(self, conn=conn, close_conn=True)
116+
getattr(self.logger, self.logger_level)("Returning connection to pool")
117+
self.pool.append(conn)
118+
self.pool_checkin.send(self, conn=conn)
123119
else:
124120
raise EngineError("Cannot close connection which is not part of pool")
121+
122+
def _close(self, conn):
123+
if self.logger:
124+
getattr(self.logger, self.logger_level)("Closing connection")
125+
conn.close()
126+
self.disconnected.send(self, conn=conn)
125127

126128
def disconnect_all(self):
127129
if self.pool is False:
@@ -130,7 +132,7 @@ def disconnect_all(self):
130132
getattr(self.logger, self.logger_level)("Closing all connections from pool")
131133
for conn in self.pool + self.active_conns:
132134
conn.close()
133-
self.disconnected.send(self, conn=conn, close_conn=True)
135+
self.disconnected.send(self, conn=conn)
134136
self.pool = []
135137
self.active_conns = []
136138

@@ -375,7 +377,8 @@ class Transaction:
375377
default_composite_separator = "__"
376378

377379
before_execute = _signals.signal("before-execute")
378-
before_executemany = _signals.signal("before-executemany")
380+
after_execute = _signals.signal("after-execute")
381+
handle_error = _signals.signal("handle-error")
379382

380383
def __init__(self, session, virtual=False):
381384
self.session = session
@@ -403,8 +406,10 @@ def cursor(self, stmt=None, params=None):
403406
return self.session.connect().cursor()
404407
stmt, params = render(stmt, params)
405408

406-
rv = _signal_rv(self.before_execute.send(self, stmt=stmt, params=params))
407-
if rv:
409+
rv = _signal_rv(self.before_execute.send(self, stmt=stmt, params=params, many=False))
410+
if isinstance(rv, tuple):
411+
stmt, params = rv
412+
elif rv:
408413
return rv
409414

410415
if self.session and self.session.logger:
@@ -413,10 +418,19 @@ def cursor(self, stmt=None, params=None):
413418
)
414419

415420
cur = self.session.connect().cursor()
416-
if params:
417-
cur.execute(stmt, params)
418-
else:
419-
cur.execute(stmt)
421+
try:
422+
# because the default value of params may depend on some engine
423+
if params:
424+
cur.execute(stmt, params)
425+
else:
426+
cur.execute(stmt)
427+
except Exception as e:
428+
rv = _signal_rv(self.handle_error.send(self, cursor=cur, stmt=stmt, params=params, exc=e, many=False))
429+
if rv:
430+
return rv
431+
raise
432+
433+
self.after_execute.send(self, cursor=cur, stmt=stmt, params=params, many=False)
420434
return cur
421435

422436
def execute(self, stmt, params=None):
@@ -428,9 +442,11 @@ def execute(self, stmt, params=None):
428442

429443
def executemany(self, stmt, seq_of_parameters):
430444
rv = _signal_rv(
431-
self.before_executemany.send(self, stmt=stmt, seq_of_parameters=seq_of_parameters)
445+
self.before_execute.send(self, stmt=stmt, params=seq_of_parameters, many=True)
432446
)
433-
if rv is False:
447+
if isinstance(rv, tuple):
448+
stmt, seq_of_parameters = rv
449+
elif rv is False:
434450
return
435451

436452
if self.session and self.session.logger:
@@ -439,7 +455,16 @@ def executemany(self, stmt, seq_of_parameters):
439455
)
440456

441457
cur = self.cursor()
442-
cur.executemany(str(stmt), seq_of_parameters)
458+
459+
try:
460+
cur.executemany(str(stmt), seq_of_parameters)
461+
except Exception as e:
462+
if not _signal_rv(
463+
self.handle_error.send(self, cursor=cur, stmt=stmt, params=seq_of_parameters, exc=e, many=True)
464+
):
465+
raise
466+
467+
self.after_execute.send(self, cursor=cur, stmt=stmt, params=seq_of_parameters, many=True)
443468
cur.close()
444469

445470
def fetch(self, stmt, params=None, model=None, obj=None, loader=None):
@@ -555,3 +580,21 @@ def _signal_rv(signal_rv):
555580
if rv:
556581
final_rv = rv
557582
return final_rv
583+
584+
585+
def connect_via_engine(engine, signal, func=None):
586+
def decorator(func):
587+
@functools.wraps(func)
588+
def wrapper(sender, **kw):
589+
matches = False
590+
if isinstance(sender, Engine):
591+
matches = sender is engine
592+
elif isinstance(sender, Session):
593+
matches = sender.engine is engine
594+
elif isinstance(sender, Transaction):
595+
matches = sender.session.engine is engine
596+
if matches:
597+
return func(sender, **kw)
598+
signal.connect(wrapper, weak=False)
599+
return wrapper
600+
return decorator(func) if func else decorator

sqlorm/opentelemetry/__init__.py

+107
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# Modified from opentelemetry-instrumentation-sqlalchemy
2+
from collections.abc import Sequence
3+
from typing import Collection
4+
5+
from wrapt import wrap_function_wrapper as _w
6+
7+
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
8+
from opentelemetry.instrumentation.utils import unwrap
9+
from opentelemetry.metrics import get_meter
10+
from opentelemetry.semconv.metrics import MetricInstruments
11+
from opentelemetry.trace import get_tracer
12+
13+
import sqlorm
14+
15+
from .tracer import (
16+
EngineTracer,
17+
_wrap_connect,
18+
_wrap_engine_init,
19+
)
20+
21+
22+
class SQLORMInstrumentor(BaseInstrumentor):
23+
"""An instrumentor for SQLORM
24+
See `BaseInstrumentor`
25+
"""
26+
27+
def _instrument(self, **kwargs):
28+
"""Instruments SQLORM engine creation methods and the engine
29+
if passed as an argument.
30+
31+
Args:
32+
**kwargs: Optional arguments
33+
``engine``: a SQLORM engine instance
34+
``engines``: a list of SQLORM engine instances
35+
``tracer_provider``: a TracerProvider, defaults to global
36+
``meter_provider``: a MeterProvider, defaults to global
37+
``enable_commenter``: bool to enable sqlcommenter, defaults to False
38+
``commenter_options``: dict of sqlcommenter config, defaults to {}
39+
40+
Returns:
41+
An instrumented engine if passed in as an argument or list of instrumented engines, None otherwise.
42+
"""
43+
tracer_provider = kwargs.get("tracer_provider")
44+
tracer = get_tracer(
45+
__name__,
46+
sqlorm.__version__,
47+
tracer_provider,
48+
schema_url="https://opentelemetry.io/schemas/1.11.0",
49+
)
50+
51+
meter_provider = kwargs.get("meter_provider")
52+
meter = get_meter(
53+
__name__,
54+
sqlorm.__version__,
55+
meter_provider,
56+
schema_url="https://opentelemetry.io/schemas/1.11.0",
57+
)
58+
59+
connections_usage = meter.create_up_down_counter(
60+
name=MetricInstruments.DB_CLIENT_CONNECTIONS_USAGE,
61+
unit="connections",
62+
description="The number of connections that are currently in state described by the state attribute.",
63+
)
64+
65+
enable_commenter = kwargs.get("enable_commenter", False)
66+
commenter_options = kwargs.get("commenter_options", {})
67+
68+
_w(
69+
"sqlorm.engine",
70+
"Engine.__init__",
71+
_wrap_engine_init(
72+
tracer, connections_usage, enable_commenter, commenter_options
73+
),
74+
)
75+
_w(
76+
"sqlorm.engine",
77+
"Engine._connect",
78+
_wrap_connect(tracer),
79+
)
80+
if kwargs.get("engine") is not None:
81+
return EngineTracer(
82+
tracer,
83+
kwargs.get("engine"),
84+
connections_usage,
85+
kwargs.get("enable_commenter", False),
86+
kwargs.get("commenter_options", {}),
87+
)
88+
if kwargs.get("engines") is not None and isinstance(
89+
kwargs.get("engines"), Sequence
90+
):
91+
return [
92+
EngineTracer(
93+
tracer,
94+
engine,
95+
connections_usage,
96+
kwargs.get("enable_commenter", False),
97+
kwargs.get("commenter_options", {}),
98+
)
99+
for engine in kwargs.get("engines")
100+
]
101+
102+
return None
103+
104+
def _uninstrument(self, **kwargs):
105+
unwrap(sqlorm.Engine, "__init__")
106+
unwrap(sqlorm.Engine, "_connect")
107+
EngineTracer.remove_all_event_listeners()

0 commit comments

Comments
 (0)