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,68 @@
|
|
|
1
|
+
"""The `otel` channel: the debug report as OpenTelemetry spans, over OTLP/HTTP.
|
|
2
|
+
|
|
3
|
+
It is the one delivery meant for PRODUCTION, and the only one whose consumer is a tool rather than a
|
|
4
|
+
person. Its audience is `OPERATOR`: it goes out sideways to infrastructure the operator already
|
|
5
|
+
runs, instead of riding on the response. That is why it is NOT in `RISKY_CHANNELS`: putting it there
|
|
6
|
+
would drop it exactly where it justifies existing.
|
|
7
|
+
|
|
8
|
+
Riding on the response is NOT what makes a channel risky — `timing` rides it and is harmless. What
|
|
9
|
+
decides it is whether the requester ends up holding the SQL, which is what `ChannelAudience` asks.
|
|
10
|
+
|
|
11
|
+
What travels is the parametrised SQL and never the values. The convention collects `db.query.text`
|
|
12
|
+
by default "because parametrising is a strong signal from the user that anything sensitive is in
|
|
13
|
+
the values", and SnakeORM never interpolates. The values are opt-in, key by key, from code.
|
|
14
|
+
|
|
15
|
+
Four pieces:
|
|
16
|
+
|
|
17
|
+
- `context` — the only thing `opentelemetry-api` is for: is a span already open, and which.
|
|
18
|
+
- `summary` — `db.query.summary`, which NAMES the span (`SELECT orders`, not the whole SQL).
|
|
19
|
+
- `spans` — the pure mapping from a `DebugReport` to the hybrid shape.
|
|
20
|
+
- `payload` / `exporter` — OTLP's JSON and the background queue that posts it.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from snakeorm.debug.otel.context import TraceContext as TraceContext
|
|
24
|
+
from snakeorm.debug.otel.context import active_context as active_context
|
|
25
|
+
from snakeorm.debug.otel.exporter import DEFAULT_ENDPOINT as DEFAULT_ENDPOINT
|
|
26
|
+
from snakeorm.debug.otel.exporter import DEFAULT_SERVICE_NAME as DEFAULT_SERVICE_NAME
|
|
27
|
+
from snakeorm.debug.otel.exporter import ENDPOINT_ENV_KEY as ENDPOINT_ENV_KEY
|
|
28
|
+
from snakeorm.debug.otel.exporter import OtelExporter as OtelExporter
|
|
29
|
+
from snakeorm.debug.otel.exporter import SERVICE_NAME_ENV_KEY as SERVICE_NAME_ENV_KEY
|
|
30
|
+
from snakeorm.debug.otel.exporter import (
|
|
31
|
+
TRACES_ENDPOINT_ENV_KEY as TRACES_ENDPOINT_ENV_KEY,
|
|
32
|
+
)
|
|
33
|
+
from snakeorm.debug.otel.exporter import Transport as Transport
|
|
34
|
+
from snakeorm.debug.otel.exporter import current_exporter as current_exporter
|
|
35
|
+
from snakeorm.debug.otel.exporter import endpoint_from_env as endpoint_from_env
|
|
36
|
+
from snakeorm.debug.otel.exporter import post_json as post_json
|
|
37
|
+
from snakeorm.debug.otel.exporter import service_name_from_env as service_name_from_env
|
|
38
|
+
from snakeorm.debug.otel.exporter import set_exporter as set_exporter
|
|
39
|
+
from snakeorm.debug.otel.payload import SCOPE_NAME as SCOPE_NAME
|
|
40
|
+
from snakeorm.debug.otel.payload import encode_payload as encode_payload
|
|
41
|
+
from snakeorm.debug.otel.payload import otlp_payload as otlp_payload
|
|
42
|
+
from snakeorm.debug.otel.report import export_report as export_report
|
|
43
|
+
from snakeorm.debug.otel.spans import CODE_FILE_PATH as CODE_FILE_PATH
|
|
44
|
+
from snakeorm.debug.otel.spans import CODE_FUNCTION_NAME as CODE_FUNCTION_NAME
|
|
45
|
+
from snakeorm.debug.otel.spans import CODE_LINE_NUMBER as CODE_LINE_NUMBER
|
|
46
|
+
from snakeorm.debug.otel.spans import DB_COLLECTION_NAME as DB_COLLECTION_NAME
|
|
47
|
+
from snakeorm.debug.otel.spans import DB_NAMESPACE as DB_NAMESPACE
|
|
48
|
+
from snakeorm.debug.otel.spans import DB_OPERATION_NAME as DB_OPERATION_NAME
|
|
49
|
+
from snakeorm.debug.otel.spans import (
|
|
50
|
+
DB_QUERY_PARAMETER_PREFIX as DB_QUERY_PARAMETER_PREFIX,
|
|
51
|
+
)
|
|
52
|
+
from snakeorm.debug.otel.spans import DB_QUERY_SUMMARY as DB_QUERY_SUMMARY
|
|
53
|
+
from snakeorm.debug.otel.spans import DB_QUERY_TEXT as DB_QUERY_TEXT
|
|
54
|
+
from snakeorm.debug.otel.spans import (
|
|
55
|
+
DB_RESPONSE_RETURNED_ROWS as DB_RESPONSE_RETURNED_ROWS,
|
|
56
|
+
)
|
|
57
|
+
from snakeorm.debug.otel.spans import DB_SYSTEM_NAME as DB_SYSTEM_NAME
|
|
58
|
+
from snakeorm.debug.otel.spans import SNAKEORM_HAS_N_PLUS_ONE as SNAKEORM_HAS_N_PLUS_ONE
|
|
59
|
+
from snakeorm.debug.otel.spans import AttributeValue as AttributeValue
|
|
60
|
+
from snakeorm.debug.otel.spans import IdSource as IdSource
|
|
61
|
+
from snakeorm.debug.otel.spans import RandomIds as RandomIds
|
|
62
|
+
from snakeorm.debug.otel.spans import SnakeSpan as SnakeSpan
|
|
63
|
+
from snakeorm.debug.otel.spans import SpanEvent as SpanEvent
|
|
64
|
+
from snakeorm.debug.otel.spans import SpanKind as SpanKind
|
|
65
|
+
from snakeorm.debug.otel.spans import monotonic_epoch_ns as monotonic_epoch_ns
|
|
66
|
+
from snakeorm.debug.otel.spans import spans_from_report as spans_from_report
|
|
67
|
+
from snakeorm.debug.otel.summary import QuerySummary as QuerySummary
|
|
68
|
+
from snakeorm.debug.otel.summary import summarise as summarise
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Reading the trace context the APPLICATION already has open, if it has one.
|
|
2
|
+
|
|
3
|
+
This is the ONLY thing `opentelemetry-api` is needed for, and it is the only thing that cannot be
|
|
4
|
+
reimplemented: a process-wide context variable owned by a library we do not control. Everything else
|
|
5
|
+
in this channel —the payload, the HTTP POST, the queue— is stdlib.
|
|
6
|
+
|
|
7
|
+
It degrades three ways and none of them raises: no library installed, no provider active, or a
|
|
8
|
+
provider that misbehaves. All three give `None`, which turns our span into a loose root instead of a
|
|
9
|
+
child.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class TraceContext:
|
|
19
|
+
"""The active span, in the hex form OTLP/JSON wants: 32 characters of trace, 16 of span."""
|
|
20
|
+
|
|
21
|
+
trace_id: str
|
|
22
|
+
span_id: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def active_context() -> TraceContext | None:
|
|
26
|
+
"""The application's active span, or `None` if there is none to hang off.
|
|
27
|
+
|
|
28
|
+
`ImportError` is the expected path for an application that never installed the extra, so it is
|
|
29
|
+
caught rather than guarded with a flag. The broad catch underneath is deliberate too: a
|
|
30
|
+
telemetry read has no business turning into a 500.
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
from opentelemetry import trace
|
|
34
|
+
|
|
35
|
+
span_context = trace.get_current_span().get_span_context()
|
|
36
|
+
if not span_context.is_valid:
|
|
37
|
+
return None
|
|
38
|
+
return TraceContext(
|
|
39
|
+
trace_id=format(span_context.trace_id, "032x"),
|
|
40
|
+
span_id=format(span_context.span_id, "016x"),
|
|
41
|
+
)
|
|
42
|
+
except Exception:
|
|
43
|
+
return None
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Sending the spans: OTLP over HTTP, from a BACKGROUND QUEUE, with `urllib` and nothing else.
|
|
2
|
+
|
|
3
|
+
The queue is not an optimisation and it is not deferred to a later version. Measured against
|
|
4
|
+
localhost, exporting in line adds ~210 ms to a request of 503 queries; on the asynchronous path that
|
|
5
|
+
does not slow one request down, it blocks the WHOLE event loop. So `submit` reads the active trace
|
|
6
|
+
context —which has to happen on the calling thread, because that is where the context lives— hands
|
|
7
|
+
the report to a worker and returns.
|
|
8
|
+
|
|
9
|
+
Two failure modes, and both are declared rather than swallowed. A full queue DROPS (telemetry must
|
|
10
|
+
never make an application wait) and counts the drop. A transport that fails counts the failure and
|
|
11
|
+
warns ONCE, naming the endpoint: a silence would be the exact fault this ORM exists not to commit,
|
|
12
|
+
and a warning per request would be a log nobody reads.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import atexit
|
|
18
|
+
import os
|
|
19
|
+
import queue
|
|
20
|
+
import threading
|
|
21
|
+
import warnings
|
|
22
|
+
from collections.abc import Callable, Mapping
|
|
23
|
+
from typing import TYPE_CHECKING
|
|
24
|
+
|
|
25
|
+
from snakeorm.core.exceptions import SnakeWarning
|
|
26
|
+
from snakeorm.debug.otel.context import TraceContext, active_context
|
|
27
|
+
from snakeorm.debug.otel.payload import encode_payload, otlp_payload
|
|
28
|
+
from snakeorm.debug.otel.spans import spans_from_report
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from snakeorm.debug.report import DebugReport
|
|
32
|
+
|
|
33
|
+
Transport = Callable[[str, bytes], None]
|
|
34
|
+
"""How the body actually leaves: `(endpoint, body) -> None`. Injected, so it is testable offline."""
|
|
35
|
+
|
|
36
|
+
DEFAULT_ENDPOINT = "http://localhost:4318/v1/traces"
|
|
37
|
+
"""The local OTLP/HTTP port, which is the one `docker compose --profile tracing` publishes."""
|
|
38
|
+
|
|
39
|
+
DEFAULT_SERVICE_NAME = "snakeorm"
|
|
40
|
+
"""What Jaeger's Service dropdown says when the application did not name itself."""
|
|
41
|
+
|
|
42
|
+
TRACES_ENDPOINT_ENV_KEY = "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"
|
|
43
|
+
ENDPOINT_ENV_KEY = "OTEL_EXPORTER_OTLP_ENDPOINT"
|
|
44
|
+
SERVICE_NAME_ENV_KEY = "OTEL_SERVICE_NAME"
|
|
45
|
+
# The STANDARD variable names, not ours. Anyone who has configured any other exporter has already
|
|
46
|
+
# set them, and a private spelling would be one more thing to discover.
|
|
47
|
+
|
|
48
|
+
_TRACES_PATH = "/v1/traces"
|
|
49
|
+
_HTTP_TIMEOUT_SECONDS = 5.0
|
|
50
|
+
_DEFAULT_QUEUE_SIZE = 256
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def post_json(endpoint: str, body: bytes) -> None:
|
|
54
|
+
"""POST the payload as `application/json`. The response body is read and dropped: only the send matters.
|
|
55
|
+
|
|
56
|
+
`urllib.request` is imported HERE and not at the top, which is the one lazy import in this
|
|
57
|
+
package and the one that earns it. `snakeorm/__init__` re-exports `snakeorm.debug`, which
|
|
58
|
+
re-exports `export_report`, which lives in this module — so a module-level import would put an
|
|
59
|
+
HTTP client (plus `http.client`, `email.*` and `ssl`, ~17 ms measured) into the startup of every
|
|
60
|
+
application that uses the ORM, including the ones that never turn this channel on. The channel
|
|
61
|
+
promises that switching it off costs nothing; an import is part of that promise.
|
|
62
|
+
"""
|
|
63
|
+
import urllib.request
|
|
64
|
+
|
|
65
|
+
request = urllib.request.Request(
|
|
66
|
+
endpoint,
|
|
67
|
+
data=body,
|
|
68
|
+
headers={"Content-Type": "application/json"},
|
|
69
|
+
method="POST",
|
|
70
|
+
)
|
|
71
|
+
with urllib.request.urlopen(request, timeout=_HTTP_TIMEOUT_SECONDS) as response:
|
|
72
|
+
response.read()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def endpoint_from_env(environ: Mapping[str, str] | None = None) -> str:
|
|
76
|
+
"""The traces endpoint: the specific variable, then the generic one plus `/v1/traces`, then local."""
|
|
77
|
+
env = _environ(environ)
|
|
78
|
+
specific = env.get(TRACES_ENDPOINT_ENV_KEY, "").strip()
|
|
79
|
+
if specific:
|
|
80
|
+
return specific
|
|
81
|
+
generic = env.get(ENDPOINT_ENV_KEY, "").strip()
|
|
82
|
+
if generic:
|
|
83
|
+
return f"{generic.rstrip('/')}{_TRACES_PATH}"
|
|
84
|
+
return DEFAULT_ENDPOINT
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def service_name_from_env(environ: Mapping[str, str] | None = None) -> str:
|
|
88
|
+
"""The service name (`OTEL_SERVICE_NAME`), or ours when the application did not declare one."""
|
|
89
|
+
return (
|
|
90
|
+
_environ(environ).get(SERVICE_NAME_ENV_KEY, "").strip() or DEFAULT_SERVICE_NAME
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _environ(environ: Mapping[str, str] | None) -> Mapping[str, str]:
|
|
95
|
+
"""The given mapping, or the real environment. Injected in tests, read for real everywhere else."""
|
|
96
|
+
return os.environ if environ is None else environ
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class OtelExporter:
|
|
100
|
+
"""Turns reports into OTLP spans and posts them, from a worker thread of its own.
|
|
101
|
+
|
|
102
|
+
One per process (see `current_exporter`): one thread and one queue, not one per request.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
__slots__ = (
|
|
106
|
+
"_dropped",
|
|
107
|
+
"_endpoint",
|
|
108
|
+
"_failures",
|
|
109
|
+
"_lock",
|
|
110
|
+
"_parameter_keys",
|
|
111
|
+
"_pending",
|
|
112
|
+
"_queue",
|
|
113
|
+
"_service_name",
|
|
114
|
+
"_transport",
|
|
115
|
+
"_warned",
|
|
116
|
+
"_worker",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def __init__(
|
|
120
|
+
self,
|
|
121
|
+
*,
|
|
122
|
+
endpoint: str,
|
|
123
|
+
service_name: str,
|
|
124
|
+
transport: Transport = post_json,
|
|
125
|
+
queue_size: int = _DEFAULT_QUEUE_SIZE,
|
|
126
|
+
parameter_keys: frozenset[str] = frozenset(),
|
|
127
|
+
) -> None:
|
|
128
|
+
"""`parameter_keys` names the query parameters to send, and there is NO environment variable for it.
|
|
129
|
+
|
|
130
|
+
That omission is the decision: an environment variable is precisely the switch somebody
|
|
131
|
+
flips in production by accident, and this one would send user data to a tracing backend. It
|
|
132
|
+
takes an explicit line of code, key by key.
|
|
133
|
+
"""
|
|
134
|
+
self._endpoint = endpoint
|
|
135
|
+
self._service_name = service_name
|
|
136
|
+
self._transport = transport
|
|
137
|
+
self._parameter_keys = parameter_keys
|
|
138
|
+
self._queue: queue.Queue[tuple[DebugReport, TraceContext | None] | None] = (
|
|
139
|
+
queue.Queue(maxsize=queue_size)
|
|
140
|
+
)
|
|
141
|
+
self._lock = threading.Condition()
|
|
142
|
+
self._pending = 0
|
|
143
|
+
self._dropped = 0
|
|
144
|
+
self._failures = 0
|
|
145
|
+
self._warned = False
|
|
146
|
+
self._worker: threading.Thread | None = None
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def endpoint(self) -> str:
|
|
150
|
+
"""Where the spans are posted."""
|
|
151
|
+
return self._endpoint
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def service_name(self) -> str:
|
|
155
|
+
"""What the resource calls this service."""
|
|
156
|
+
return self._service_name
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def dropped(self) -> int:
|
|
160
|
+
"""How many reports were discarded because the queue was full."""
|
|
161
|
+
return self._dropped
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def failures(self) -> int:
|
|
165
|
+
"""How many sends the transport refused."""
|
|
166
|
+
return self._failures
|
|
167
|
+
|
|
168
|
+
def submit(self, report: DebugReport) -> None:
|
|
169
|
+
"""Queue a report and RETURN. Nothing is serialised or sent on the calling thread.
|
|
170
|
+
|
|
171
|
+
The trace context is read HERE and not in the worker: it lives in a `ContextVar` belonging
|
|
172
|
+
to the request's task, and a worker thread cannot see it.
|
|
173
|
+
"""
|
|
174
|
+
item = (report, active_context())
|
|
175
|
+
with self._lock:
|
|
176
|
+
self._start_worker()
|
|
177
|
+
try:
|
|
178
|
+
self._queue.put_nowait(item)
|
|
179
|
+
except queue.Full:
|
|
180
|
+
self._dropped += 1
|
|
181
|
+
return
|
|
182
|
+
self._pending += 1
|
|
183
|
+
|
|
184
|
+
def send_now(self, report: DebugReport, *, parent: TraceContext | None) -> None:
|
|
185
|
+
"""Build the payload and post it, synchronously. The worker's body, and the test's seam."""
|
|
186
|
+
try:
|
|
187
|
+
spans = spans_from_report(
|
|
188
|
+
report, parent=parent, parameter_keys=self._parameter_keys
|
|
189
|
+
)
|
|
190
|
+
body = encode_payload(otlp_payload(spans, service_name=self._service_name))
|
|
191
|
+
self._transport(self._endpoint, body)
|
|
192
|
+
except (
|
|
193
|
+
Exception
|
|
194
|
+
) as error: # a collector that is down is not an application error
|
|
195
|
+
self._record_failure(error)
|
|
196
|
+
|
|
197
|
+
def flush(self, timeout: float = 5.0) -> bool:
|
|
198
|
+
"""Wait until the queue has drained. `True` if it did, `False` if the wait ran out."""
|
|
199
|
+
with self._lock:
|
|
200
|
+
return self._lock.wait_for(lambda: self._pending == 0, timeout=timeout)
|
|
201
|
+
|
|
202
|
+
def shutdown(self, timeout: float = 5.0) -> None:
|
|
203
|
+
"""Drain what is queued and stop the worker. Registered at exit for the process exporter."""
|
|
204
|
+
self.flush(timeout)
|
|
205
|
+
with self._lock:
|
|
206
|
+
worker, self._worker = self._worker, None
|
|
207
|
+
if worker is None:
|
|
208
|
+
return
|
|
209
|
+
self._queue.put(None)
|
|
210
|
+
worker.join(timeout)
|
|
211
|
+
|
|
212
|
+
def _start_worker(self) -> None:
|
|
213
|
+
"""Start the worker on first use. Daemon: a stuck send never keeps the process alive."""
|
|
214
|
+
if self._worker is not None:
|
|
215
|
+
return
|
|
216
|
+
self._worker = threading.Thread(
|
|
217
|
+
target=self._drain, name="snakeorm-otel", daemon=True
|
|
218
|
+
)
|
|
219
|
+
self._worker.start()
|
|
220
|
+
|
|
221
|
+
def _drain(self) -> None:
|
|
222
|
+
"""Take reports off the queue and send them, one at a time, until the sentinel arrives."""
|
|
223
|
+
while True:
|
|
224
|
+
item = self._queue.get()
|
|
225
|
+
if item is None:
|
|
226
|
+
return
|
|
227
|
+
report, parent = item
|
|
228
|
+
try:
|
|
229
|
+
self.send_now(report, parent=parent)
|
|
230
|
+
finally:
|
|
231
|
+
with self._lock:
|
|
232
|
+
self._pending -= 1
|
|
233
|
+
self._lock.notify_all()
|
|
234
|
+
|
|
235
|
+
def _record_failure(self, error: Exception) -> None:
|
|
236
|
+
"""Count the failure and warn about the FIRST one, naming the endpoint and the reason."""
|
|
237
|
+
with self._lock:
|
|
238
|
+
self._failures += 1
|
|
239
|
+
already_warned, self._warned = self._warned, True
|
|
240
|
+
if already_warned:
|
|
241
|
+
return
|
|
242
|
+
warnings.warn(
|
|
243
|
+
f"The debug channel 'otel' could not reach {self._endpoint}: {error}. "
|
|
244
|
+
f"The spans of this request are lost; the following failures stay quiet.",
|
|
245
|
+
SnakeWarning,
|
|
246
|
+
stacklevel=2,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
_process_exporter: OtelExporter | None = None
|
|
251
|
+
_process_lock = threading.Lock()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def current_exporter() -> OtelExporter:
|
|
255
|
+
"""The process's exporter, built from the standard environment variables on first use."""
|
|
256
|
+
global _process_exporter
|
|
257
|
+
with _process_lock:
|
|
258
|
+
if _process_exporter is None:
|
|
259
|
+
_process_exporter = OtelExporter(
|
|
260
|
+
endpoint=endpoint_from_env(), service_name=service_name_from_env()
|
|
261
|
+
)
|
|
262
|
+
atexit.register(_shutdown_process_exporter)
|
|
263
|
+
return _process_exporter
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def set_exporter(exporter: OtelExporter | None) -> None:
|
|
267
|
+
"""Install (or clear) the process exporter. `None` makes the next call build a fresh one."""
|
|
268
|
+
global _process_exporter
|
|
269
|
+
with _process_lock:
|
|
270
|
+
_process_exporter = exporter
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _shutdown_process_exporter() -> None:
|
|
274
|
+
"""Drain whatever is queued before the interpreter goes away, so the last request is not lost."""
|
|
275
|
+
exporter = _process_exporter
|
|
276
|
+
if exporter is not None:
|
|
277
|
+
exporter.shutdown()
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""The OTLP/HTTP JSON body: the ~60 lines of stdlib that stand in for the whole SDK.
|
|
2
|
+
|
|
3
|
+
The shape is protobuf's JSON mapping, which is picky in ways plain JSON is not, and each pickiness
|
|
4
|
+
is a 400 from the collector rather than a wrong picture:
|
|
5
|
+
|
|
6
|
+
- A 64-bit field travels as a STRING (`startTimeUnixNano`, `intValue`). A JSON number loses
|
|
7
|
+
precision above 2^53, and a Unix nanosecond is well past that.
|
|
8
|
+
- Every attribute is a `{key, value}` pair with the value TAGGED by type.
|
|
9
|
+
- An absent parent is an ABSENT field, not an empty one.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
|
|
16
|
+
from snakeorm.debug.otel.spans import AttributeValue, SnakeSpan, SpanEvent
|
|
17
|
+
|
|
18
|
+
SCOPE_NAME = "snakeorm"
|
|
19
|
+
"""Who produced these spans. It is how a reader tells ours from the application's own."""
|
|
20
|
+
|
|
21
|
+
SERVICE_NAME_KEY = "service.name"
|
|
22
|
+
"""The resource attribute Jaeger lists in its Service dropdown."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def otlp_payload(
|
|
26
|
+
spans: tuple[SnakeSpan, ...], *, service_name: str
|
|
27
|
+
) -> dict[str, object]:
|
|
28
|
+
"""The complete `resourceSpans` document for one export, ready to be serialised."""
|
|
29
|
+
return {
|
|
30
|
+
"resourceSpans": [
|
|
31
|
+
{
|
|
32
|
+
"resource": {
|
|
33
|
+
"attributes": [_attribute(SERVICE_NAME_KEY, service_name)]
|
|
34
|
+
},
|
|
35
|
+
"scopeSpans": [
|
|
36
|
+
{
|
|
37
|
+
"scope": {"name": SCOPE_NAME},
|
|
38
|
+
"spans": [_span(span) for span in spans],
|
|
39
|
+
}
|
|
40
|
+
],
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def encode_payload(payload: dict[str, object]) -> bytes:
|
|
47
|
+
"""The bytes the POST sends. Compact and UTF-8: nothing here is read by a person."""
|
|
48
|
+
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _span(span: SnakeSpan) -> dict[str, object]:
|
|
52
|
+
"""One span in OTLP's JSON shape, omitting the parent when there is none."""
|
|
53
|
+
encoded: dict[str, object] = {
|
|
54
|
+
"traceId": span.trace_id,
|
|
55
|
+
"spanId": span.span_id,
|
|
56
|
+
"name": span.name,
|
|
57
|
+
"kind": int(span.kind),
|
|
58
|
+
"startTimeUnixNano": str(span.start_unix_nano),
|
|
59
|
+
"endTimeUnixNano": str(span.end_unix_nano),
|
|
60
|
+
"attributes": [_attribute(key, value) for key, value in span.attributes],
|
|
61
|
+
}
|
|
62
|
+
if span.parent_span_id:
|
|
63
|
+
encoded["parentSpanId"] = span.parent_span_id
|
|
64
|
+
if span.events:
|
|
65
|
+
encoded["events"] = [_event(event) for event in span.events]
|
|
66
|
+
return encoded
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _event(event: SpanEvent) -> dict[str, object]:
|
|
70
|
+
"""One span event: its instant, its name and its own attributes."""
|
|
71
|
+
return {
|
|
72
|
+
"name": event.name,
|
|
73
|
+
"timeUnixNano": str(event.time_unix_nano),
|
|
74
|
+
"attributes": [_attribute(key, value) for key, value in event.attributes],
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _attribute(key: str, value: AttributeValue) -> dict[str, object]:
|
|
79
|
+
"""A `{key, value}` pair with the value tagged by its type."""
|
|
80
|
+
return {"key": key, "value": _value(value)}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _value(value: AttributeValue) -> dict[str, object]:
|
|
84
|
+
"""Tag a value with its OTLP type.
|
|
85
|
+
|
|
86
|
+
`bool` is checked FIRST on purpose: in Python it is a subclass of `int`, so the obvious order
|
|
87
|
+
ships every `True` as the integer `1` and the flag stops being a flag in the backend's index.
|
|
88
|
+
"""
|
|
89
|
+
if isinstance(value, bool):
|
|
90
|
+
return {"boolValue": value}
|
|
91
|
+
if isinstance(value, int):
|
|
92
|
+
return {"intValue": str(value)}
|
|
93
|
+
if isinstance(value, float):
|
|
94
|
+
return {"doubleValue": value}
|
|
95
|
+
if isinstance(value, tuple):
|
|
96
|
+
return {"arrayValue": {"values": [{"stringValue": item} for item in value]}}
|
|
97
|
+
return {"stringValue": value}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""`export_report`: the ONE call the three web adapters make, and where the channel is checked.
|
|
2
|
+
|
|
3
|
+
It lives here and not in `contrib/deliver.py` on purpose. `plan_delivery` is a PURE function that
|
|
4
|
+
answers `(headers, envelope)` — two things that modify the response — and `Delivery` is exactly that
|
|
5
|
+
pair. Sending bytes over the network is neither, and widening that dataclass to hold an I/O side
|
|
6
|
+
effect would dissolve the reason `deliver.py` exists. So this is shared the way `index_advice`
|
|
7
|
+
already is: one function, called from all three adapters right after `report.with_request(...)`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from snakeorm.debug.channel import SnakeDebugChannel
|
|
15
|
+
from snakeorm.debug.otel.exporter import current_exporter
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from snakeorm.debug.report import DebugReport
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def export_report(report: DebugReport, channels: frozenset[SnakeDebugChannel]) -> None:
|
|
22
|
+
"""Queue the report as spans when the `otel` channel is on; do nothing at all when it is not.
|
|
23
|
+
|
|
24
|
+
With the channel off the whole cost is one frozenset membership test: no exporter is built, no
|
|
25
|
+
thread is started and `opentelemetry` is never imported.
|
|
26
|
+
"""
|
|
27
|
+
if SnakeDebugChannel.OTEL not in channels:
|
|
28
|
+
return
|
|
29
|
+
current_exporter().submit(report)
|