snake-orm 0.1.0b1__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.
- snake_orm-0.1.0b1.dist-info/METADATA +366 -0
- snake_orm-0.1.0b1.dist-info/RECORD +198 -0
- snake_orm-0.1.0b1.dist-info/WHEEL +4 -0
- snake_orm-0.1.0b1.dist-info/entry_points.txt +2 -0
- snake_orm-0.1.0b1.dist-info/licenses/LICENSE +21 -0
- snakeorm/__init__.py +214 -0
- snakeorm/advisor.py +109 -0
- snakeorm/cli/__init__.py +3 -0
- snakeorm/cli/app.py +992 -0
- snakeorm/cli/discovery.py +179 -0
- snakeorm/cli/hooks.py +86 -0
- snakeorm/compiler/__init__.py +3 -0
- snakeorm/compiler/compiler.py +330 -0
- snakeorm/connection.py +228 -0
- snakeorm/contrib/__init__.py +17 -0
- snakeorm/contrib/asgi.py +192 -0
- snakeorm/contrib/config.py +264 -0
- snakeorm/contrib/deliver.py +256 -0
- snakeorm/contrib/django.py +245 -0
- snakeorm/contrib/sidecar.py +44 -0
- snakeorm/contrib/wsgi.py +176 -0
- snakeorm/core/__init__.py +1 -0
- snakeorm/core/config.py +173 -0
- snakeorm/core/converters.py +172 -0
- snakeorm/core/exceptions.py +175 -0
- snakeorm/core/placement.py +15 -0
- snakeorm/core/sentinels.py +37 -0
- snakeorm/core/signals.py +114 -0
- snakeorm/debug/__init__.py +42 -0
- snakeorm/debug/assets/css/panel.css +616 -0
- snakeorm/debug/assets/css/snake_orm_app.css +293 -0
- snakeorm/debug/assets/img/icon.svg +5 -0
- snakeorm/debug/assets/js/history.js +425 -0
- snakeorm/debug/assets/js/language.js +283 -0
- snakeorm/debug/assets/js/panel.js +241 -0
- snakeorm/debug/assets/js/snake_orm_app.js +82 -0
- snakeorm/debug/assets/pages/cli.html +28 -0
- snakeorm/debug/assets/pages/config.html +34 -0
- snakeorm/debug/assets/pages/database_first.html +29 -0
- snakeorm/debug/assets/pages/help.html +21 -0
- snakeorm/debug/assets/pages/history.html +27 -0
- snakeorm/debug/capture.py +234 -0
- snakeorm/debug/channel.py +131 -0
- snakeorm/debug/collector.py +144 -0
- snakeorm/debug/config.py +149 -0
- snakeorm/debug/html.py +578 -0
- snakeorm/debug/matrix.py +60 -0
- snakeorm/debug/origin.py +42 -0
- snakeorm/debug/otel/__init__.py +68 -0
- snakeorm/debug/otel/context.py +43 -0
- snakeorm/debug/otel/exporter.py +277 -0
- snakeorm/debug/otel/payload.py +97 -0
- snakeorm/debug/otel/report.py +29 -0
- snakeorm/debug/otel/spans.py +393 -0
- snakeorm/debug/otel/summary.py +59 -0
- snakeorm/debug/record.py +54 -0
- snakeorm/debug/report.py +311 -0
- snakeorm/debug/testing.py +23 -0
- snakeorm/decorators/__init__.py +13 -0
- snakeorm/decorators/abstract.py +26 -0
- snakeorm/decorators/db_first.py +104 -0
- snakeorm/decorators/function.py +28 -0
- snakeorm/decorators/model.py +268 -0
- snakeorm/decorators/polymorphic.py +140 -0
- snakeorm/decorators/result.py +167 -0
- snakeorm/decorators/row.py +90 -0
- snakeorm/decorators/trigger.py +47 -0
- snakeorm/decorators/view.py +149 -0
- snakeorm/dialects/__init__.py +6 -0
- snakeorm/dialects/base.py +373 -0
- snakeorm/dialects/capabilities.py +571 -0
- snakeorm/dialects/literals.py +46 -0
- snakeorm/dialects/matrix.py +384 -0
- snakeorm/dialects/mysql.py +519 -0
- snakeorm/dialects/postgres.py +504 -0
- snakeorm/dialects/sqlite.py +417 -0
- snakeorm/drivers/__init__.py +18 -0
- snakeorm/drivers/asyncbase.py +73 -0
- snakeorm/drivers/asyncdecorators.py +244 -0
- snakeorm/drivers/asyncpool.py +228 -0
- snakeorm/drivers/asyncpsycopg.py +124 -0
- snakeorm/drivers/asyncpymysql.py +50 -0
- snakeorm/drivers/asyncsqlite.py +30 -0
- snakeorm/drivers/base.py +76 -0
- snakeorm/drivers/failures.py +181 -0
- snakeorm/drivers/logging.py +167 -0
- snakeorm/drivers/pool.py +352 -0
- snakeorm/drivers/psycopg.py +188 -0
- snakeorm/drivers/pymysql.py +176 -0
- snakeorm/drivers/savepoints.py +46 -0
- snakeorm/drivers/sqlite.py +170 -0
- snakeorm/drivers/threaded.py +143 -0
- snakeorm/drivers/timeout.py +87 -0
- snakeorm/dto/__init__.py +55 -0
- snakeorm/dto/read.py +231 -0
- snakeorm/dto/region.py +326 -0
- snakeorm/dto/resolve.py +354 -0
- snakeorm/dto/spec.py +201 -0
- snakeorm/expressions/__init__.py +88 -0
- snakeorm/expressions/conditional.py +178 -0
- snakeorm/expressions/expression.py +810 -0
- snakeorm/expressions/functions.py +71 -0
- snakeorm/expressions/keys.py +299 -0
- snakeorm/expressions/paths.py +43 -0
- snakeorm/expressions/scalar.py +293 -0
- snakeorm/expressions/window.py +248 -0
- snakeorm/fields/__init__.py +68 -0
- snakeorm/fields/check.py +79 -0
- snakeorm/fields/column.py +316 -0
- snakeorm/fields/enum.py +64 -0
- snakeorm/fields/index.py +46 -0
- snakeorm/fields/relationship.py +738 -0
- snakeorm/fields/typed.py +736 -0
- snakeorm/helpers/__init__.py +1 -0
- snakeorm/helpers/annotations.py +21 -0
- snakeorm/helpers/inheritance.py +41 -0
- snakeorm/helpers/pyliteral.py +52 -0
- snakeorm/helpers/pytype.py +50 -0
- snakeorm/introspection/__init__.py +13 -0
- snakeorm/introspection/base.py +30 -0
- snakeorm/introspection/drift.py +78 -0
- snakeorm/introspection/models.py +633 -0
- snakeorm/introspection/mysql.py +367 -0
- snakeorm/introspection/postgres.py +397 -0
- snakeorm/introspection/sqlite.py +217 -0
- snakeorm/introspection/unsupported.py +94 -0
- snakeorm/linker/__init__.py +3 -0
- snakeorm/linker/linker.py +521 -0
- snakeorm/metadata/__init__.py +41 -0
- snakeorm/metadata/check.py +37 -0
- snakeorm/metadata/column.py +201 -0
- snakeorm/metadata/enum_storage.py +33 -0
- snakeorm/metadata/fk_action.py +19 -0
- snakeorm/metadata/foreign_key.py +26 -0
- snakeorm/metadata/index.py +43 -0
- snakeorm/metadata/index_method.py +19 -0
- snakeorm/metadata/int_size.py +20 -0
- snakeorm/metadata/json_storage.py +21 -0
- snakeorm/metadata/polymorphic.py +30 -0
- snakeorm/metadata/primary_key.py +23 -0
- snakeorm/metadata/relationship.py +60 -0
- snakeorm/metadata/relationship_kind.py +43 -0
- snakeorm/metadata/routine.py +26 -0
- snakeorm/metadata/server_default.py +20 -0
- snakeorm/metadata/table.py +97 -0
- snakeorm/metadata/table_kind.py +23 -0
- snakeorm/metadata/trigger.py +62 -0
- snakeorm/metadata/type_params.py +348 -0
- snakeorm/migration/__init__.py +88 -0
- snakeorm/migration/asyncrunner.py +168 -0
- snakeorm/migration/autodetect.py +96 -0
- snakeorm/migration/ddl.py +1172 -0
- snakeorm/migration/diff.py +701 -0
- snakeorm/migration/loader.py +77 -0
- snakeorm/migration/operations.py +952 -0
- snakeorm/migration/planner.py +78 -0
- snakeorm/migration/realize.py +420 -0
- snakeorm/migration/renames.py +153 -0
- snakeorm/migration/render.py +1095 -0
- snakeorm/migration/runner.py +314 -0
- snakeorm/migration/squash.py +60 -0
- snakeorm/migration/state.py +107 -0
- snakeorm/model.py +162 -0
- snakeorm/py.typed +0 -0
- snakeorm/query/__init__.py +9 -0
- snakeorm/query/compound.py +288 -0
- snakeorm/query/join_kind.py +19 -0
- snakeorm/query/joined.py +143 -0
- snakeorm/query/query.py +1001 -0
- snakeorm/query/recursive.py +244 -0
- snakeorm/registry/__init__.py +5 -0
- snakeorm/registry/by_module.py +163 -0
- snakeorm/registry/registry.py +166 -0
- snakeorm/session/__init__.py +8 -0
- snakeorm/session/asyncsession.py +638 -0
- snakeorm/session/coercion.py +306 -0
- snakeorm/session/factory.py +29 -0
- snakeorm/session/guards.py +269 -0
- snakeorm/session/isolation.py +23 -0
- snakeorm/session/mapper.py +213 -0
- snakeorm/session/planning.py +815 -0
- snakeorm/session/retry.py +96 -0
- snakeorm/session/session.py +871 -0
- snakeorm/session/shared.py +355 -0
- snakeorm/sql/__init__.py +18 -0
- snakeorm/sql/adapt.py +104 -0
- snakeorm/sql/aggregate.py +232 -0
- snakeorm/sql/condition.py +359 -0
- snakeorm/sql/delete.py +46 -0
- snakeorm/sql/insert.py +149 -0
- snakeorm/sql/joins.py +158 -0
- snakeorm/sql/pk_subquery.py +54 -0
- snakeorm/sql/refs.py +19 -0
- snakeorm/sql/resolver.py +28 -0
- snakeorm/sql/select.py +175 -0
- snakeorm/sql/update.py +82 -0
- snakeorm/sql/value.py +552 -0
- snakeorm/times.py +276 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""The HYBRID shape of a trace: one root span, one CLIENT child per query, aggregates on both.
|
|
2
|
+
|
|
3
|
+
Three shapes were compared in Jaeger's own interface, not in JSON, and this one won:
|
|
4
|
+
|
|
5
|
+
- A single BLOB span is invisible to the search. One operation, nothing to group by, and the whole
|
|
6
|
+
report inside an attribute — the panel of SnakeORM stuffed into Jaeger, paying for Jaeger without
|
|
7
|
+
using it.
|
|
8
|
+
- A purely MAPPED tree loses `warnings`, `index_hints` and `summary`: the convention has no
|
|
9
|
+
equivalent for them, so a strict mapping simply drops them.
|
|
10
|
+
- The HYBRID keeps both halves. The children make `db.collection.name` and `code.line.number`
|
|
11
|
+
groupable, which is how Trace Statistics re-derives the ORM's own `(sql, origin)` grouping AND
|
|
12
|
+
adds the share of the cost, which the panel does not give. The aggregates ride on the root as
|
|
13
|
+
attributes —searchable ACROSS traces, which is what answers "show me every request with an
|
|
14
|
+
N+1"— and as events, so they get a row on the timeline.
|
|
15
|
+
|
|
16
|
+
Nothing here executes or sends: it is a pure mapping from a `DebugReport` to spans.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from enum import IntEnum
|
|
24
|
+
from time import perf_counter, time_ns
|
|
25
|
+
from typing import Protocol
|
|
26
|
+
|
|
27
|
+
from snakeorm.debug.otel.context import TraceContext
|
|
28
|
+
from snakeorm.debug.otel.summary import summarise
|
|
29
|
+
from snakeorm.debug.report import DebugReport, RequestInfo
|
|
30
|
+
from snakeorm.debug.record import QueryRecord
|
|
31
|
+
|
|
32
|
+
AttributeValue = str | int | float | bool | tuple[str, ...]
|
|
33
|
+
"""What an OTLP attribute can hold. Deliberately narrow: no nesting, no `Any`."""
|
|
34
|
+
|
|
35
|
+
# --- Semantic conventions, v1.44 ------------------------------------------------------------------
|
|
36
|
+
# Written as literals and NOT imported from `opentelemetry.semconv`: the channel has to work with the
|
|
37
|
+
# API alone, and with nothing installed at all. `src/test/debug/otel/test_semconv_names.py` checks
|
|
38
|
+
# them against the installed package so the literals cannot go stale in silence.
|
|
39
|
+
DB_SYSTEM_NAME = "db.system.name"
|
|
40
|
+
DB_QUERY_TEXT = "db.query.text"
|
|
41
|
+
DB_QUERY_SUMMARY = "db.query.summary"
|
|
42
|
+
DB_COLLECTION_NAME = "db.collection.name"
|
|
43
|
+
DB_NAMESPACE = "db.namespace"
|
|
44
|
+
DB_OPERATION_NAME = "db.operation.name"
|
|
45
|
+
DB_RESPONSE_RETURNED_ROWS = "db.response.returned_rows"
|
|
46
|
+
DB_QUERY_PARAMETER_PREFIX = "db.query.parameter."
|
|
47
|
+
CODE_FILE_PATH = "code.file.path"
|
|
48
|
+
CODE_LINE_NUMBER = "code.line.number"
|
|
49
|
+
CODE_FUNCTION_NAME = "code.function.name"
|
|
50
|
+
HTTP_REQUEST_METHOD = "http.request.method"
|
|
51
|
+
HTTP_RESPONSE_STATUS_CODE = "http.response.status_code"
|
|
52
|
+
URL_PATH = "url.path"
|
|
53
|
+
|
|
54
|
+
# --- What the convention has no name for, under our own prefix ------------------------------------
|
|
55
|
+
SNAKEORM_QUERY_COUNT = "snakeorm.query_count"
|
|
56
|
+
SNAKEORM_DB_MS = "snakeorm.db_ms"
|
|
57
|
+
SNAKEORM_WALL_MS = "snakeorm.wall_ms"
|
|
58
|
+
# Rides next to `app_ms` because it is the half that was taken OUT of it: `app_ms` is
|
|
59
|
+
# `wall - db - mapping`, so a root that omits this one does not add up.
|
|
60
|
+
SNAKEORM_MAPPING_MS = "snakeorm.mapping_ms"
|
|
61
|
+
SNAKEORM_APP_MS = "snakeorm.app_ms"
|
|
62
|
+
SNAKEORM_DUPLICATE_GROUPS = "snakeorm.duplicate_groups"
|
|
63
|
+
SNAKEORM_WORST_REPEAT_COUNT = "snakeorm.worst_repeat_count"
|
|
64
|
+
SNAKEORM_HAS_N_PLUS_ONE = "snakeorm.has_n_plus_one"
|
|
65
|
+
SNAKEORM_SUMMARY = "snakeorm.summary"
|
|
66
|
+
SNAKEORM_WARNINGS = "snakeorm.warnings"
|
|
67
|
+
SNAKEORM_WARNING = "snakeorm.warning"
|
|
68
|
+
SNAKEORM_INDEX_HINTS = "snakeorm.index_hints"
|
|
69
|
+
SNAKEORM_INDEX_HINT = "snakeorm.index_hint"
|
|
70
|
+
SNAKEORM_COLUMN = "snakeorm.column"
|
|
71
|
+
SNAKEORM_WORST_MS = "snakeorm.worst_ms"
|
|
72
|
+
SNAKEORM_REPEAT_COUNT = "snakeorm.repeat_count"
|
|
73
|
+
SNAKEORM_N = "snakeorm.n"
|
|
74
|
+
SNAKEORM_KIND = "snakeorm.kind"
|
|
75
|
+
|
|
76
|
+
ROOT_SPAN_NAME = "snakeorm"
|
|
77
|
+
"""The root's name when there is no request to name it after (a script, a CLI command, a test)."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class SpanKind(IntEnum):
|
|
81
|
+
"""OTLP's span kinds, by their protocol numbers (the payload spells them as integers)."""
|
|
82
|
+
|
|
83
|
+
INTERNAL = 1
|
|
84
|
+
SERVER = 2
|
|
85
|
+
CLIENT = 3
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True, slots=True)
|
|
89
|
+
class SpanEvent:
|
|
90
|
+
"""A point in time inside a span: what gives an aggregate a ROW on Jaeger's timeline."""
|
|
91
|
+
|
|
92
|
+
name: str
|
|
93
|
+
time_unix_nano: int
|
|
94
|
+
attributes: tuple[tuple[str, AttributeValue], ...] = ()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True, slots=True)
|
|
98
|
+
class SnakeSpan:
|
|
99
|
+
"""One span, already resolved: ids in hex, instants in Unix nanoseconds, attributes flattened.
|
|
100
|
+
|
|
101
|
+
`parent_span_id` is an empty string when there is no parent; the payload then OMITS the field,
|
|
102
|
+
because an empty parent and no parent are different things to a collector.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
trace_id: str
|
|
106
|
+
span_id: str
|
|
107
|
+
parent_span_id: str
|
|
108
|
+
name: str
|
|
109
|
+
kind: SpanKind
|
|
110
|
+
start_unix_nano: int
|
|
111
|
+
end_unix_nano: int
|
|
112
|
+
attributes: tuple[tuple[str, AttributeValue], ...]
|
|
113
|
+
events: tuple[SpanEvent, ...] = ()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class IdSource(Protocol):
|
|
117
|
+
"""Where the ids come from. A Protocol so a test can assert the tree instead of randomness."""
|
|
118
|
+
|
|
119
|
+
def trace_id(self) -> str:
|
|
120
|
+
"""A fresh 32-character hex trace id."""
|
|
121
|
+
...
|
|
122
|
+
|
|
123
|
+
def span_id(self) -> str:
|
|
124
|
+
"""A fresh 16-character hex span id."""
|
|
125
|
+
...
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class RandomIds:
|
|
129
|
+
"""Ids from `os.urandom`, which is what an id needs to be: unique, not meaningful."""
|
|
130
|
+
|
|
131
|
+
__slots__ = ()
|
|
132
|
+
|
|
133
|
+
def trace_id(self) -> str:
|
|
134
|
+
"""16 random bytes in hex, the width OTLP gives a trace."""
|
|
135
|
+
return os.urandom(16).hex()
|
|
136
|
+
|
|
137
|
+
def span_id(self) -> str:
|
|
138
|
+
"""8 random bytes in hex, the width OTLP gives a span."""
|
|
139
|
+
return os.urandom(8).hex()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def monotonic_epoch_ns() -> int:
|
|
143
|
+
"""The Unix nanosecond a `perf_counter()` reading of `0.0` would name.
|
|
144
|
+
|
|
145
|
+
`perf_counter` is monotonic and has no origin, so a reading means nothing on its own; anchoring
|
|
146
|
+
it to the wall clock ONCE per export turns every reading into an absolute instant while keeping
|
|
147
|
+
the ORDER and the DISTANCES the monotonic clock measured. That is exactly the trade a timeline
|
|
148
|
+
wants: the wall clock can step (NTP), the differences between queries cannot.
|
|
149
|
+
"""
|
|
150
|
+
return time_ns() - int(perf_counter() * 1_000_000_000)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def spans_from_report(
|
|
154
|
+
report: DebugReport,
|
|
155
|
+
*,
|
|
156
|
+
parent: TraceContext | None = None,
|
|
157
|
+
ids: IdSource | None = None,
|
|
158
|
+
epoch_ns: int | None = None,
|
|
159
|
+
parameter_keys: frozenset[str] = frozenset(),
|
|
160
|
+
) -> tuple[SnakeSpan, ...]:
|
|
161
|
+
"""Map a report to its spans: the root first, then one CLIENT child per statement.
|
|
162
|
+
|
|
163
|
+
`parent` is the application's active span, when it has one: the root then hangs off it as an
|
|
164
|
+
INTERNAL section instead of claiming to be the request's server span. `parameter_keys` names
|
|
165
|
+
the parameter positions to send, and is empty by default — the convention makes parameters
|
|
166
|
+
opt-in and there is no way to ask for them in bulk.
|
|
167
|
+
"""
|
|
168
|
+
source = RandomIds() if ids is None else ids
|
|
169
|
+
anchor = monotonic_epoch_ns() if epoch_ns is None else epoch_ns
|
|
170
|
+
trace_id = source.trace_id() if parent is None else parent.trace_id
|
|
171
|
+
root_id = source.span_id()
|
|
172
|
+
|
|
173
|
+
children = tuple(
|
|
174
|
+
_child_span(
|
|
175
|
+
record,
|
|
176
|
+
trace_id=trace_id,
|
|
177
|
+
parent_span_id=root_id,
|
|
178
|
+
span_id=source.span_id(),
|
|
179
|
+
epoch_ns=anchor,
|
|
180
|
+
parameter_keys=parameter_keys,
|
|
181
|
+
)
|
|
182
|
+
for record in report.records
|
|
183
|
+
)
|
|
184
|
+
root = _root_span(
|
|
185
|
+
report,
|
|
186
|
+
trace_id=trace_id,
|
|
187
|
+
span_id=root_id,
|
|
188
|
+
parent=parent,
|
|
189
|
+
children=children,
|
|
190
|
+
epoch_ns=anchor,
|
|
191
|
+
)
|
|
192
|
+
return (root, *children)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _child_span(
|
|
196
|
+
record: QueryRecord,
|
|
197
|
+
*,
|
|
198
|
+
trace_id: str,
|
|
199
|
+
parent_span_id: str,
|
|
200
|
+
span_id: str,
|
|
201
|
+
epoch_ns: int,
|
|
202
|
+
parameter_keys: frozenset[str],
|
|
203
|
+
) -> SnakeSpan:
|
|
204
|
+
"""One statement as a CLIENT span, named after its summary and carrying the `db.*` attributes."""
|
|
205
|
+
summary = summarise(record.sql)
|
|
206
|
+
start = epoch_ns + int(record.started_at * 1_000_000_000)
|
|
207
|
+
attributes: list[tuple[str, AttributeValue]] = [
|
|
208
|
+
(DB_QUERY_TEXT, record.sql),
|
|
209
|
+
(DB_QUERY_SUMMARY, summary.text),
|
|
210
|
+
(DB_OPERATION_NAME, summary.operation),
|
|
211
|
+
(DB_RESPONSE_RETURNED_ROWS, record.rows),
|
|
212
|
+
(SNAKEORM_N, record.n),
|
|
213
|
+
(SNAKEORM_KIND, record.kind.value),
|
|
214
|
+
]
|
|
215
|
+
if summary.collection:
|
|
216
|
+
attributes.append((DB_COLLECTION_NAME, summary.collection))
|
|
217
|
+
if summary.namespace:
|
|
218
|
+
attributes.append((DB_NAMESPACE, summary.namespace))
|
|
219
|
+
if record.system:
|
|
220
|
+
attributes.append((DB_SYSTEM_NAME, record.system))
|
|
221
|
+
if record.origin is not None:
|
|
222
|
+
attributes.append((CODE_FILE_PATH, record.origin.file))
|
|
223
|
+
attributes.append((CODE_LINE_NUMBER, record.origin.line))
|
|
224
|
+
attributes.append((CODE_FUNCTION_NAME, record.origin.function))
|
|
225
|
+
attributes.extend(_parameters(record, parameter_keys))
|
|
226
|
+
return SnakeSpan(
|
|
227
|
+
trace_id=trace_id,
|
|
228
|
+
span_id=span_id,
|
|
229
|
+
parent_span_id=parent_span_id,
|
|
230
|
+
name=summary.text or ROOT_SPAN_NAME,
|
|
231
|
+
kind=SpanKind.CLIENT,
|
|
232
|
+
start_unix_nano=start,
|
|
233
|
+
end_unix_nano=start + int(record.duration_ms * 1_000_000),
|
|
234
|
+
attributes=tuple(attributes),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _parameters(
|
|
239
|
+
record: QueryRecord, parameter_keys: frozenset[str]
|
|
240
|
+
) -> list[tuple[str, AttributeValue]]:
|
|
241
|
+
"""The parameters explicitly ASKED for, one key at a time. Empty by default, which is the point.
|
|
242
|
+
|
|
243
|
+
The convention collects the PARAMETRISED text by default "because parametrising is a strong
|
|
244
|
+
signal from the user that anything sensitive is in the values", and makes the values themselves
|
|
245
|
+
opt-in. SnakeORM never interpolates, so the text is safe by construction and the values are the
|
|
246
|
+
only thing that could carry user data — which is why they are named one by one and never in
|
|
247
|
+
bulk. The key of a positional parameter is its 0-based index, as the convention says.
|
|
248
|
+
"""
|
|
249
|
+
if not parameter_keys:
|
|
250
|
+
return []
|
|
251
|
+
return [
|
|
252
|
+
(f"{DB_QUERY_PARAMETER_PREFIX}{index}", str(value))
|
|
253
|
+
for index, value in enumerate(record.params)
|
|
254
|
+
if str(index) in parameter_keys
|
|
255
|
+
]
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _root_span(
|
|
259
|
+
report: DebugReport,
|
|
260
|
+
*,
|
|
261
|
+
trace_id: str,
|
|
262
|
+
span_id: str,
|
|
263
|
+
parent: TraceContext | None,
|
|
264
|
+
children: tuple[SnakeSpan, ...],
|
|
265
|
+
epoch_ns: int,
|
|
266
|
+
) -> SnakeSpan:
|
|
267
|
+
"""The span of the whole unit of work, with the aggregates as attributes AND as events."""
|
|
268
|
+
start, end = _root_window(report, children, epoch_ns)
|
|
269
|
+
duplicates = report.duplicates()
|
|
270
|
+
attributes: list[tuple[str, AttributeValue]] = [
|
|
271
|
+
(SNAKEORM_SUMMARY, report.summary),
|
|
272
|
+
(SNAKEORM_QUERY_COUNT, report.count),
|
|
273
|
+
(SNAKEORM_DB_MS, round(report.total_ms, 3)),
|
|
274
|
+
(SNAKEORM_DUPLICATE_GROUPS, len(duplicates)),
|
|
275
|
+
(SNAKEORM_HAS_N_PLUS_ONE, bool(duplicates)),
|
|
276
|
+
(
|
|
277
|
+
SNAKEORM_WORST_REPEAT_COUNT,
|
|
278
|
+
max((group.count for group in duplicates), default=0),
|
|
279
|
+
),
|
|
280
|
+
]
|
|
281
|
+
if report.wall_ms is not None:
|
|
282
|
+
attributes.append((SNAKEORM_WALL_MS, round(report.wall_ms, 3)))
|
|
283
|
+
if report.mapping_ms is not None:
|
|
284
|
+
attributes.append((SNAKEORM_MAPPING_MS, round(report.mapping_ms, 3)))
|
|
285
|
+
if report.app_ms is not None:
|
|
286
|
+
attributes.append((SNAKEORM_APP_MS, round(report.app_ms, 3)))
|
|
287
|
+
if report.warnings:
|
|
288
|
+
attributes.append((SNAKEORM_WARNINGS, report.warnings))
|
|
289
|
+
if report.index_hints:
|
|
290
|
+
attributes.append((SNAKEORM_INDEX_HINTS, _hint_texts(report)))
|
|
291
|
+
attributes.extend(_request_attributes(report.request))
|
|
292
|
+
return SnakeSpan(
|
|
293
|
+
trace_id=trace_id,
|
|
294
|
+
span_id=span_id,
|
|
295
|
+
parent_span_id="" if parent is None else parent.span_id,
|
|
296
|
+
name=_root_name(report.request),
|
|
297
|
+
kind=SpanKind.SERVER if parent is None else SpanKind.INTERNAL,
|
|
298
|
+
start_unix_nano=start,
|
|
299
|
+
end_unix_nano=end,
|
|
300
|
+
attributes=tuple(attributes),
|
|
301
|
+
events=_root_events(report, at=end),
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _root_window(
|
|
306
|
+
report: DebugReport, children: tuple[SnakeSpan, ...], epoch_ns: int
|
|
307
|
+
) -> tuple[int, int]:
|
|
308
|
+
"""The root's start and end, wide enough to CONTAIN every child.
|
|
309
|
+
|
|
310
|
+
Two clocks meet here and they must not be allowed to disagree visibly. The request's instant
|
|
311
|
+
(`RequestInfo.at`) comes off the wall clock; the children come off the monotonic one anchored to
|
|
312
|
+
it. In a live process the two are taken microseconds apart and the containment is free — the
|
|
313
|
+
adapters read them on the same line. Widening to the children's extremes costs nothing there and
|
|
314
|
+
guarantees a tree Jaeger can draw when it is not, which is better than a child that renders
|
|
315
|
+
outside its parent.
|
|
316
|
+
"""
|
|
317
|
+
starts = [child.start_unix_nano for child in children]
|
|
318
|
+
ends = [child.end_unix_nano for child in children]
|
|
319
|
+
if report.request is None:
|
|
320
|
+
opened = min(starts, default=epoch_ns + int(perf_counter() * 1_000_000_000))
|
|
321
|
+
else:
|
|
322
|
+
opened = int(report.request.at.timestamp() * 1_000_000_000)
|
|
323
|
+
closed = opened + int((report.wall_ms or 0.0) * 1_000_000)
|
|
324
|
+
return min([opened, *starts]), max([closed, *ends])
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _root_name(request: RequestInfo | None) -> str:
|
|
328
|
+
"""`GET /orders` when there is a request; the package's name when there is not."""
|
|
329
|
+
if request is None:
|
|
330
|
+
return ROOT_SPAN_NAME
|
|
331
|
+
return f"{request.method} {request.path}".strip()
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _request_attributes(
|
|
335
|
+
request: RequestInfo | None,
|
|
336
|
+
) -> list[tuple[str, AttributeValue]]:
|
|
337
|
+
"""The HTTP half of the root, under the convention's names. Empty for a standalone report."""
|
|
338
|
+
if request is None:
|
|
339
|
+
return []
|
|
340
|
+
return [
|
|
341
|
+
(HTTP_REQUEST_METHOD, request.method),
|
|
342
|
+
(URL_PATH, request.path),
|
|
343
|
+
(HTTP_RESPONSE_STATUS_CODE, request.status),
|
|
344
|
+
]
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _hint_texts(report: DebugReport) -> tuple[str, ...]:
|
|
348
|
+
"""The advisor's suggestions in the SAME wording the envelope uses: one message, one spelling."""
|
|
349
|
+
return tuple(
|
|
350
|
+
f"{table}.{column} ({ms:.0f}ms)" for table, column, ms in report.index_hints
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _root_events(report: DebugReport, *, at: int) -> tuple[SpanEvent, ...]:
|
|
355
|
+
"""Summary, warnings and index hints as events, so each gets a row on the timeline.
|
|
356
|
+
|
|
357
|
+
They are stamped at the END of the span because that is when they exist: the duplicates are only
|
|
358
|
+
known once every statement has run.
|
|
359
|
+
"""
|
|
360
|
+
events = [
|
|
361
|
+
SpanEvent(
|
|
362
|
+
name=SNAKEORM_SUMMARY,
|
|
363
|
+
time_unix_nano=at,
|
|
364
|
+
attributes=((SNAKEORM_SUMMARY, report.summary),),
|
|
365
|
+
)
|
|
366
|
+
]
|
|
367
|
+
for group, warning in zip(report.duplicates(), report.warnings, strict=True):
|
|
368
|
+
attributes: list[tuple[str, AttributeValue]] = [
|
|
369
|
+
(SNAKEORM_WARNING, warning),
|
|
370
|
+
(SNAKEORM_REPEAT_COUNT, group.count),
|
|
371
|
+
(DB_QUERY_TEXT, group.sql),
|
|
372
|
+
]
|
|
373
|
+
if group.origin is not None:
|
|
374
|
+
attributes.append((CODE_FILE_PATH, group.origin.file))
|
|
375
|
+
attributes.append((CODE_LINE_NUMBER, group.origin.line))
|
|
376
|
+
events.append(
|
|
377
|
+
SpanEvent(
|
|
378
|
+
name=SNAKEORM_WARNING, time_unix_nano=at, attributes=tuple(attributes)
|
|
379
|
+
)
|
|
380
|
+
)
|
|
381
|
+
for table, column, worst_ms in report.index_hints:
|
|
382
|
+
events.append(
|
|
383
|
+
SpanEvent(
|
|
384
|
+
name=SNAKEORM_INDEX_HINT,
|
|
385
|
+
time_unix_nano=at,
|
|
386
|
+
attributes=(
|
|
387
|
+
(DB_COLLECTION_NAME, table),
|
|
388
|
+
(SNAKEORM_COLUMN, column),
|
|
389
|
+
(SNAKEORM_WORST_MS, round(worst_ms, 3)),
|
|
390
|
+
),
|
|
391
|
+
)
|
|
392
|
+
)
|
|
393
|
+
return tuple(events)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""`db.query.summary`: the short shape of a statement, which is what NAMES the span.
|
|
2
|
+
|
|
3
|
+
The convention is explicit that the span name is the summary and NOT the query text. Jaeger gives
|
|
4
|
+
`db.query.text` no special formatting, so a span named after the whole SQL is an unreadable row; a
|
|
5
|
+
span named `SELECT orders` reads at a glance and groups with its five hundred siblings.
|
|
6
|
+
|
|
7
|
+
It is a HEURISTIC over already-emitted SQL, not a parser, and it says so: the verb is the first word
|
|
8
|
+
and the collection is the first table the statement names. Anything it cannot read comes back empty,
|
|
9
|
+
never wrong.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
# The first word: the verb (SELECT, INSERT, CREATE...). Anything else gives no operation.
|
|
18
|
+
_OPERATION = re.compile(r"^\s*([A-Za-z]+)")
|
|
19
|
+
|
|
20
|
+
# The first table the statement names, however the engine quotes it (`"x"`, `` `x` ``, bare), and
|
|
21
|
+
# QUALIFIED or not. The optional first group is the schema: SnakeORM emits `"public"."users"` against
|
|
22
|
+
# Postgres, so a pattern that stopped at the first identifier named the SCHEMA on every single span.
|
|
23
|
+
_COLLECTION = re.compile(
|
|
24
|
+
r"\b(?:FROM|INTO|UPDATE|TABLE|VIEW|INDEX)\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?"
|
|
25
|
+
r'(?:["`]?([A-Za-z_]\w*)["`]?\.)?["`]?([A-Za-z_]\w*)',
|
|
26
|
+
re.IGNORECASE,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class QuerySummary:
|
|
32
|
+
"""A statement's `db.operation.name`, `db.collection.name` and `db.namespace`, plus the summary.
|
|
33
|
+
|
|
34
|
+
The namespace is the SCHEMA half of a qualified name (`"public"."users"`). It is kept apart
|
|
35
|
+
because the convention keeps it apart, and because folding it in made every span of a real
|
|
36
|
+
request read `SELECT public`.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
operation: str
|
|
40
|
+
collection: str
|
|
41
|
+
namespace: str = ""
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def text(self) -> str:
|
|
45
|
+
"""`db.query.summary`: `SELECT orders`, or the verb alone when no table is named."""
|
|
46
|
+
if not self.collection:
|
|
47
|
+
return self.operation
|
|
48
|
+
return f"{self.operation} {self.collection}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def summarise(sql: str) -> QuerySummary:
|
|
52
|
+
"""The verb and the table of an emitted statement. Empty pieces when it cannot tell."""
|
|
53
|
+
operation = _OPERATION.match(sql)
|
|
54
|
+
collection = _COLLECTION.search(sql)
|
|
55
|
+
return QuerySummary(
|
|
56
|
+
operation="" if operation is None else operation.group(1).upper(),
|
|
57
|
+
collection="" if collection is None else collection.group(2),
|
|
58
|
+
namespace="" if collection is None else (collection.group(1) or ""),
|
|
59
|
+
)
|
snakeorm/debug/record.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""A `QueryRecord`: the atom of the debug, one captured SQL statement. Immutable (`frozen`): a measurement already taken is not touched."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class QueryKind(StrEnum):
|
|
10
|
+
"""Which kind of statement it was, so reads can be told from writes in the panel."""
|
|
11
|
+
|
|
12
|
+
SELECT = "select" # came from fetch_all (SELECT / RETURNING)
|
|
13
|
+
WRITE = "write" # came from execute (INSERT / UPDATE / DELETE / DDL)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class QueryOrigin:
|
|
18
|
+
"""Where the query came from: the USER code frame that fired it (file, line, function).
|
|
19
|
+
|
|
20
|
+
It is the "who" that Django Debug Toolbar gives: with the origin you find the code guilty of
|
|
21
|
+
the extra calls (an N+1 or a duplicate), instead of merely seeing that they exist.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
file: str
|
|
25
|
+
line: int
|
|
26
|
+
function: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class QueryRecord:
|
|
31
|
+
"""One captured statement: order, SQL, params, duration, rows and ORIGIN.
|
|
32
|
+
|
|
33
|
+
`n` is the (1-based) sequence within the scope, so they can be listed in the ORDER they ran (that is how you read an N+1); `rows` are rows returned (SELECT) or affected (a write); `origin` is the user frame that fired it (or `None` if it could not be resolved).
|
|
34
|
+
|
|
35
|
+
`started_at` is a `perf_counter()` reading, monotonic and meaningless on its own: only
|
|
36
|
+
differences between two of them mean anything. It is kept because a duration cannot place a
|
|
37
|
+
query on a TIMELINE — a span needs a start and an end, and synthesising the start makes the
|
|
38
|
+
timeline lie. It costs nothing to keep: the capture driver already reads that clock to measure
|
|
39
|
+
the duration, so this is the first half of a subtraction that was happening anyway.
|
|
40
|
+
|
|
41
|
+
`system` is what OpenTelemetry calls the engine (`db.system.name`: `postgresql`, `mysql`,
|
|
42
|
+
`mariadb`, `sqlite`). It is DECLARED by whoever wires the capture driver, never guessed from the
|
|
43
|
+
SQL; empty means undeclared, and the exporter then omits the attribute instead of inventing one.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
n: int
|
|
47
|
+
sql: str
|
|
48
|
+
params: tuple[object, ...]
|
|
49
|
+
duration_ms: float
|
|
50
|
+
rows: int
|
|
51
|
+
kind: QueryKind
|
|
52
|
+
origin: QueryOrigin | None = None
|
|
53
|
+
started_at: float = 0.0
|
|
54
|
+
system: str = ""
|