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.
- justanalytics/__init__.py +429 -0
- justanalytics/client.py +665 -0
- justanalytics/context.py +143 -0
- justanalytics/integrations/__init__.py +11 -0
- justanalytics/integrations/django.py +157 -0
- justanalytics/integrations/fastapi.py +197 -0
- justanalytics/integrations/flask.py +203 -0
- justanalytics/integrations/logging.py +175 -0
- justanalytics/integrations/requests.py +149 -0
- justanalytics/integrations/urllib3.py +146 -0
- justanalytics/span.py +281 -0
- justanalytics/trace_context.py +124 -0
- justanalytics/transport.py +430 -0
- justanalytics/types.py +214 -0
- justanalytics_python-0.1.0.dist-info/METADATA +173 -0
- justanalytics_python-0.1.0.dist-info/RECORD +17 -0
- justanalytics_python-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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
|