justanalytics-python 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,143 @@
1
+ """
2
+ Context propagation using ``contextvars.ContextVar``.
3
+
4
+ Provides trace context propagation across async/await boundaries using
5
+ Python's built-in ``contextvars`` module (available since Python 3.7).
6
+ This is the Python equivalent of Node.js ``AsyncLocalStorage``.
7
+
8
+ Context is propagated automatically through:
9
+ - asyncio tasks (await, create_task)
10
+ - threading (when using contextvars.copy_context())
11
+ - generator-based coroutines
12
+
13
+ Each ``start_span()`` call sets a new active span in the context.
14
+ Nested spans automatically form parent-child relationships.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import contextvars
20
+ from typing import Any, Dict, Optional, TYPE_CHECKING
21
+
22
+ if TYPE_CHECKING:
23
+ from .span import Span
24
+ from .types import UserContext
25
+
26
+ # --- Context Variables ---
27
+
28
+ _active_span_var: contextvars.ContextVar[Optional[Span]] = contextvars.ContextVar(
29
+ "ja_active_span", default=None
30
+ )
31
+
32
+ _trace_id_var: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
33
+ "ja_trace_id", default=None
34
+ )
35
+
36
+ _user_var: contextvars.ContextVar[Optional[UserContext]] = contextvars.ContextVar(
37
+ "ja_user", default=None
38
+ )
39
+
40
+ _tags_var: contextvars.ContextVar[Dict[str, str]] = contextvars.ContextVar(
41
+ "ja_tags", default={}
42
+ )
43
+
44
+
45
+ def get_active_span() -> Optional[Span]:
46
+ """Get the currently active span from context.
47
+
48
+ Returns:
49
+ The active Span, or None if not in a traced context.
50
+ """
51
+ return _active_span_var.get(None)
52
+
53
+
54
+ def set_active_span(span: Optional[Span]) -> contextvars.Token:
55
+ """Set the active span in context.
56
+
57
+ Args:
58
+ span: The span to make active, or None to clear.
59
+
60
+ Returns:
61
+ A token that can be used to restore the previous value.
62
+ """
63
+ return _active_span_var.set(span)
64
+
65
+
66
+ def get_trace_id() -> Optional[str]:
67
+ """Get the current trace ID from context.
68
+
69
+ Returns:
70
+ The trace ID string, or None if not in a traced context.
71
+ """
72
+ return _trace_id_var.get(None)
73
+
74
+
75
+ def set_trace_id(trace_id: Optional[str]) -> contextvars.Token:
76
+ """Set the trace ID in context.
77
+
78
+ Args:
79
+ trace_id: The trace ID to set, or None to clear.
80
+
81
+ Returns:
82
+ A token that can be used to restore the previous value.
83
+ """
84
+ return _trace_id_var.set(trace_id)
85
+
86
+
87
+ def get_user() -> Optional[UserContext]:
88
+ """Get the current user context.
89
+
90
+ Returns:
91
+ The UserContext, or None if not set.
92
+ """
93
+ return _user_var.get(None)
94
+
95
+
96
+ def set_user(user: Optional[UserContext]) -> contextvars.Token:
97
+ """Set the user context.
98
+
99
+ Args:
100
+ user: The user context to set, or None to clear.
101
+
102
+ Returns:
103
+ A token that can be used to restore the previous value.
104
+ """
105
+ return _user_var.set(user)
106
+
107
+
108
+ def get_tags() -> Dict[str, str]:
109
+ """Get the current context tags.
110
+
111
+ Returns:
112
+ A copy of the current tags dict.
113
+ """
114
+ return dict(_tags_var.get({}))
115
+
116
+
117
+ def set_tag(key: str, value: str) -> contextvars.Token:
118
+ """Set a single tag in the context (copy-on-write).
119
+
120
+ Tags are attached as attributes on all spans created within this scope.
121
+
122
+ Args:
123
+ key: Tag key.
124
+ value: Tag value.
125
+
126
+ Returns:
127
+ A token that can be used to restore the previous value.
128
+ """
129
+ current = _tags_var.get({})
130
+ new_tags = {**current, key: value}
131
+ return _tags_var.set(new_tags)
132
+
133
+
134
+ def set_tags(tags: Dict[str, str]) -> contextvars.Token:
135
+ """Replace all tags in the context.
136
+
137
+ Args:
138
+ tags: The new tags dict.
139
+
140
+ Returns:
141
+ A token that can be used to restore the previous value.
142
+ """
143
+ return _tags_var.set(dict(tags))
@@ -0,0 +1,11 @@
1
+ """
2
+ Auto-instrumentation integrations for the JustAnalytics Python SDK.
3
+
4
+ Available integrations:
5
+ - ``requests``: Monkey-patches ``requests.Session.send()`` for outgoing HTTP spans.
6
+ - ``urllib3``: Patches ``urllib3.HTTPConnectionPool.urlopen()`` for lower-level HTTP tracking.
7
+ - ``django``: ``JustAnalyticsMiddleware`` for incoming request spans, user extraction, error capture.
8
+ - ``flask``: ``JustAnalyticsMiddleware`` for before/after request hooks, error handler.
9
+ - ``fastapi``: ASGI middleware + exception handler for FastAPI/Starlette.
10
+ - ``logging``: ``JustAnalyticsHandler(logging.Handler)`` bridging Python logging to JA log ingestion.
11
+ """
@@ -0,0 +1,157 @@
1
+ """
2
+ Django middleware for JustAnalytics.
3
+
4
+ Provides ``JustAnalyticsMiddleware`` that automatically:
5
+ - Creates server spans for incoming HTTP requests
6
+ - Reads ``traceparent`` headers for distributed trace propagation
7
+ - Extracts Django user information
8
+ - Captures unhandled exceptions
9
+ - Records HTTP method, path, status code, and timing
10
+
11
+ Usage (settings.py)::
12
+
13
+ MIDDLEWARE = [
14
+ "justanalytics.integrations.django.JustAnalyticsMiddleware",
15
+ # ... other middleware
16
+ ]
17
+
18
+ The middleware expects ``justanalytics.init()`` to have been called before
19
+ the first request is served. If the SDK is not initialized, the middleware
20
+ is a no-op passthrough.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+ import time
27
+ from typing import Any, Callable, Optional
28
+
29
+ logger = logging.getLogger("justanalytics.integrations.django")
30
+
31
+
32
+ class JustAnalyticsMiddleware:
33
+ """
34
+ Django middleware that creates server spans for incoming HTTP requests.
35
+
36
+ This middleware:
37
+ 1. Reads the ``traceparent`` header to continue an existing trace
38
+ 2. Creates a ``server`` span for the request
39
+ 3. Extracts the authenticated Django user (if available)
40
+ 4. Captures exceptions and sets span status to ``error``
41
+ 5. Records ``http.method``, ``http.url``, ``http.status_code``
42
+
43
+ Args:
44
+ get_response: The next middleware or view in the Django chain.
45
+ """
46
+
47
+ def __init__(self, get_response: Callable[..., Any]) -> None:
48
+ self.get_response = get_response
49
+
50
+ def __call__(self, request: Any) -> Any:
51
+ """Process the request, wrapping it in a JA span."""
52
+ # Import here to avoid circular imports and allow lazy SDK init
53
+ try:
54
+ from .. import context as ctx
55
+ from ..client import JustAnalyticsClient
56
+ from ..span import Span, _generate_trace_id
57
+ from ..trace_context import parse_traceparent, serialize_traceparent
58
+ from ..types import SpanKind, SpanStatus, UserContext
59
+ except Exception:
60
+ return self.get_response(request)
61
+
62
+ # Access the global client through the module
63
+ try:
64
+ import justanalytics as ja
65
+ client = ja._client
66
+ if not client.is_initialized or not client._enabled:
67
+ return self.get_response(request)
68
+ except Exception:
69
+ return self.get_response(request)
70
+
71
+ method = request.method or "GET"
72
+ path = request.path or "/"
73
+ span_name = f"{method} {path}"
74
+
75
+ # Parse incoming traceparent header
76
+ traceparent_header = request.META.get("HTTP_TRACEPARENT", "")
77
+ parsed = parse_traceparent(traceparent_header) if traceparent_header else None
78
+
79
+ if parsed:
80
+ trace_id = parsed.trace_id
81
+ parent_span_id = parsed.parent_span_id
82
+ else:
83
+ trace_id = _generate_trace_id()
84
+ parent_span_id = None
85
+
86
+ attributes = {
87
+ "http.method": method,
88
+ "http.url": request.build_absolute_uri() if hasattr(request, "build_absolute_uri") else path,
89
+ "http.target": path,
90
+ "http.scheme": request.scheme if hasattr(request, "scheme") else "http",
91
+ }
92
+
93
+ if client._environment:
94
+ attributes["environment"] = client._environment
95
+ if client._release:
96
+ attributes["release"] = client._release
97
+
98
+ span = Span(
99
+ operation_name=span_name,
100
+ service_name=client._service_name,
101
+ kind=SpanKind.SERVER,
102
+ trace_id=trace_id,
103
+ parent_span_id=parent_span_id,
104
+ attributes=attributes,
105
+ )
106
+
107
+ with span:
108
+ # Extract Django user if authenticated
109
+ try:
110
+ if hasattr(request, "user") and request.user.is_authenticated:
111
+ user_id = str(getattr(request.user, "pk", "")) or str(getattr(request.user, "id", ""))
112
+ email = getattr(request.user, "email", None)
113
+ username = getattr(request.user, "username", None)
114
+ ctx.set_user(UserContext(id=user_id, email=email, username=username))
115
+ span.set_attribute("user.id", user_id)
116
+ if email:
117
+ span.set_attribute("user.email", email)
118
+ except Exception:
119
+ pass # User extraction is best-effort
120
+
121
+ try:
122
+ response = self.get_response(request)
123
+ status_code = getattr(response, "status_code", 200)
124
+ span.set_attribute("http.status_code", status_code)
125
+ if status_code >= 500:
126
+ span.set_status(SpanStatus.ERROR, f"HTTP {status_code}")
127
+ elif status_code >= 400:
128
+ span.set_status(SpanStatus.ERROR, f"HTTP {status_code}")
129
+ else:
130
+ span.set_status(SpanStatus.OK)
131
+ return response
132
+ except Exception as exc:
133
+ span.set_status(SpanStatus.ERROR, str(exc))
134
+ # Capture exception via SDK
135
+ try:
136
+ client.capture_exception(exc)
137
+ except Exception:
138
+ pass
139
+ raise
140
+
141
+ # Enqueue the ended span
142
+ if client._transport and span.is_ended:
143
+ client._transport.enqueue_span(span.to_dict())
144
+
145
+ def process_exception(self, request: Any, exception: BaseException) -> None:
146
+ """
147
+ Called by Django when a view raises an exception.
148
+
149
+ Captures the exception via the SDK for error tracking.
150
+ """
151
+ try:
152
+ import justanalytics as ja
153
+ client = ja._client
154
+ if client.is_initialized and client._enabled:
155
+ client.capture_exception(exception)
156
+ except Exception:
157
+ pass # Never crash from error capture
@@ -0,0 +1,197 @@
1
+ """
2
+ FastAPI / Starlette ASGI middleware for JustAnalytics.
3
+
4
+ Provides ``JustAnalyticsMiddleware`` as ASGI middleware that automatically:
5
+ - Creates server spans for incoming HTTP requests
6
+ - Reads ``traceparent`` headers for distributed trace propagation
7
+ - Captures unhandled exceptions
8
+ - Records HTTP method, path, status code, and timing
9
+
10
+ Usage::
11
+
12
+ from fastapi import FastAPI
13
+ from justanalytics.integrations.fastapi import JustAnalyticsMiddleware
14
+
15
+ app = FastAPI()
16
+ app.add_middleware(JustAnalyticsMiddleware)
17
+
18
+ Works with any ASGI framework (FastAPI, Starlette, etc.).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ from typing import Any, Awaitable, Callable, Optional
25
+
26
+ logger = logging.getLogger("justanalytics.integrations.fastapi")
27
+
28
+ # Type aliases for ASGI
29
+ Scope = dict
30
+ Receive = Callable[[], Awaitable[dict]]
31
+ Send = Callable[[dict], Awaitable[None]]
32
+ ASGIApp = Callable[[Scope, Receive, Send], Awaitable[None]]
33
+
34
+
35
+ class JustAnalyticsMiddleware:
36
+ """
37
+ ASGI middleware that creates server spans for incoming HTTP requests.
38
+
39
+ Compatible with FastAPI, Starlette, and any ASGI-compliant framework.
40
+
41
+ Args:
42
+ app: The ASGI application to wrap.
43
+ """
44
+
45
+ def __init__(self, app: ASGIApp) -> None:
46
+ self.app = app
47
+
48
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
49
+ """Process an ASGI request."""
50
+ if scope["type"] != "http":
51
+ await self.app(scope, receive, send)
52
+ return
53
+
54
+ try:
55
+ from .. import context as ctx
56
+ from ..span import Span, _generate_trace_id
57
+ from ..trace_context import parse_traceparent
58
+ from ..types import SpanKind, SpanStatus
59
+ except Exception:
60
+ await self.app(scope, receive, send)
61
+ return
62
+
63
+ try:
64
+ import justanalytics as ja
65
+ client = ja._client
66
+ if not client.is_initialized or not client._enabled:
67
+ await self.app(scope, receive, send)
68
+ return
69
+ except Exception:
70
+ await self.app(scope, receive, send)
71
+ return
72
+
73
+ # Extract request info from ASGI scope
74
+ method = scope.get("method", "GET")
75
+ path = scope.get("path", "/")
76
+ scheme = scope.get("scheme", "http")
77
+ headers = dict(scope.get("headers", []))
78
+
79
+ span_name = f"{method} {path}"
80
+
81
+ # Parse incoming traceparent header
82
+ traceparent_header = ""
83
+ for key, value in headers.items():
84
+ if key == b"traceparent":
85
+ traceparent_header = value.decode("utf-8", errors="replace")
86
+ break
87
+
88
+ parsed = parse_traceparent(traceparent_header) if traceparent_header else None
89
+
90
+ if parsed:
91
+ trace_id = parsed.trace_id
92
+ parent_span_id = parsed.parent_span_id
93
+ else:
94
+ trace_id = _generate_trace_id()
95
+ parent_span_id = None
96
+
97
+ # Build server/host from scope
98
+ server = scope.get("server")
99
+ if server:
100
+ host = f"{server[0]}:{server[1]}"
101
+ else:
102
+ host = "unknown"
103
+
104
+ attributes = {
105
+ "http.method": method,
106
+ "http.url": f"{scheme}://{host}{path}",
107
+ "http.target": path,
108
+ "http.scheme": scheme,
109
+ }
110
+ if client._environment:
111
+ attributes["environment"] = client._environment
112
+ if client._release:
113
+ attributes["release"] = client._release
114
+
115
+ # Query string
116
+ query_string = scope.get("query_string", b"")
117
+ if query_string:
118
+ attributes["http.query_string"] = query_string.decode("utf-8", errors="replace")
119
+
120
+ span = Span(
121
+ operation_name=span_name,
122
+ service_name=client._service_name,
123
+ kind=SpanKind.SERVER,
124
+ trace_id=trace_id,
125
+ parent_span_id=parent_span_id,
126
+ attributes=attributes,
127
+ )
128
+
129
+ # Track response status code
130
+ response_status: Optional[int] = None
131
+
132
+ async def send_wrapper(message: dict) -> None:
133
+ nonlocal response_status
134
+ if message["type"] == "http.response.start":
135
+ response_status = message.get("status", 200)
136
+ await send(message)
137
+
138
+ with span:
139
+ try:
140
+ await self.app(scope, receive, send_wrapper)
141
+ if response_status is not None:
142
+ span.set_attribute("http.status_code", response_status)
143
+ if response_status >= 400:
144
+ span.set_status(SpanStatus.ERROR, f"HTTP {response_status}")
145
+ else:
146
+ span.set_status(SpanStatus.OK)
147
+ else:
148
+ span.set_status(SpanStatus.OK)
149
+ except Exception as exc:
150
+ span.set_status(SpanStatus.ERROR, str(exc))
151
+ # Capture exception
152
+ try:
153
+ client.capture_exception(exc)
154
+ except Exception:
155
+ pass
156
+ raise
157
+
158
+ # Enqueue the ended span
159
+ if client._transport and span.is_ended:
160
+ client._transport.enqueue_span(span.to_dict())
161
+
162
+
163
+ def setup_exception_handler(app: Any) -> None:
164
+ """
165
+ Register a FastAPI exception handler that captures exceptions via JA.
166
+
167
+ Args:
168
+ app: The FastAPI application instance.
169
+
170
+ Usage::
171
+
172
+ from fastapi import FastAPI
173
+ from justanalytics.integrations.fastapi import setup_exception_handler
174
+
175
+ app = FastAPI()
176
+ setup_exception_handler(app)
177
+ """
178
+ try:
179
+ from fastapi import Request
180
+ from fastapi.responses import JSONResponse
181
+ except ImportError:
182
+ logger.debug("FastAPI not installed, skipping exception handler setup")
183
+ return
184
+
185
+ @app.exception_handler(Exception)
186
+ async def ja_exception_handler(request: Request, exc: Exception) -> JSONResponse:
187
+ try:
188
+ import justanalytics as ja
189
+ client = ja._client
190
+ if client.is_initialized and client._enabled:
191
+ client.capture_exception(exc)
192
+ except Exception:
193
+ pass
194
+ return JSONResponse(
195
+ status_code=500,
196
+ content={"detail": "Internal Server Error"},
197
+ )