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,203 @@
1
+ """
2
+ Flask middleware for JustAnalytics.
3
+
4
+ Provides ``JustAnalyticsMiddleware`` that uses Flask's ``before_request``
5
+ and ``after_request`` hooks to automatically:
6
+ - Create server spans for incoming HTTP requests
7
+ - Read ``traceparent`` headers for distributed trace propagation
8
+ - Capture unhandled exceptions via error handler
9
+ - Record HTTP method, path, status code, and timing
10
+
11
+ Usage::
12
+
13
+ from flask import Flask
14
+ from justanalytics.integrations.flask import JustAnalyticsMiddleware
15
+
16
+ app = Flask(__name__)
17
+ JustAnalyticsMiddleware(app)
18
+
19
+ The middleware expects ``justanalytics.init()`` to have been called before
20
+ the first request is served.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+ from typing import Any, Optional
27
+
28
+ logger = logging.getLogger("justanalytics.integrations.flask")
29
+
30
+ # Flask stores the current span in g.ja_span
31
+ _SPAN_ATTR = "ja_span"
32
+ _SPAN_TOKEN_ATTR = "ja_span_token"
33
+ _TRACE_TOKEN_ATTR = "ja_trace_token"
34
+
35
+
36
+ class JustAnalyticsMiddleware:
37
+ """
38
+ Flask middleware using before/after request hooks and error handler.
39
+
40
+ Registers hooks on the Flask app to create server spans for every
41
+ incoming request.
42
+
43
+ Args:
44
+ app: The Flask application instance. If None, call ``init_app()`` later.
45
+ """
46
+
47
+ def __init__(self, app: Any = None) -> None:
48
+ self.app = app
49
+ if app is not None:
50
+ self.init_app(app)
51
+
52
+ def init_app(self, app: Any) -> None:
53
+ """
54
+ Initialize the middleware on a Flask app.
55
+
56
+ Registers ``before_request``, ``after_request``, and ``teardown_request``
57
+ hooks, plus a catch-all error handler.
58
+
59
+ Args:
60
+ app: The Flask application instance.
61
+ """
62
+ app.before_request(self._before_request)
63
+ app.after_request(self._after_request)
64
+ app.teardown_request(self._teardown_request)
65
+ # Register error handler for uncaught exceptions
66
+ app.register_error_handler(Exception, self._handle_error)
67
+
68
+ def _before_request(self) -> None:
69
+ """Create a server span before each request."""
70
+ try:
71
+ from flask import request, g
72
+ from .. import context as ctx
73
+ from ..span import Span, _generate_trace_id
74
+ from ..trace_context import parse_traceparent
75
+ from ..types import SpanKind
76
+ except Exception:
77
+ return
78
+
79
+ try:
80
+ import justanalytics as ja
81
+ client = ja._client
82
+ if not client.is_initialized or not client._enabled:
83
+ return
84
+ except Exception:
85
+ return
86
+
87
+ method = request.method or "GET"
88
+ path = request.path or "/"
89
+ span_name = f"{method} {path}"
90
+
91
+ # Parse incoming traceparent header
92
+ traceparent_header = request.headers.get("traceparent", "")
93
+ parsed = parse_traceparent(traceparent_header) if traceparent_header else None
94
+
95
+ if parsed:
96
+ trace_id = parsed.trace_id
97
+ parent_span_id = parsed.parent_span_id
98
+ else:
99
+ trace_id = _generate_trace_id()
100
+ parent_span_id = None
101
+
102
+ attributes = {
103
+ "http.method": method,
104
+ "http.url": request.url,
105
+ "http.target": path,
106
+ "http.scheme": request.scheme,
107
+ }
108
+ if client._environment:
109
+ attributes["environment"] = client._environment
110
+ if client._release:
111
+ attributes["release"] = client._release
112
+
113
+ span = Span(
114
+ operation_name=span_name,
115
+ service_name=client._service_name,
116
+ kind=SpanKind.SERVER,
117
+ trace_id=trace_id,
118
+ parent_span_id=parent_span_id,
119
+ attributes=attributes,
120
+ )
121
+
122
+ # Enter the span context (set in contextvars)
123
+ span_token = ctx.set_active_span(span)
124
+ trace_token = ctx.set_trace_id(span.trace_id)
125
+
126
+ # Store span and tokens in Flask's g for later retrieval
127
+ setattr(g, _SPAN_ATTR, span)
128
+ setattr(g, _SPAN_TOKEN_ATTR, span_token)
129
+ setattr(g, _TRACE_TOKEN_ATTR, trace_token)
130
+
131
+ def _after_request(self, response: Any) -> Any:
132
+ """End the span and record response status after each request."""
133
+ try:
134
+ from flask import g
135
+ from ..types import SpanStatus
136
+ except Exception:
137
+ return response
138
+
139
+ span = getattr(g, _SPAN_ATTR, None)
140
+ if span is None:
141
+ return response
142
+
143
+ try:
144
+ status_code = response.status_code
145
+ span.set_attribute("http.status_code", status_code)
146
+ if status_code >= 400:
147
+ span.set_status(SpanStatus.ERROR, f"HTTP {status_code}")
148
+ else:
149
+ span.set_status(SpanStatus.OK)
150
+ except Exception:
151
+ pass
152
+
153
+ return response
154
+
155
+ def _teardown_request(self, exception: Optional[BaseException] = None) -> None:
156
+ """End the span and restore context at teardown."""
157
+ try:
158
+ from flask import g
159
+ from .. import context as ctx
160
+ from ..types import SpanStatus
161
+ except Exception:
162
+ return
163
+
164
+ span = getattr(g, _SPAN_ATTR, None)
165
+ if span is None:
166
+ return
167
+
168
+ # If there was an exception, mark the span as error
169
+ if exception is not None and not span.is_ended:
170
+ span.set_status(SpanStatus.ERROR, str(exception))
171
+
172
+ # End the span
173
+ if not span.is_ended:
174
+ span.end()
175
+
176
+ # Enqueue the span
177
+ try:
178
+ import justanalytics as ja
179
+ client = ja._client
180
+ if client._transport:
181
+ client._transport.enqueue_span(span.to_dict())
182
+ except Exception:
183
+ pass
184
+
185
+ # Restore context
186
+ span_token = getattr(g, _SPAN_TOKEN_ATTR, None)
187
+ trace_token = getattr(g, _TRACE_TOKEN_ATTR, None)
188
+ if span_token is not None:
189
+ ctx._active_span_var.reset(span_token)
190
+ if trace_token is not None:
191
+ ctx._trace_id_var.reset(trace_token)
192
+
193
+ def _handle_error(self, error: Exception) -> Any:
194
+ """Capture uncaught exceptions via the SDK."""
195
+ try:
196
+ import justanalytics as ja
197
+ client = ja._client
198
+ if client.is_initialized and client._enabled:
199
+ client.capture_exception(error)
200
+ except Exception:
201
+ pass
202
+ # Re-raise to let Flask handle the error response
203
+ raise error
@@ -0,0 +1,175 @@
1
+ """
2
+ Python ``logging.Handler`` bridge for JustAnalytics log ingestion.
3
+
4
+ Provides ``JustAnalyticsHandler`` that bridges Python's standard ``logging``
5
+ module to JustAnalytics' log ingestion API. Log records are automatically
6
+ enriched with trace context (trace_id, span_id) from the current
7
+ ``contextvars`` scope.
8
+
9
+ Usage::
10
+
11
+ import logging
12
+ from justanalytics.integrations.logging import JustAnalyticsHandler
13
+
14
+ # Add to root logger
15
+ handler = JustAnalyticsHandler()
16
+ logging.getLogger().addHandler(handler)
17
+
18
+ # Or add to a specific logger
19
+ logger = logging.getLogger("myapp")
20
+ logger.addHandler(JustAnalyticsHandler(level=logging.WARNING))
21
+
22
+ # Logs will now be sent to JustAnalytics with trace context
23
+ logger.info("User logged in", extra={"userId": "u123"})
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import logging
29
+ from datetime import datetime, timezone
30
+ from typing import Any, Dict, Optional
31
+
32
+ from .. import context as ctx
33
+ from ..types import LogLevel, LogPayload
34
+
35
+ # Map Python log levels to JustAnalytics log levels
36
+ _LEVEL_MAP: Dict[int, LogLevel] = {
37
+ logging.DEBUG: LogLevel.DEBUG,
38
+ logging.INFO: LogLevel.INFO,
39
+ logging.WARNING: LogLevel.WARN,
40
+ logging.ERROR: LogLevel.ERROR,
41
+ logging.CRITICAL: LogLevel.FATAL,
42
+ }
43
+
44
+
45
+ def _map_level(levelno: int) -> LogLevel:
46
+ """Map a Python logging level number to a JustAnalytics LogLevel."""
47
+ if levelno <= logging.DEBUG:
48
+ return LogLevel.DEBUG
49
+ elif levelno <= logging.INFO:
50
+ return LogLevel.INFO
51
+ elif levelno <= logging.WARNING:
52
+ return LogLevel.WARN
53
+ elif levelno <= logging.ERROR:
54
+ return LogLevel.ERROR
55
+ else:
56
+ return LogLevel.FATAL
57
+
58
+
59
+ class JustAnalyticsHandler(logging.Handler):
60
+ """
61
+ A ``logging.Handler`` that sends log records to JustAnalytics.
62
+
63
+ Automatically attaches trace context (trace_id, span_id) from the
64
+ current ``contextvars`` scope. Extra fields from the log record
65
+ are included as attributes.
66
+
67
+ The handler is a no-op if the SDK is not initialized.
68
+
69
+ Args:
70
+ level: Minimum logging level (default: ``logging.NOTSET``).
71
+ service_name_override: Override the service name from SDK config.
72
+
73
+ Example::
74
+
75
+ handler = JustAnalyticsHandler(level=logging.INFO)
76
+ logging.getLogger("myapp").addHandler(handler)
77
+ """
78
+
79
+ def __init__(
80
+ self,
81
+ level: int = logging.NOTSET,
82
+ service_name_override: Optional[str] = None,
83
+ ) -> None:
84
+ super().__init__(level)
85
+ self._service_name_override = service_name_override
86
+
87
+ def _get_client(self):
88
+ """Get the JustAnalytics client. Returns None if unavailable."""
89
+ try:
90
+ import justanalytics
91
+ return justanalytics._client
92
+ except Exception:
93
+ return None
94
+
95
+ def emit(self, record: logging.LogRecord) -> None:
96
+ """
97
+ Emit a log record to JustAnalytics.
98
+
99
+ Converts the log record to a ``LogPayload`` and enqueues it
100
+ in the SDK's transport buffer.
101
+
102
+ Args:
103
+ record: The logging.LogRecord to process.
104
+ """
105
+ try:
106
+ client = self._get_client()
107
+ if client is None or not client.is_initialized or not client._enabled or not client._transport:
108
+ return
109
+ except Exception:
110
+ return
111
+
112
+ try:
113
+ # Map level
114
+ ja_level = _map_level(record.levelno)
115
+
116
+ # Get trace context
117
+ active_span = ctx.get_active_span()
118
+ trace_id = active_span.trace_id if active_span else None
119
+ span_id = active_span.id if active_span else None
120
+
121
+ # Build attributes from record extras
122
+ attributes: Dict[str, Any] = {}
123
+
124
+ # Standard record fields worth capturing
125
+ if record.name:
126
+ attributes["logger.name"] = record.name
127
+ if record.funcName and record.funcName != "<module>":
128
+ attributes["code.function"] = record.funcName
129
+ if record.pathname:
130
+ attributes["code.filepath"] = record.pathname
131
+ if record.lineno:
132
+ attributes["code.lineno"] = record.lineno
133
+
134
+ # Extract extra fields (user-supplied via extra={} in logging call)
135
+ # Avoid standard LogRecord attributes
136
+ _STANDARD_ATTRS = frozenset({
137
+ "name", "msg", "args", "created", "relativeCreated",
138
+ "exc_info", "exc_text", "stack_info", "lineno", "funcName",
139
+ "pathname", "filename", "module", "levelno", "levelname",
140
+ "thread", "threadName", "process", "processName",
141
+ "getMessage", "message", "asctime", "msecs", "taskName",
142
+ })
143
+ for key, value in record.__dict__.items():
144
+ if key.startswith("_") or key in _STANDARD_ATTRS:
145
+ continue
146
+ # Only include serializable values
147
+ if isinstance(value, (str, int, float, bool)):
148
+ attributes[key] = value
149
+
150
+ # Format the message
151
+ message = record.getMessage()
152
+
153
+ # Include exception info if present
154
+ if record.exc_info and record.exc_info[0] is not None:
155
+ import traceback as tb
156
+ exc_text = "".join(tb.format_exception(*record.exc_info))
157
+ attributes["exception.stacktrace"] = exc_text
158
+
159
+ service_name = self._service_name_override or client._service_name
160
+
161
+ payload = LogPayload(
162
+ level=ja_level.value,
163
+ message=message[:10000], # Server enforces max 10000 chars
164
+ service_name=service_name,
165
+ timestamp=datetime.now(timezone.utc).isoformat(),
166
+ trace_id=trace_id,
167
+ span_id=span_id,
168
+ attributes=attributes,
169
+ )
170
+
171
+ client._transport.enqueue_log(payload.to_dict())
172
+
173
+ except Exception:
174
+ # Never crash from the logging handler
175
+ self.handleError(record)
@@ -0,0 +1,149 @@
1
+ """
2
+ Auto-instrumentation for the ``requests`` library.
3
+
4
+ Monkey-patches ``requests.Session.send()`` to automatically create client
5
+ spans for outgoing HTTP requests, inject ``traceparent`` headers for
6
+ distributed tracing, and capture response status codes.
7
+
8
+ Usage::
9
+
10
+ from justanalytics.integrations.requests import RequestsIntegration
11
+
12
+ # Enable (call after justanalytics.init())
13
+ integration = RequestsIntegration()
14
+ integration.enable()
15
+
16
+ # All requests.get/post/etc. calls will now be traced
17
+
18
+ # Disable (restores original behavior)
19
+ integration.disable()
20
+
21
+ The integration is safe to enable/disable multiple times. It only patches
22
+ ``Session.send()`` once, even if ``enable()`` is called multiple times.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import logging
28
+ from typing import Any, Callable, Optional, TYPE_CHECKING
29
+
30
+ from .. import context as ctx
31
+ from ..span import Span
32
+ from ..trace_context import serialize_traceparent
33
+ from ..types import SpanKind, SpanStatus
34
+
35
+ if TYPE_CHECKING:
36
+ pass
37
+
38
+ logger = logging.getLogger("justanalytics.integrations.requests")
39
+
40
+
41
+ class RequestsIntegration:
42
+ """
43
+ Monkey-patches ``requests.Session.send()`` to create spans for outgoing HTTP.
44
+
45
+ Args:
46
+ enqueue_span: Callback to enqueue ended spans for transport.
47
+ If None, spans are created but not sent.
48
+ service_name: Service name for span attribution.
49
+ ignore_urls: URL patterns to exclude from instrumentation.
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ enqueue_span: Optional[Callable[[dict], None]] = None,
55
+ service_name: str = "unknown",
56
+ ignore_urls: Optional[list] = None,
57
+ ) -> None:
58
+ self._enqueue_span = enqueue_span
59
+ self._service_name = service_name
60
+ self._ignore_urls = ignore_urls or []
61
+ self._original_send: Optional[Callable] = None
62
+ self._enabled = False
63
+
64
+ def enable(self) -> None:
65
+ """Enable the integration by monkey-patching requests.Session.send."""
66
+ if self._enabled:
67
+ return
68
+ try:
69
+ import requests
70
+ except ImportError:
71
+ logger.debug("requests library not installed, skipping integration")
72
+ return
73
+
74
+ self._original_send = requests.Session.send
75
+ integration = self
76
+
77
+ def patched_send(session_self: Any, request: Any, **kwargs: Any) -> Any:
78
+ """Wrapped Session.send that creates spans for outgoing HTTP."""
79
+ url = str(request.url) if request.url else ""
80
+
81
+ # Check ignore patterns
82
+ for pattern in integration._ignore_urls:
83
+ if isinstance(pattern, str) and pattern in url:
84
+ return integration._original_send(session_self, request, **kwargs) # type: ignore[misc]
85
+
86
+ method = (request.method or "GET").upper()
87
+ span_name = f"{method} {_extract_host(url)}"
88
+
89
+ # Create span
90
+ parent = ctx.get_active_span()
91
+ span = Span(
92
+ operation_name=span_name,
93
+ service_name=integration._service_name,
94
+ kind=SpanKind.CLIENT,
95
+ trace_id=parent.trace_id if parent else None,
96
+ parent_span_id=parent.id if parent else None,
97
+ attributes={
98
+ "http.method": method,
99
+ "http.url": url,
100
+ },
101
+ )
102
+
103
+ # Inject traceparent header
104
+ traceparent = serialize_traceparent(span.trace_id, span.id)
105
+ if request.headers is None:
106
+ request.headers = {}
107
+ request.headers["traceparent"] = traceparent
108
+
109
+ with span:
110
+ try:
111
+ response = integration._original_send(session_self, request, **kwargs) # type: ignore[misc]
112
+ span.set_attribute("http.status_code", response.status_code)
113
+ if response.status_code >= 400:
114
+ span.set_status(SpanStatus.ERROR, f"HTTP {response.status_code}")
115
+ else:
116
+ span.set_status(SpanStatus.OK)
117
+ return response
118
+ except Exception as exc:
119
+ span.set_status(SpanStatus.ERROR, str(exc))
120
+ raise
121
+
122
+ # Note: span is ended by context manager __exit__, then we enqueue
123
+ if integration._enqueue_span and span.is_ended:
124
+ integration._enqueue_span(span.to_dict())
125
+
126
+ requests.Session.send = patched_send # type: ignore[assignment]
127
+ self._enabled = True
128
+
129
+ def disable(self) -> None:
130
+ """Disable the integration by restoring the original Session.send."""
131
+ if not self._enabled or self._original_send is None:
132
+ return
133
+ try:
134
+ import requests
135
+ requests.Session.send = self._original_send # type: ignore[assignment]
136
+ except ImportError:
137
+ pass
138
+ self._original_send = None
139
+ self._enabled = False
140
+
141
+
142
+ def _extract_host(url: str) -> str:
143
+ """Extract the host portion from a URL for the span name."""
144
+ try:
145
+ from urllib.parse import urlparse
146
+ parsed = urlparse(url)
147
+ return parsed.netloc or url
148
+ except Exception:
149
+ return url
@@ -0,0 +1,146 @@
1
+ """
2
+ Auto-instrumentation for ``urllib3``.
3
+
4
+ Patches ``urllib3.HTTPConnectionPool.urlopen()`` to automatically create
5
+ client spans for outgoing HTTP requests at the connection pool level.
6
+ This captures HTTP traffic from libraries that use urllib3 under the hood
7
+ (including ``requests``).
8
+
9
+ Usage::
10
+
11
+ from justanalytics.integrations.urllib3 import Urllib3Integration
12
+
13
+ integration = Urllib3Integration(enqueue_span=transport.enqueue_span)
14
+ integration.enable()
15
+
16
+ # All urllib3 requests will now be traced
17
+
18
+ integration.disable()
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ from typing import Any, Callable, Optional
25
+
26
+ from .. import context as ctx
27
+ from ..span import Span
28
+ from ..trace_context import serialize_traceparent
29
+ from ..types import SpanKind, SpanStatus
30
+
31
+ logger = logging.getLogger("justanalytics.integrations.urllib3")
32
+
33
+
34
+ class Urllib3Integration:
35
+ """
36
+ Patches ``urllib3.HTTPConnectionPool.urlopen()`` for outgoing HTTP spans.
37
+
38
+ Args:
39
+ enqueue_span: Callback to enqueue ended spans for transport.
40
+ service_name: Service name for span attribution.
41
+ ignore_urls: URL patterns to exclude from instrumentation.
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ enqueue_span: Optional[Callable[[dict], None]] = None,
47
+ service_name: str = "unknown",
48
+ ignore_urls: Optional[list] = None,
49
+ ) -> None:
50
+ self._enqueue_span = enqueue_span
51
+ self._service_name = service_name
52
+ self._ignore_urls = ignore_urls or []
53
+ self._original_urlopen: Optional[Callable] = None
54
+ self._enabled = False
55
+
56
+ def enable(self) -> None:
57
+ """Enable the integration by monkey-patching urllib3."""
58
+ if self._enabled:
59
+ return
60
+ try:
61
+ import urllib3
62
+ except ImportError:
63
+ logger.debug("urllib3 library not installed, skipping integration")
64
+ return
65
+
66
+ self._original_urlopen = urllib3.HTTPConnectionPool.urlopen
67
+ integration = self
68
+
69
+ def patched_urlopen(
70
+ pool_self: Any, method: str, url: str, body: Any = None, headers: Any = None, **kwargs: Any
71
+ ) -> Any:
72
+ """Wrapped urlopen that creates spans for outgoing HTTP."""
73
+ # Reconstruct full URL for ignore checks
74
+ scheme = "https" if hasattr(pool_self, "scheme") and pool_self.scheme == "https" else "http"
75
+ host = getattr(pool_self, "host", "unknown")
76
+ port = getattr(pool_self, "port", None)
77
+ if port and port not in (80, 443):
78
+ full_url = f"{scheme}://{host}:{port}{url}"
79
+ else:
80
+ full_url = f"{scheme}://{host}{url}"
81
+
82
+ # Check ignore patterns
83
+ for pattern in integration._ignore_urls:
84
+ if isinstance(pattern, str) and pattern in full_url:
85
+ return integration._original_urlopen( # type: ignore[misc]
86
+ pool_self, method, url, body=body, headers=headers, **kwargs
87
+ )
88
+
89
+ method_upper = method.upper()
90
+ span_name = f"{method_upper} {host}"
91
+
92
+ parent = ctx.get_active_span()
93
+ span = Span(
94
+ operation_name=span_name,
95
+ service_name=integration._service_name,
96
+ kind=SpanKind.CLIENT,
97
+ trace_id=parent.trace_id if parent else None,
98
+ parent_span_id=parent.id if parent else None,
99
+ attributes={
100
+ "http.method": method_upper,
101
+ "http.url": full_url,
102
+ "net.peer.name": host,
103
+ },
104
+ )
105
+
106
+ # Inject traceparent header
107
+ if headers is None:
108
+ headers = {}
109
+ else:
110
+ headers = dict(headers)
111
+ headers["traceparent"] = serialize_traceparent(span.trace_id, span.id)
112
+
113
+ with span:
114
+ try:
115
+ response = integration._original_urlopen( # type: ignore[misc]
116
+ pool_self, method, url, body=body, headers=headers, **kwargs
117
+ )
118
+ status_code = getattr(response, "status", None)
119
+ if status_code is not None:
120
+ span.set_attribute("http.status_code", status_code)
121
+ if status_code >= 400:
122
+ span.set_status(SpanStatus.ERROR, f"HTTP {status_code}")
123
+ else:
124
+ span.set_status(SpanStatus.OK)
125
+ return response
126
+ except Exception as exc:
127
+ span.set_status(SpanStatus.ERROR, str(exc))
128
+ raise
129
+
130
+ if integration._enqueue_span and span.is_ended:
131
+ integration._enqueue_span(span.to_dict())
132
+
133
+ urllib3.HTTPConnectionPool.urlopen = patched_urlopen # type: ignore[assignment]
134
+ self._enabled = True
135
+
136
+ def disable(self) -> None:
137
+ """Disable the integration by restoring the original urlopen."""
138
+ if not self._enabled or self._original_urlopen is None:
139
+ return
140
+ try:
141
+ import urllib3
142
+ urllib3.HTTPConnectionPool.urlopen = self._original_urlopen # type: ignore[assignment]
143
+ except ImportError:
144
+ pass
145
+ self._original_urlopen = None
146
+ self._enabled = False