plain.models 0.36.0__py3-none-any.whl → 0.37.0__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.
- plain/models/CHANGELOG.md +11 -0
- plain/models/backends/utils.py +13 -10
- plain/models/otel.py +175 -0
- plain/models/test/pytest.py +16 -12
- plain/models/test/utils.py +5 -2
- {plain_models-0.36.0.dist-info → plain_models-0.37.0.dist-info}/METADATA +1 -1
- {plain_models-0.36.0.dist-info → plain_models-0.37.0.dist-info}/RECORD +10 -9
- {plain_models-0.36.0.dist-info → plain_models-0.37.0.dist-info}/WHEEL +0 -0
- {plain_models-0.36.0.dist-info → plain_models-0.37.0.dist-info}/entry_points.txt +0 -0
- {plain_models-0.36.0.dist-info → plain_models-0.37.0.dist-info}/licenses/LICENSE +0 -0
plain/models/CHANGELOG.md
CHANGED
@@ -1,5 +1,16 @@
|
|
1
1
|
# plain-models changelog
|
2
2
|
|
3
|
+
## [0.37.0](https://github.com/dropseed/plain/releases/plain-models@0.37.0) (2025-07-18)
|
4
|
+
|
5
|
+
### What's changed
|
6
|
+
|
7
|
+
- Added OpenTelemetry instrumentation for database operations - all SQL queries now automatically generate OpenTelemetry spans with standardized attributes following semantic conventions ([b0224d0](https://github.com/dropseed/plain/commit/b0224d0))
|
8
|
+
- Database operations in tests are now wrapped with tracing suppression to avoid generating telemetry noise during test execution ([b0224d0](https://github.com/dropseed/plain/commit/b0224d0))
|
9
|
+
|
10
|
+
### Upgrade instructions
|
11
|
+
|
12
|
+
- No changes required
|
13
|
+
|
3
14
|
## [0.36.0](https://github.com/dropseed/plain/releases/plain-models@0.36.0) (2025-07-18)
|
4
15
|
|
5
16
|
### What's changed
|
plain/models/backends/utils.py
CHANGED
@@ -7,6 +7,7 @@ from contextlib import contextmanager
|
|
7
7
|
from hashlib import md5
|
8
8
|
|
9
9
|
from plain.models.db import NotSupportedError
|
10
|
+
from plain.models.otel import db_span
|
10
11
|
from plain.utils.dateparse import parse_time
|
11
12
|
|
12
13
|
logger = logging.getLogger("plain.models.backends")
|
@@ -80,18 +81,20 @@ class CursorWrapper:
|
|
80
81
|
return executor(sql, params, many, context)
|
81
82
|
|
82
83
|
def _execute(self, sql, params, *ignored_wrapper_args):
|
83
|
-
|
84
|
-
with self.db
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
84
|
+
# Wrap in an OpenTelemetry span with standard attributes.
|
85
|
+
with db_span(self.db, sql, params=params):
|
86
|
+
self.db.validate_no_broken_transaction()
|
87
|
+
with self.db.wrap_database_errors:
|
88
|
+
if params is None:
|
89
|
+
return self.cursor.execute(sql)
|
90
|
+
else:
|
91
|
+
return self.cursor.execute(sql, params)
|
90
92
|
|
91
93
|
def _executemany(self, sql, param_list, *ignored_wrapper_args):
|
92
|
-
self.db
|
93
|
-
|
94
|
-
|
94
|
+
with db_span(self.db, sql, many=True, params=param_list):
|
95
|
+
self.db.validate_no_broken_transaction()
|
96
|
+
with self.db.wrap_database_errors:
|
97
|
+
return self.cursor.executemany(sql, param_list)
|
95
98
|
|
96
99
|
|
97
100
|
class CursorDebugWrapper(CursorWrapper):
|
plain/models/otel.py
ADDED
@@ -0,0 +1,175 @@
|
|
1
|
+
import re
|
2
|
+
from contextlib import contextmanager
|
3
|
+
from typing import Any
|
4
|
+
|
5
|
+
from opentelemetry import context as otel_context
|
6
|
+
from opentelemetry import trace
|
7
|
+
from opentelemetry.semconv._incubating.attributes.db_attributes import (
|
8
|
+
DB_QUERY_PARAMETER_TEMPLATE,
|
9
|
+
DB_USER,
|
10
|
+
)
|
11
|
+
from opentelemetry.semconv.attributes.db_attributes import (
|
12
|
+
DB_COLLECTION_NAME,
|
13
|
+
DB_NAMESPACE,
|
14
|
+
DB_OPERATION_NAME,
|
15
|
+
DB_QUERY_SUMMARY,
|
16
|
+
DB_QUERY_TEXT,
|
17
|
+
DB_SYSTEM_NAME,
|
18
|
+
)
|
19
|
+
from opentelemetry.semconv.attributes.network_attributes import (
|
20
|
+
NETWORK_PEER_ADDRESS,
|
21
|
+
NETWORK_PEER_PORT,
|
22
|
+
)
|
23
|
+
from opentelemetry.semconv.trace import DbSystemValues
|
24
|
+
from opentelemetry.trace import SpanKind
|
25
|
+
|
26
|
+
from plain.runtime import settings
|
27
|
+
|
28
|
+
_SUPPRESS_KEY = object()
|
29
|
+
|
30
|
+
tracer = trace.get_tracer("plain.models")
|
31
|
+
|
32
|
+
|
33
|
+
def db_system_for(vendor: str) -> str: # noqa: D401 – simple helper
|
34
|
+
"""Return the canonical ``db.system.name`` value for a backend vendor."""
|
35
|
+
|
36
|
+
return {
|
37
|
+
"postgresql": DbSystemValues.POSTGRESQL.value,
|
38
|
+
"mysql": DbSystemValues.MYSQL.value,
|
39
|
+
"mariadb": DbSystemValues.MARIADB.value,
|
40
|
+
"sqlite": DbSystemValues.SQLITE.value,
|
41
|
+
}.get(vendor, vendor)
|
42
|
+
|
43
|
+
|
44
|
+
def extract_operation_and_target(sql: str) -> tuple[str, str | None, str | None]:
|
45
|
+
"""Extract operation, table name, and collection from SQL.
|
46
|
+
|
47
|
+
Returns: (operation, summary, collection_name)
|
48
|
+
"""
|
49
|
+
sql_upper = sql.upper().strip()
|
50
|
+
operation = sql_upper.split()[0] if sql_upper else "UNKNOWN"
|
51
|
+
|
52
|
+
# Pattern to match quoted and unquoted identifiers
|
53
|
+
# Matches: "quoted", `quoted`, [quoted], unquoted.name
|
54
|
+
identifier_pattern = r'("([^"]+)"|`([^`]+)`|\[([^\]]+)\]|([\w.]+))'
|
55
|
+
|
56
|
+
# Extract table/collection name based on operation
|
57
|
+
collection_name = None
|
58
|
+
summary = operation
|
59
|
+
|
60
|
+
if operation in ("SELECT", "DELETE"):
|
61
|
+
match = re.search(rf"FROM\s+{identifier_pattern}", sql, re.IGNORECASE)
|
62
|
+
if match:
|
63
|
+
collection_name = _clean_identifier(match.group(1))
|
64
|
+
summary = f"{operation} {collection_name}"
|
65
|
+
|
66
|
+
elif operation in ("INSERT", "REPLACE"):
|
67
|
+
match = re.search(rf"INTO\s+{identifier_pattern}", sql, re.IGNORECASE)
|
68
|
+
if match:
|
69
|
+
collection_name = _clean_identifier(match.group(1))
|
70
|
+
summary = f"{operation} {collection_name}"
|
71
|
+
|
72
|
+
elif operation == "UPDATE":
|
73
|
+
match = re.search(rf"UPDATE\s+{identifier_pattern}", sql, re.IGNORECASE)
|
74
|
+
if match:
|
75
|
+
collection_name = _clean_identifier(match.group(1))
|
76
|
+
summary = f"{operation} {collection_name}"
|
77
|
+
|
78
|
+
return operation, summary, collection_name
|
79
|
+
|
80
|
+
|
81
|
+
def _clean_identifier(identifier: str) -> str:
|
82
|
+
"""Remove quotes from SQL identifiers."""
|
83
|
+
# Remove different types of SQL quotes
|
84
|
+
if identifier.startswith('"') and identifier.endswith('"'):
|
85
|
+
return identifier[1:-1]
|
86
|
+
elif identifier.startswith("`") and identifier.endswith("`"):
|
87
|
+
return identifier[1:-1]
|
88
|
+
elif identifier.startswith("[") and identifier.endswith("]"):
|
89
|
+
return identifier[1:-1]
|
90
|
+
return identifier
|
91
|
+
|
92
|
+
|
93
|
+
@contextmanager
|
94
|
+
def db_span(db, sql: Any, *, many: bool = False, params=None):
|
95
|
+
"""Open an OpenTelemetry CLIENT span for a database query.
|
96
|
+
|
97
|
+
All common attributes (`db.*`, `network.*`, etc.) are set automatically.
|
98
|
+
Follows OpenTelemetry semantic conventions for database instrumentation.
|
99
|
+
"""
|
100
|
+
|
101
|
+
# Fast-exit if instrumentation suppression flag set in context.
|
102
|
+
if otel_context.get_value(_SUPPRESS_KEY):
|
103
|
+
yield None
|
104
|
+
return
|
105
|
+
|
106
|
+
sql = str(sql) # Ensure SQL is a string for span attributes.
|
107
|
+
|
108
|
+
# Extract operation and target information
|
109
|
+
operation, summary, collection_name = extract_operation_and_target(sql)
|
110
|
+
|
111
|
+
if many:
|
112
|
+
summary = f"{summary} many"
|
113
|
+
|
114
|
+
# Span name follows semantic conventions: {target} or {db.operation.name} {target}
|
115
|
+
if summary:
|
116
|
+
span_name = summary[:255]
|
117
|
+
else:
|
118
|
+
span_name = operation
|
119
|
+
|
120
|
+
# Build attribute set following semantic conventions
|
121
|
+
attrs: dict[str, Any] = {
|
122
|
+
DB_SYSTEM_NAME: db_system_for(db.vendor),
|
123
|
+
DB_NAMESPACE: db.settings_dict.get("NAME"),
|
124
|
+
DB_QUERY_TEXT: sql, # Already parameterized from Django/Plain
|
125
|
+
DB_QUERY_SUMMARY: summary,
|
126
|
+
DB_OPERATION_NAME: operation,
|
127
|
+
}
|
128
|
+
|
129
|
+
# Add collection name if detected
|
130
|
+
if collection_name:
|
131
|
+
attrs[DB_COLLECTION_NAME] = collection_name
|
132
|
+
|
133
|
+
# Add user attribute
|
134
|
+
if user := db.settings_dict.get("USER"):
|
135
|
+
attrs[DB_USER] = user
|
136
|
+
|
137
|
+
# Network attributes
|
138
|
+
if host := db.settings_dict.get("HOST"):
|
139
|
+
attrs[NETWORK_PEER_ADDRESS] = host
|
140
|
+
|
141
|
+
if port := db.settings_dict.get("PORT"):
|
142
|
+
try:
|
143
|
+
attrs[NETWORK_PEER_PORT] = int(port)
|
144
|
+
except (TypeError, ValueError):
|
145
|
+
pass
|
146
|
+
|
147
|
+
# Add query parameters as attributes when DEBUG is True
|
148
|
+
if settings.DEBUG and params is not None:
|
149
|
+
# Convert params to appropriate format based on type
|
150
|
+
if isinstance(params, dict):
|
151
|
+
# Dictionary params (e.g., for named placeholders)
|
152
|
+
for i, (key, value) in enumerate(params.items()):
|
153
|
+
attrs[f"{DB_QUERY_PARAMETER_TEMPLATE}.{key}"] = str(value)
|
154
|
+
elif isinstance(params, list | tuple):
|
155
|
+
# Sequential params (e.g., for %s or ? placeholders)
|
156
|
+
for i, value in enumerate(params):
|
157
|
+
attrs[f"{DB_QUERY_PARAMETER_TEMPLATE}.{i + 1}"] = str(value)
|
158
|
+
else:
|
159
|
+
# Single param (rare but possible)
|
160
|
+
attrs[f"{DB_QUERY_PARAMETER_TEMPLATE}.1"] = str(params)
|
161
|
+
|
162
|
+
with tracer.start_as_current_span(
|
163
|
+
span_name, kind=SpanKind.CLIENT, attributes=attrs
|
164
|
+
) as span:
|
165
|
+
yield span
|
166
|
+
span.set_status(trace.StatusCode.OK)
|
167
|
+
|
168
|
+
|
169
|
+
@contextmanager
|
170
|
+
def suppress_db_tracing():
|
171
|
+
token = otel_context.attach(otel_context.set_value(_SUPPRESS_KEY, True))
|
172
|
+
try:
|
173
|
+
yield
|
174
|
+
finally:
|
175
|
+
otel_context.detach(token)
|
plain/models/test/pytest.py
CHANGED
@@ -2,6 +2,7 @@ import re
|
|
2
2
|
|
3
3
|
import pytest
|
4
4
|
|
5
|
+
from plain.models.otel import suppress_db_tracing
|
5
6
|
from plain.signals import request_finished, request_started
|
6
7
|
|
7
8
|
from .. import transaction
|
@@ -60,29 +61,32 @@ def setup_db(request):
|
|
60
61
|
def db(setup_db, request):
|
61
62
|
if "isolated_db" in request.fixturenames:
|
62
63
|
pytest.fail("The 'db' and 'isolated_db' fixtures cannot be used together")
|
64
|
+
|
63
65
|
# Set .cursor() back to the original implementation to unblock it
|
64
66
|
BaseDatabaseWrapper.cursor = BaseDatabaseWrapper._enabled_cursor
|
65
67
|
|
66
68
|
if not db_connection.features.supports_transactions:
|
67
69
|
pytest.fail("Database does not support transactions")
|
68
70
|
|
69
|
-
|
70
|
-
|
71
|
-
|
71
|
+
with suppress_db_tracing():
|
72
|
+
atomic = transaction.atomic()
|
73
|
+
atomic._from_testcase = True # TODO remove this somehow?
|
74
|
+
atomic.__enter__()
|
72
75
|
|
73
76
|
yield
|
74
77
|
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
78
|
+
with suppress_db_tracing():
|
79
|
+
if (
|
80
|
+
db_connection.features.can_defer_constraint_checks
|
81
|
+
and not db_connection.needs_rollback
|
82
|
+
and db_connection.is_usable()
|
83
|
+
):
|
84
|
+
db_connection.check_constraints()
|
81
85
|
|
82
|
-
|
83
|
-
|
86
|
+
db_connection.set_rollback(True)
|
87
|
+
atomic.__exit__(None, None, None)
|
84
88
|
|
85
|
-
|
89
|
+
db_connection.close()
|
86
90
|
|
87
91
|
|
88
92
|
@pytest.fixture
|
plain/models/test/utils.py
CHANGED
@@ -1,11 +1,14 @@
|
|
1
1
|
from plain.models import db_connection
|
2
|
+
from plain.models.otel import suppress_db_tracing
|
2
3
|
|
3
4
|
|
4
5
|
def setup_database(*, verbosity, prefix=""):
|
5
6
|
old_name = db_connection.settings_dict["NAME"]
|
6
|
-
|
7
|
+
with suppress_db_tracing():
|
8
|
+
db_connection.creation.create_test_db(verbosity=verbosity, prefix=prefix)
|
7
9
|
return old_name
|
8
10
|
|
9
11
|
|
10
12
|
def teardown_database(old_name, verbosity):
|
11
|
-
|
13
|
+
with suppress_db_tracing():
|
14
|
+
db_connection.creation.destroy_test_db(old_name, verbosity)
|
@@ -1,4 +1,4 @@
|
|
1
|
-
plain/models/CHANGELOG.md,sha256=
|
1
|
+
plain/models/CHANGELOG.md,sha256=jfmC_PUfV3p29nRVGxSnDztzD46CIRRud69XtAbPF-Q,5162
|
2
2
|
plain/models/README.md,sha256=vsZPev3Fna-Irdcs3-wrOcAoII5LOhXdcWLBMT87CS0,1626
|
3
3
|
plain/models/__init__.py,sha256=dnU6MOXs3lGoK31nLWjCqbf7zigkaUccomchz9lNDJ8,2950
|
4
4
|
plain/models/aggregates.py,sha256=P0mhsMl1VZt2CVHMuCHnNI8SxZ9citjDLEgioN6NOpo,7240
|
@@ -21,6 +21,7 @@ plain/models/indexes.py,sha256=fazIZPJgCX5_Bhwk7MQy3YbWOxpHvaCe1dDLGGldTuY,11540
|
|
21
21
|
plain/models/lookups.py,sha256=0tbuMBpd4DlTUeO0IdZPtSO2GcjsSgcbRcj5lYfe87M,24776
|
22
22
|
plain/models/manager.py,sha256=zc2W-vTTk3zkDXCds5-TCXgLhVmM4PdQb-qtu-njeLQ,5827
|
23
23
|
plain/models/options.py,sha256=AxlxzHY_cKIcuWESeIDM02fzknGl4KN0-Bp5XWr3IVk,23829
|
24
|
+
plain/models/otel.py,sha256=OCG6ZXbaQmAwvjjAHwH6ISFXJ651W942m2dFu87Pmi8,5893
|
24
25
|
plain/models/preflight.py,sha256=PlS1S2YHEpSKZ57KbTP6TbED98dDDXYSBUk6xMIpgsI,8136
|
25
26
|
plain/models/query.py,sha256=DEqiSsNPNtPX6_Cycwemq5XH99CoCi_7y42BEtBi5cE,92648
|
26
27
|
plain/models/query_utils.py,sha256=EtKxtyAk36Jou0Uz4beADGENEucQymziVtfp7uJjoSQ,14254
|
@@ -29,7 +30,7 @@ plain/models/transaction.py,sha256=KqkRDT6aqMgbPA_ch7qO8a9NyDvwY_2FaxM7FkBkcgY,9
|
|
29
30
|
plain/models/utils.py,sha256=rD47CAMH4SsznTe-kUnRUdnaZeZHVv1fwLUiU3KOFW0,1630
|
30
31
|
plain/models/backends/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
31
32
|
plain/models/backends/ddl_references.py,sha256=rPgGp1mjcZyIo6aA8OjNvPRv42yCBbvVgk9IEHAcAGc,8096
|
32
|
-
plain/models/backends/utils.py,sha256=
|
33
|
+
plain/models/backends/utils.py,sha256=VN9b_hnGeLqndVAcCx00X7KhFC6jY2Tn6J3E62HqDEE,10005
|
33
34
|
plain/models/backends/base/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
34
35
|
plain/models/backends/base/base.py,sha256=I5040pUAe7-yNTb9wfMNOYjeiFQLZ5dcRI4rtmVKJ04,26457
|
35
36
|
plain/models/backends/base/client.py,sha256=90Ffs6zZYCli3tJjwsPH8TItZ8tz1Pp-zhQa-EpsNqc,937
|
@@ -111,10 +112,10 @@ plain/models/sql/query.py,sha256=jSnzBoM66dTR-4aJpO-T2ksOc5PSeIQ07ToPEzYj58U,108
|
|
111
112
|
plain/models/sql/subqueries.py,sha256=JkVjYuWyEBpSDFNMOH00RmXxe8V4cERjUQ_ypGepqyo,5847
|
112
113
|
plain/models/sql/where.py,sha256=ezE9Clt2BmKo-I7ARsgqZ_aVA-1UdayCwr6ULSWZL6c,12635
|
113
114
|
plain/models/test/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
114
|
-
plain/models/test/pytest.py,sha256=
|
115
|
-
plain/models/test/utils.py,sha256=
|
116
|
-
plain_models-0.
|
117
|
-
plain_models-0.
|
118
|
-
plain_models-0.
|
119
|
-
plain_models-0.
|
120
|
-
plain_models-0.
|
115
|
+
plain/models/test/pytest.py,sha256=KD5-mxonBxOYIhUh9Ql5uJOIiC9R4t-LYfb6sjA0UdE,3486
|
116
|
+
plain/models/test/utils.py,sha256=S3d6zf3OFWDxB_kBJr0tDvwn51bjwDVWKPumv37N-p8,467
|
117
|
+
plain_models-0.37.0.dist-info/METADATA,sha256=glPHfgeEYKGSRwOGyDgRljuBZz7hw1L_mshPrS931T8,1921
|
118
|
+
plain_models-0.37.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
119
|
+
plain_models-0.37.0.dist-info/entry_points.txt,sha256=IYJAW9MpL3PXyXFWmKmALagAGXC_5rzBn2eEGJlcV04,112
|
120
|
+
plain_models-0.37.0.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
|
121
|
+
plain_models-0.37.0.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|