tracelet-sdk 0.1.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.
@@ -0,0 +1,130 @@
1
+ """Tag each database query with the request that ran it.
2
+
3
+ Queries get a sqlcommenter comment (https://google.github.io/sqlcommenter/)::
4
+
5
+ SELECT * FROM users WHERE id = %s /*route='GET%20%2Fusers%2F%3Aid',traceparent='00-…-…-01'*/
6
+
7
+ Tracelet reads these comments to group queries into requests. Nothing is
8
+ sent anywhere: the comment travels with the query to your database.
9
+
10
+ Framework integrations live in submodules: ``tracelet_sdk.wsgi``,
11
+ ``tracelet_sdk.asgi``, ``tracelet_sdk.flask``, ``tracelet_sdk.django`` and
12
+ ``tracelet_sdk.sqlalchemy``.
13
+ """
14
+
15
+ import contextvars
16
+ import os
17
+ import re
18
+ from contextlib import contextmanager
19
+ from typing import Callable, Dict, Iterator, Optional, Union
20
+ from urllib.parse import quote
21
+
22
+ __all__ = [
23
+ "RequestContext",
24
+ "request",
25
+ "current_request",
26
+ "comment",
27
+ "new_traceparent",
28
+ "parse_traceparent",
29
+ ]
30
+
31
+ Route = Union[str, Callable[[], Optional[str]], None]
32
+
33
+ _TRACEPARENT = re.compile(r"^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$")
34
+
35
+
36
+ class RequestContext:
37
+ """The request a query belongs to."""
38
+
39
+ __slots__ = ("_route", "traceparent")
40
+
41
+ def __init__(self, route: Route = None, traceparent: Optional[str] = None):
42
+ self._route = route
43
+ self.traceparent = traceparent if parse_traceparent(traceparent) else new_traceparent(traceparent)
44
+
45
+ @property
46
+ def route(self) -> Optional[str]:
47
+ """The route pattern. Callables are evaluated now, so frameworks that
48
+ only know the route after routing can supply it lazily."""
49
+ route = self._route
50
+ if callable(route):
51
+ try:
52
+ route = route()
53
+ except Exception: # never let tagging break a query
54
+ route = None
55
+ return route or None
56
+
57
+
58
+ _current: "contextvars.ContextVar[Optional[RequestContext]]" = contextvars.ContextVar("tracelet_request", default=None)
59
+
60
+
61
+ def parse_traceparent(header: Optional[str]) -> Optional[Dict[str, str]]:
62
+ """Parse a W3C traceparent header, or return None if it is not valid."""
63
+ if not isinstance(header, str):
64
+ return None
65
+ m = _TRACEPARENT.match(header.strip())
66
+ if not m or m.group(1) == "ff" or set(m.group(2)) == {"0"} or set(m.group(3)) == {"0"}:
67
+ return None
68
+ return {"version": m.group(1), "trace_id": m.group(2), "parent_id": m.group(3), "flags": m.group(4)}
69
+
70
+
71
+ def new_traceparent(incoming: Optional[str] = None) -> str:
72
+ """A traceparent for a new request span. An incoming header's trace id is
73
+ kept, so Tracelet's traces line up with the rest of your tracing."""
74
+ parent = parse_traceparent(incoming)
75
+ trace_id = parent["trace_id"] if parent else os.urandom(16).hex()
76
+ flags = parent["flags"] if parent else "01"
77
+ return "00-{}-{}-{}".format(trace_id, os.urandom(8).hex(), flags)
78
+
79
+
80
+ @contextmanager
81
+ def request(route: Route = None, traceparent: Optional[str] = None) -> Iterator[RequestContext]:
82
+ """Run a block as one request: queries inside it are tagged with it.
83
+ Works across ``await`` and in threads that copy the context."""
84
+ ctx = RequestContext(route, traceparent)
85
+ token = _current.set(ctx)
86
+ try:
87
+ yield ctx
88
+ finally:
89
+ _current.reset(token)
90
+
91
+
92
+ def current_request() -> Optional[RequestContext]:
93
+ """The current request context, or None outside a request."""
94
+ return _current.get()
95
+
96
+
97
+ def _enc(value: str) -> str:
98
+ # Same as JavaScript's encodeURIComponent, then sqlcommenter's \' escape.
99
+ return quote(str(value), safe="-_.!~*'()").replace("'", "\\'")
100
+
101
+
102
+ def comment(sql: str, fields: Optional[Dict[str, Optional[str]]] = None, *, escape_percent: bool = False) -> str:
103
+ """Append a sqlcommenter comment with ``fields`` (or the current
104
+ request's) to ``sql``.
105
+
106
+ Set ``escape_percent`` when the driver will %-format the SQL (psycopg,
107
+ MySQLdb and Django with parameters): the comment's URL-encoded values
108
+ contain ``%``, which must then be written as ``%%``.
109
+
110
+ SQL that already contains a comment is returned unchanged, as the
111
+ sqlcommenter spec requires.
112
+ """
113
+ if not isinstance(sql, str) or "*/" in sql:
114
+ return sql
115
+ if fields is None:
116
+ ctx = _current.get()
117
+ if ctx is None:
118
+ return sql
119
+ fields = {"route": ctx.route, "traceparent": ctx.traceparent}
120
+ pairs = ["{}='{}'".format(_enc(k), _enc(v)) for k, v in sorted(fields.items()) if v not in (None, "")]
121
+ if not pairs:
122
+ return sql
123
+
124
+ text = "/*" + ",".join(pairs) + "*/"
125
+ if escape_percent:
126
+ text = text.replace("%", "%%")
127
+ trimmed = sql.rstrip()
128
+ if trimmed.endswith(";"):
129
+ return "{} {};".format(trimmed[:-1].rstrip(), text)
130
+ return "{} {}".format(trimmed, text)
tracelet_sdk/asgi.py ADDED
@@ -0,0 +1,36 @@
1
+ """ASGI middleware: one request context per HTTP request (FastAPI, Starlette, Django ASGI)."""
2
+
3
+ from typing import Any, Callable, Dict, Optional
4
+
5
+ from . import new_traceparent, request
6
+
7
+ RouteGetter = Callable[[Dict[str, Any]], Optional[str]]
8
+
9
+
10
+ def default_route(scope: Dict[str, Any]) -> Optional[str]:
11
+ """The route pattern FastAPI records in the scope after routing."""
12
+ route = scope.get("route")
13
+ path = getattr(route, "path", None) or getattr(route, "path_format", None)
14
+ return "{} {}".format(scope.get("method", ""), path).strip() if path else None
15
+
16
+
17
+ class TraceletMiddleware:
18
+ """Wrap any ASGI app. ``route`` is called with the ASGI scope each time a
19
+ query runs; the default reads the route FastAPI matched."""
20
+
21
+ def __init__(self, app: Callable, route: Optional[RouteGetter] = default_route):
22
+ self.app = app
23
+ self.route = route
24
+
25
+ async def __call__(self, scope: Dict[str, Any], receive: Callable, send: Callable) -> None:
26
+ if scope.get("type") != "http":
27
+ await self.app(scope, receive, send)
28
+ return
29
+ incoming = None
30
+ for name, value in scope.get("headers") or []:
31
+ if name == b"traceparent":
32
+ incoming = value.decode("latin-1")
33
+ break
34
+ getter = (lambda: self.route(scope)) if self.route else None
35
+ with request(route=getter, traceparent=new_traceparent(incoming)):
36
+ await self.app(scope, receive, send)
tracelet_sdk/django.py ADDED
@@ -0,0 +1,37 @@
1
+ """Django integration. Add to MIDDLEWARE::
2
+
3
+ MIDDLEWARE = ["tracelet_sdk.django.TraceletMiddleware", ...]
4
+
5
+ Every query Django runs during a request is tagged with the URL pattern
6
+ ("GET /users/<int:pk>/"). No other setup is needed.
7
+ """
8
+
9
+ from contextlib import ExitStack
10
+ from typing import Any, Callable
11
+
12
+ from . import comment, new_traceparent, request
13
+
14
+
15
+ def _execute_wrapper(execute: Callable, sql: str, params: Any, many: bool, context: Any) -> Any:
16
+ # With parameters, Django's backends %-format the SQL, so the comment's
17
+ # % signs must be doubled. Without parameters they are sent as-is.
18
+ return execute(comment(sql, escape_percent=params is not None), params, many, context)
19
+
20
+
21
+ class TraceletMiddleware:
22
+ def __init__(self, get_response: Callable):
23
+ self.get_response = get_response
24
+
25
+ def __call__(self, http_request: Any) -> Any:
26
+ from django.db import connections
27
+
28
+ def route():
29
+ match = getattr(http_request, "resolver_match", None)
30
+ pattern = getattr(match, "route", None)
31
+ return "{} /{}".format(http_request.method, pattern) if pattern is not None else None
32
+
33
+ incoming = http_request.META.get("HTTP_TRACEPARENT")
34
+ with request(route=route, traceparent=new_traceparent(incoming)), ExitStack() as stack:
35
+ for conn in connections.all():
36
+ stack.enter_context(conn.execute_wrapper(_execute_wrapper))
37
+ return self.get_response(http_request)
tracelet_sdk/flask.py ADDED
@@ -0,0 +1,21 @@
1
+ """Flask integration: ``tracelet_sdk.flask.instrument(app)``."""
2
+
3
+ from typing import Any, Dict, Optional
4
+
5
+ from .wsgi import TraceletMiddleware
6
+
7
+
8
+ def _route(environ: Dict[str, Any]) -> Optional[str]:
9
+ from flask import has_request_context, request
10
+
11
+ if has_request_context() and request.url_rule is not None:
12
+ return "{} {}".format(request.method, request.url_rule.rule)
13
+ return None
14
+
15
+
16
+ def instrument(app: Any) -> Any:
17
+ """Tag queries with the Flask route that ran them ("GET /users/<int:id>").
18
+ Combine with ``tracelet_sdk.sqlalchemy.instrument`` (Flask-SQLAlchemy:
19
+ ``instrument(db.engine)``) or call ``tracelet_sdk.comment`` yourself."""
20
+ app.wsgi_app = TraceletMiddleware(app.wsgi_app, route=_route)
21
+ return app
@@ -0,0 +1,20 @@
1
+ """SQLAlchemy integration: ``tracelet_sdk.sqlalchemy.instrument(engine)``."""
2
+
3
+ from typing import Any
4
+
5
+ from . import comment
6
+
7
+
8
+ def instrument(engine: Any) -> Any:
9
+ """Tag every statement the engine runs inside a request context.
10
+ Works with any driver; % signs are escaped for drivers that %-format."""
11
+ from sqlalchemy import event
12
+
13
+ target = getattr(engine, "sync_engine", engine) # AsyncEngine wraps a sync engine
14
+
15
+ @event.listens_for(target, "before_cursor_execute", retval=True)
16
+ def _tag(conn, cursor, statement, parameters, context, executemany):
17
+ formats = conn.dialect.paramstyle in ("format", "pyformat") and parameters is not None
18
+ return comment(statement, escape_percent=formats), parameters
19
+
20
+ return engine
tracelet_sdk/wsgi.py ADDED
@@ -0,0 +1,26 @@
1
+ """WSGI middleware: one request context per HTTP request."""
2
+
3
+ from typing import Any, Callable, Dict, Optional
4
+
5
+ from . import new_traceparent, request
6
+
7
+ RouteGetter = Callable[[Dict[str, Any]], Optional[str]]
8
+
9
+
10
+ class TraceletMiddleware:
11
+ """Wrap any WSGI app. ``route`` is called with the WSGI environ each
12
+ time a query runs and should return the matched route pattern (for
13
+ example ``"GET /users/<id>"``). Without it, queries are still grouped by
14
+ request, just without a route. Raw URLs are never used, so ids and other
15
+ values in paths don't end up in your stats."""
16
+
17
+ def __init__(self, app: Callable, route: Optional[RouteGetter] = None):
18
+ self.app = app
19
+ self.route = route
20
+
21
+ def __call__(self, environ: Dict[str, Any], start_response: Callable):
22
+ getter = (lambda: self.route(environ)) if self.route else None
23
+ with request(route=getter, traceparent=new_traceparent(environ.get("HTTP_TRACEPARENT"))):
24
+ # Queries made while a streamed response is being iterated, after
25
+ # this returns, are not tagged.
26
+ return self.app(environ, start_response)
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.4
2
+ Name: tracelet-sdk
3
+ Version: 0.1.0
4
+ Summary: Tag your database queries with the request that ran them, so Tracelet can show per-request waterfalls and catch N+1 loops.
5
+ License: Apache-2.0
6
+ Project-URL: Homepage, https://github.com/moseleydev/tracelet
7
+ Keywords: tracelet,sqlcommenter,postgres,database,performance,n+1
8
+ Requires-Python: >=3.8
9
+ Description-Content-Type: text/markdown
10
+
11
+ # tracelet-sdk (Python)
12
+
13
+ Tags each database query with the request that ran it, so [Tracelet](https://github.com/moseleydev/tracelet) can show per-request waterfalls and catch N+1 loops. Zero dependencies. Python 3.8+.
14
+
15
+ ```bash
16
+ pip install tracelet-sdk
17
+ ```
18
+
19
+ ## Django
20
+
21
+ ```python
22
+ MIDDLEWARE = [
23
+ "tracelet_sdk.django.TraceletMiddleware",
24
+ # ...
25
+ ]
26
+ ```
27
+
28
+ That's all. Every query Django runs during a request carries the URL pattern (`GET /users/<int:pk>/`) and a trace id.
29
+
30
+ ## Flask
31
+
32
+ ```python
33
+ from tracelet_sdk.flask import instrument
34
+ from tracelet_sdk.sqlalchemy import instrument as instrument_engine
35
+
36
+ instrument(app)
37
+ instrument_engine(db.engine) # Flask-SQLAlchemy, or any SQLAlchemy engine
38
+ ```
39
+
40
+ ## FastAPI / Starlette / any ASGI app
41
+
42
+ ```python
43
+ from tracelet_sdk.asgi import TraceletMiddleware
44
+ from tracelet_sdk.sqlalchemy import instrument
45
+
46
+ app.add_middleware(TraceletMiddleware) # reads the matched FastAPI route
47
+ instrument(engine)
48
+ ```
49
+
50
+ Plain WSGI apps can use `tracelet_sdk.wsgi.TraceletMiddleware(app, route=...)`.
51
+
52
+ ## Anything else
53
+
54
+ ```python
55
+ import tracelet_sdk
56
+
57
+ with tracelet_sdk.request(route="POST /import"):
58
+ cursor.execute(tracelet_sdk.comment("SELECT * FROM jobs WHERE id = %s", escape_percent=True), (job_id,))
59
+ ```
60
+
61
+ Pass `escape_percent=True` when the driver %-formats the query (psycopg, MySQLdb and Django with parameters). The comment's URL-encoded values contain `%`, which must then be doubled. The Django and SQLAlchemy integrations do this for you.
62
+
63
+ ## Notes
64
+
65
+ - **Traces:** an incoming `traceparent` header is continued, so traces line up with OpenTelemetry.
66
+ - **No raw URLs:** paths are never recorded, so ids and other values in them don't end up in your stats.
67
+ - **Existing comments:** SQL that already contains a comment is left unchanged, as the sqlcommenter spec requires.
68
+ - **Streaming responses:** in WSGI, queries made while a streamed response is iterated are not tagged.
@@ -0,0 +1,10 @@
1
+ tracelet_sdk/__init__.py,sha256=S3WLomwaB4867o4J-iZdrcA1ZORpi46X-ML9WWJqSxA,4687
2
+ tracelet_sdk/asgi.py,sha256=hn5tBrA0omUHsZKGysd6MV7Iy9K1D4fTwVxW_kF0kfA,1463
3
+ tracelet_sdk/django.py,sha256=1PSh_8GOFJR2zMuQEehmpoILxn1yM8fJMyb4iDatjzs,1467
4
+ tracelet_sdk/flask.py,sha256=u6vCCDqOPg_HfWpPyrkfqyN1eBBaMFJQLX3XAaDcs9s,746
5
+ tracelet_sdk/sqlalchemy.py,sha256=9nQxBbigru7aI4A3l7wfc0DxJocbKenqk9hBvWE-arA,757
6
+ tracelet_sdk/wsgi.py,sha256=Cb-tNthK8Ieo9dJ9S8hfBqNwLtE3Z_zttSYfvg70PYU,1132
7
+ tracelet_sdk-0.1.0.dist-info/METADATA,sha256=lCRT5xHxI2kzbvyHrNB1zIRH42sxkyCsHSWWaWoLDJE,2313
8
+ tracelet_sdk-0.1.0.dist-info/WHEEL,sha256=a-udL2m4V_6Yn8b-uKzWb12RjRxwwMo6ohYFM24tpaY,91
9
+ tracelet_sdk-0.1.0.dist-info/top_level.txt,sha256=EX3uds2sf1D-gOtjjJudnyfhgiQdxa-RyFzzDSJbrbg,13
10
+ tracelet_sdk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ tracelet_sdk