karrio-server-core 2025.5rc12__py3-none-any.whl → 2026.1.1__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.
- karrio/server/core/authentication.py +59 -25
- karrio/server/core/config.py +31 -0
- karrio/server/core/datatypes.py +30 -4
- karrio/server/core/dataunits.py +53 -22
- karrio/server/core/exceptions.py +287 -17
- karrio/server/core/filters.py +14 -0
- karrio/server/core/gateway.py +285 -10
- karrio/server/core/logging.py +403 -0
- karrio/server/core/management/commands/runserver.py +5 -0
- karrio/server/core/middleware.py +104 -2
- karrio/server/core/migrations/0006_add_api_log_requested_at_index.py +22 -0
- karrio/server/core/models/base.py +34 -1
- karrio/server/core/oauth_validators.py +2 -3
- karrio/server/core/permissions.py +1 -2
- karrio/server/core/serializers.py +183 -10
- karrio/server/core/signals.py +22 -28
- karrio/server/core/telemetry.py +573 -0
- karrio/server/core/tests/__init__.py +27 -0
- karrio/server/core/{tests.py → tests/base.py} +6 -7
- karrio/server/core/tests/test_exception_level.py +159 -0
- karrio/server/core/tests/test_resource_token.py +593 -0
- karrio/server/core/utils.py +688 -38
- karrio/server/core/validators.py +144 -222
- karrio/server/core/views/oauth.py +13 -12
- karrio/server/core/views/references.py +2 -2
- karrio/server/iam/apps.py +1 -4
- karrio/server/iam/migrations/0002_setup_carrier_permission_groups.py +103 -0
- karrio/server/iam/migrations/0003_remove_permission_groups.py +91 -0
- karrio/server/iam/permissions.py +7 -134
- karrio/server/iam/serializers.py +17 -2
- karrio/server/iam/signals.py +2 -4
- karrio/server/providers/admin.py +1 -1
- karrio/server/providers/management/commands/migrate_rate_sheets.py +101 -0
- karrio/server/providers/migrations/0082_add_zone_identifiers.py +50 -0
- karrio/server/providers/migrations/0083_add_optimized_rate_sheet_structure.py +33 -0
- karrio/server/providers/migrations/0084_alter_servicelevel_currency.py +168 -0
- karrio/server/providers/migrations/0085_populate_dhl_parcel_de_oauth_credentials.py +82 -0
- karrio/server/providers/migrations/0086_rename_dhl_parcel_de_customer_number_to_billing_number.py +71 -0
- karrio/server/providers/migrations/0087_alter_carrier_capabilities.py +38 -0
- karrio/server/providers/migrations/0088_servicelevel_surcharges.py +24 -0
- karrio/server/providers/migrations/0089_servicelevel_cost_max_volume.py +31 -0
- karrio/server/providers/migrations/0090_ratesheet_surcharges_servicelevel_zone_surcharge_ids.py +47 -0
- karrio/server/providers/migrations/0091_migrate_legacy_zones_surcharges.py +154 -0
- karrio/server/providers/models/__init__.py +1 -2
- karrio/server/providers/models/carrier.py +103 -18
- karrio/server/providers/models/service.py +188 -1
- karrio/server/providers/models/sheet.py +371 -0
- karrio/server/providers/serializers/base.py +263 -2
- karrio/server/providers/signals.py +2 -4
- karrio/server/providers/templates/providers/oauth_callback.html +105 -0
- karrio/server/providers/tests/__init__.py +5 -0
- karrio/server/providers/tests/test_connections.py +895 -0
- karrio/server/providers/views/carriers.py +1 -3
- karrio/server/providers/views/connections.py +322 -2
- karrio/server/samples.py +1 -1
- karrio/server/serializers/abstract.py +116 -21
- karrio/server/tracing/migrations/0007_tracingrecord_tracing_created_at_idx.py +19 -0
- karrio/server/tracing/models.py +2 -0
- karrio/server/tracing/utils.py +5 -8
- karrio/server/user/migrations/0007_user_metadata.py +25 -0
- karrio/server/user/models.py +38 -23
- karrio/server/user/serializers.py +1 -0
- karrio/server/user/templates/registration/registration_confirm_email.html +1 -1
- {karrio_server_core-2025.5rc12.dist-info → karrio_server_core-2026.1.1.dist-info}/METADATA +2 -2
- {karrio_server_core-2025.5rc12.dist-info → karrio_server_core-2026.1.1.dist-info}/RECORD +67 -86
- karrio/server/providers/extension/__init__.py +0 -1
- karrio/server/providers/extension/models/__init__.py +0 -1
- karrio/server/providers/extension/models/allied_express.py +0 -22
- karrio/server/providers/extension/models/allied_express_local.py +0 -22
- karrio/server/providers/extension/models/amazon_shipping.py +0 -27
- karrio/server/providers/extension/models/aramex.py +0 -25
- karrio/server/providers/extension/models/asendia_us.py +0 -21
- karrio/server/providers/extension/models/australiapost.py +0 -20
- karrio/server/providers/extension/models/boxknight.py +0 -19
- karrio/server/providers/extension/models/bpost.py +0 -21
- karrio/server/providers/extension/models/canadapost.py +0 -21
- karrio/server/providers/extension/models/canpar.py +0 -19
- karrio/server/providers/extension/models/chronopost.py +0 -22
- karrio/server/providers/extension/models/colissimo.py +0 -22
- karrio/server/providers/extension/models/dhl_express.py +0 -23
- karrio/server/providers/extension/models/dhl_parcel_de.py +0 -25
- karrio/server/providers/extension/models/dhl_poland.py +0 -22
- karrio/server/providers/extension/models/dhl_universal.py +0 -19
- karrio/server/providers/extension/models/dicom.py +0 -20
- karrio/server/providers/extension/models/dpd.py +0 -37
- karrio/server/providers/extension/models/dpdhl.py +0 -26
- karrio/server/providers/extension/models/easypost.py +0 -20
- karrio/server/providers/extension/models/eshipper.py +0 -21
- karrio/server/providers/extension/models/fedex.py +0 -25
- karrio/server/providers/extension/models/fedex_ws.py +0 -24
- karrio/server/providers/extension/models/freightcom.py +0 -21
- karrio/server/providers/extension/models/generic.py +0 -35
- karrio/server/providers/extension/models/geodis.py +0 -22
- karrio/server/providers/extension/models/hay_post.py +0 -22
- karrio/server/providers/extension/models/laposte.py +0 -19
- karrio/server/providers/extension/models/locate2u.py +0 -22
- karrio/server/providers/extension/models/nationex.py +0 -22
- karrio/server/providers/extension/models/purolator.py +0 -21
- karrio/server/providers/extension/models/roadie.py +0 -18
- karrio/server/providers/extension/models/royalmail.py +0 -19
- karrio/server/providers/extension/models/sendle.py +0 -22
- karrio/server/providers/extension/models/tge.py +0 -63
- karrio/server/providers/extension/models/tnt.py +0 -23
- karrio/server/providers/extension/models/ups.py +0 -23
- karrio/server/providers/extension/models/usps.py +0 -23
- karrio/server/providers/extension/models/usps_international.py +0 -23
- karrio/server/providers/extension/models/usps_wt.py +0 -24
- karrio/server/providers/extension/models/usps_wt_international.py +0 -24
- karrio/server/providers/extension/models/zoom2u.py +0 -23
- karrio/server/providers/tests.py +0 -3
- {karrio_server_core-2025.5rc12.dist-info → karrio_server_core-2026.1.1.dist-info}/WHEEL +0 -0
- {karrio_server_core-2025.5rc12.dist-info → karrio_server_core-2026.1.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Telemetry implementations for Karrio Server.
|
|
3
|
+
|
|
4
|
+
This module provides telemetry implementations for multiple APM providers:
|
|
5
|
+
- Sentry: Error tracking and performance monitoring
|
|
6
|
+
- OpenTelemetry (OTEL): Vendor-neutral observability standard
|
|
7
|
+
- Datadog: Full-stack observability platform
|
|
8
|
+
|
|
9
|
+
Provider Priority (first enabled wins):
|
|
10
|
+
1. SENTRY_DSN -> SentryTelemetry
|
|
11
|
+
2. OTEL_ENABLED=true -> OpenTelemetryTelemetry
|
|
12
|
+
3. DD_TRACE_ENABLED=true -> DatadogTelemetry
|
|
13
|
+
4. None -> NoOpTelemetry (zero overhead)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import typing
|
|
17
|
+
import functools
|
|
18
|
+
from functools import lru_cache
|
|
19
|
+
from enum import Enum
|
|
20
|
+
|
|
21
|
+
import karrio.lib as lib
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
T = typing.TypeVar("T")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def failsafe(callable: typing.Callable[[], T]) -> T:
|
|
28
|
+
"""Execute callable and return None on any exception."""
|
|
29
|
+
try:
|
|
30
|
+
return callable()
|
|
31
|
+
except Exception:
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TelemetryProvider(Enum):
|
|
36
|
+
NONE = "none"
|
|
37
|
+
SENTRY = "sentry"
|
|
38
|
+
OPENTELEMETRY = "opentelemetry"
|
|
39
|
+
DATADOG = "datadog"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# =============================================================================
|
|
43
|
+
# Provider Detection
|
|
44
|
+
# =============================================================================
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_sentry_enabled() -> bool:
|
|
48
|
+
return failsafe(lambda: bool(__import__("django.conf", fromlist=["settings"]).settings.SENTRY_DSN)) or False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_otel_enabled() -> bool:
|
|
52
|
+
return failsafe(lambda: bool(getattr(__import__("django.conf", fromlist=["settings"]).settings, "OTEL_ENABLED", False))) or False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_datadog_enabled() -> bool:
|
|
56
|
+
def _check():
|
|
57
|
+
settings = __import__("django.conf", fromlist=["settings"]).settings
|
|
58
|
+
return bool(getattr(settings, "DD_TRACE_ENABLED", False) or getattr(settings, "DATADOG_ENABLED", False))
|
|
59
|
+
return failsafe(_check) or False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_active_provider() -> TelemetryProvider:
|
|
63
|
+
if is_sentry_enabled():
|
|
64
|
+
return TelemetryProvider.SENTRY
|
|
65
|
+
if is_otel_enabled():
|
|
66
|
+
return TelemetryProvider.OPENTELEMETRY
|
|
67
|
+
if is_datadog_enabled():
|
|
68
|
+
return TelemetryProvider.DATADOG
|
|
69
|
+
return TelemetryProvider.NONE
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# =============================================================================
|
|
73
|
+
# Telemetry Factory
|
|
74
|
+
# =============================================================================
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@lru_cache(maxsize=1)
|
|
78
|
+
def get_telemetry_instance() -> lib.Telemetry:
|
|
79
|
+
provider = get_active_provider()
|
|
80
|
+
return {
|
|
81
|
+
TelemetryProvider.SENTRY: lambda: SentryTelemetry(),
|
|
82
|
+
TelemetryProvider.OPENTELEMETRY: lambda: OpenTelemetryTelemetry(),
|
|
83
|
+
TelemetryProvider.DATADOG: lambda: DatadogTelemetry(),
|
|
84
|
+
}.get(provider, lambda: lib.NoOpTelemetry())()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_telemetry_for_request() -> lib.Telemetry:
|
|
88
|
+
return get_telemetry_instance()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# =============================================================================
|
|
92
|
+
# Sentry Implementation
|
|
93
|
+
# =============================================================================
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class SentrySpanContext(lib.SpanContext):
|
|
97
|
+
def __init__(self, span):
|
|
98
|
+
self._span = span
|
|
99
|
+
|
|
100
|
+
def __enter__(self) -> "SentrySpanContext":
|
|
101
|
+
self._span and self._span.__enter__()
|
|
102
|
+
return self
|
|
103
|
+
|
|
104
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
105
|
+
if not self._span:
|
|
106
|
+
return
|
|
107
|
+
if exc_val:
|
|
108
|
+
self._span.set_status("error")
|
|
109
|
+
self._span.set_data("error.message", str(exc_val))
|
|
110
|
+
self._span.__exit__(exc_type, exc_val, exc_tb)
|
|
111
|
+
|
|
112
|
+
def set_attribute(self, key: str, value: typing.Any) -> None:
|
|
113
|
+
self._span and key and self._span.set_data(key, value)
|
|
114
|
+
|
|
115
|
+
def set_status(self, status: str, message: str = None) -> None:
|
|
116
|
+
if not self._span:
|
|
117
|
+
return
|
|
118
|
+
self._span.set_status(status or "ok")
|
|
119
|
+
message and self._span.set_data("status.message", message)
|
|
120
|
+
|
|
121
|
+
def record_exception(self, exception: Exception) -> None:
|
|
122
|
+
if self._span and exception:
|
|
123
|
+
self._span.set_data("exception.type", type(exception).__name__)
|
|
124
|
+
self._span.set_data("exception.message", str(exception))
|
|
125
|
+
|
|
126
|
+
def add_event(self, name: str, attributes: typing.Dict[str, typing.Any] = None) -> None:
|
|
127
|
+
self._span and name and self._span.set_data(f"event.{name}", attributes or {})
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class SentryTelemetry(lib.Telemetry):
|
|
131
|
+
"""Sentry-backed telemetry. Requires: sentry-sdk >= 2.0.0"""
|
|
132
|
+
|
|
133
|
+
def start_span(self, name: str, attributes: typing.Dict[str, typing.Any] = None, kind: str = None) -> lib.SpanContext:
|
|
134
|
+
try:
|
|
135
|
+
import sentry_sdk
|
|
136
|
+
current = sentry_sdk.get_current_span()
|
|
137
|
+
span = (
|
|
138
|
+
current.start_child(op=kind or "function", name=name)
|
|
139
|
+
if current else
|
|
140
|
+
sentry_sdk.start_span(op=kind or "function", name=name)
|
|
141
|
+
)
|
|
142
|
+
[span.set_data(k, v) for k, v in (attributes or {}).items()]
|
|
143
|
+
return SentrySpanContext(span)
|
|
144
|
+
except Exception:
|
|
145
|
+
return lib.NoOpSpanContext()
|
|
146
|
+
|
|
147
|
+
def add_breadcrumb(self, message: str, category: str, data: typing.Dict[str, typing.Any] = None, level: str = "info") -> None:
|
|
148
|
+
failsafe(lambda: __import__("sentry_sdk").add_breadcrumb(
|
|
149
|
+
message=message, category=category or "default", data=data or {}, level=level or "info"
|
|
150
|
+
))
|
|
151
|
+
|
|
152
|
+
def record_metric(self, name: str, value: float, unit: str = None, tags: typing.Dict[str, str] = None, metric_type: str = "counter") -> None:
|
|
153
|
+
def _record():
|
|
154
|
+
from sentry_sdk import metrics
|
|
155
|
+
metric_tags = tags or {}
|
|
156
|
+
# Use metrics.count for counters (newer API), metrics.gauge for gauges, metrics.distribution for histograms
|
|
157
|
+
if metric_type == "counter":
|
|
158
|
+
metrics.count(name, value, tags=metric_tags, unit=unit)
|
|
159
|
+
elif metric_type == "gauge":
|
|
160
|
+
metrics.gauge(name, value, tags=metric_tags, unit=unit)
|
|
161
|
+
elif metric_type == "distribution":
|
|
162
|
+
metrics.distribution(name, value, tags=metric_tags, unit=unit)
|
|
163
|
+
else:
|
|
164
|
+
metrics.count(name, value, tags=metric_tags, unit=unit)
|
|
165
|
+
failsafe(_record)
|
|
166
|
+
|
|
167
|
+
def capture_exception(self, exception: Exception, context: typing.Dict[str, typing.Any] = None, tags: typing.Dict[str, str] = None) -> None:
|
|
168
|
+
def _capture():
|
|
169
|
+
import sentry_sdk
|
|
170
|
+
with sentry_sdk.push_scope() as scope:
|
|
171
|
+
context and scope.set_context("karrio", context)
|
|
172
|
+
[scope.set_tag(k, v) for k, v in (tags or {}).items()]
|
|
173
|
+
sentry_sdk.capture_exception(exception)
|
|
174
|
+
failsafe(_capture)
|
|
175
|
+
|
|
176
|
+
def set_context(self, name: str, data: typing.Dict[str, typing.Any]) -> None:
|
|
177
|
+
failsafe(lambda: __import__("sentry_sdk").set_context(name, data or {}))
|
|
178
|
+
|
|
179
|
+
def set_tag(self, key: str, value: str) -> None:
|
|
180
|
+
failsafe(lambda: __import__("sentry_sdk").set_tag(key, str(value) if value is not None else ""))
|
|
181
|
+
|
|
182
|
+
def set_user(self, user_id: str = None, email: str = None, username: str = None, ip_address: str = None, data: typing.Dict[str, typing.Any] = None) -> None:
|
|
183
|
+
def _set_user():
|
|
184
|
+
import sentry_sdk
|
|
185
|
+
user_data = {
|
|
186
|
+
**({"id": user_id} if user_id else {}),
|
|
187
|
+
**({"email": email} if email else {}),
|
|
188
|
+
**({"username": username} if username else {}),
|
|
189
|
+
**({"ip_address": ip_address} if ip_address else {}),
|
|
190
|
+
**(data or {}),
|
|
191
|
+
}
|
|
192
|
+
user_data and sentry_sdk.set_user(user_data)
|
|
193
|
+
failsafe(_set_user)
|
|
194
|
+
|
|
195
|
+
def start_transaction(self, name: str, op: str = None, attributes: typing.Dict[str, typing.Any] = None) -> lib.SpanContext:
|
|
196
|
+
try:
|
|
197
|
+
import sentry_sdk
|
|
198
|
+
transaction = sentry_sdk.start_transaction(name=name, op=op or "http.server")
|
|
199
|
+
[transaction.set_data(k, v) for k, v in (attributes or {}).items()]
|
|
200
|
+
return SentrySpanContext(transaction)
|
|
201
|
+
except Exception:
|
|
202
|
+
return lib.NoOpSpanContext()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# =============================================================================
|
|
206
|
+
# OpenTelemetry Implementation
|
|
207
|
+
# =============================================================================
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class OTELSpanContext(lib.SpanContext):
|
|
211
|
+
def __init__(self, span, token=None):
|
|
212
|
+
self._span = span
|
|
213
|
+
self._token = token
|
|
214
|
+
|
|
215
|
+
def __enter__(self) -> "OTELSpanContext":
|
|
216
|
+
return self
|
|
217
|
+
|
|
218
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
219
|
+
if not self._span:
|
|
220
|
+
return
|
|
221
|
+
try:
|
|
222
|
+
from opentelemetry.trace import StatusCode
|
|
223
|
+
if exc_val:
|
|
224
|
+
self._span.set_status(StatusCode.ERROR, str(exc_val))
|
|
225
|
+
self._span.record_exception(exc_val)
|
|
226
|
+
else:
|
|
227
|
+
self._span.set_status(StatusCode.OK)
|
|
228
|
+
self._span.end()
|
|
229
|
+
except Exception:
|
|
230
|
+
pass
|
|
231
|
+
finally:
|
|
232
|
+
self._token and failsafe(lambda: __import__("opentelemetry").context.detach(self._token))
|
|
233
|
+
|
|
234
|
+
def _safe_value(self, value: typing.Any) -> typing.Any:
|
|
235
|
+
if value is None:
|
|
236
|
+
return ""
|
|
237
|
+
if isinstance(value, (str, int, float, bool)):
|
|
238
|
+
return value
|
|
239
|
+
if isinstance(value, (list, tuple)):
|
|
240
|
+
return [str(v) for v in value]
|
|
241
|
+
return str(value)
|
|
242
|
+
|
|
243
|
+
def set_attribute(self, key: str, value: typing.Any) -> None:
|
|
244
|
+
self._span and key and self._span.set_attribute(key, self._safe_value(value))
|
|
245
|
+
|
|
246
|
+
def set_status(self, status: str, message: str = None) -> None:
|
|
247
|
+
def _set():
|
|
248
|
+
from opentelemetry.trace import StatusCode
|
|
249
|
+
code = {"ok": StatusCode.OK, "error": StatusCode.ERROR}.get((status or "ok").lower(), StatusCode.UNSET)
|
|
250
|
+
self._span.set_status(code, message)
|
|
251
|
+
self._span and failsafe(_set)
|
|
252
|
+
|
|
253
|
+
def record_exception(self, exception: Exception) -> None:
|
|
254
|
+
self._span and exception and self._span.record_exception(exception)
|
|
255
|
+
|
|
256
|
+
def add_event(self, name: str, attributes: typing.Dict[str, typing.Any] = None) -> None:
|
|
257
|
+
if self._span and name:
|
|
258
|
+
safe_attrs = {k: self._safe_value(v) for k, v in (attributes or {}).items()}
|
|
259
|
+
self._span.add_event(name, attributes=safe_attrs or None)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class OpenTelemetryTelemetry(lib.Telemetry):
|
|
263
|
+
"""OpenTelemetry-backed telemetry. Requires: opentelemetry-api >= 1.20.0"""
|
|
264
|
+
|
|
265
|
+
def __init__(self):
|
|
266
|
+
self._tracer = None
|
|
267
|
+
self._meter = None
|
|
268
|
+
|
|
269
|
+
def _get_tracer(self):
|
|
270
|
+
if not self._tracer:
|
|
271
|
+
self._tracer = failsafe(lambda: __import__("opentelemetry").trace.get_tracer("karrio"))
|
|
272
|
+
return self._tracer
|
|
273
|
+
|
|
274
|
+
def _get_meter(self):
|
|
275
|
+
if not self._meter:
|
|
276
|
+
self._meter = failsafe(lambda: __import__("opentelemetry").metrics.get_meter("karrio"))
|
|
277
|
+
return self._meter
|
|
278
|
+
|
|
279
|
+
def _safe_value(self, value: typing.Any) -> typing.Any:
|
|
280
|
+
if value is None:
|
|
281
|
+
return ""
|
|
282
|
+
if isinstance(value, (str, int, float, bool)):
|
|
283
|
+
return value
|
|
284
|
+
if isinstance(value, (list, tuple)):
|
|
285
|
+
return [str(v) for v in value]
|
|
286
|
+
return str(value)
|
|
287
|
+
|
|
288
|
+
def start_span(self, name: str, attributes: typing.Dict[str, typing.Any] = None, kind: str = None) -> lib.SpanContext:
|
|
289
|
+
try:
|
|
290
|
+
from opentelemetry import trace, context
|
|
291
|
+
from opentelemetry.trace import SpanKind
|
|
292
|
+
|
|
293
|
+
tracer = self._get_tracer()
|
|
294
|
+
if not tracer:
|
|
295
|
+
return lib.NoOpSpanContext()
|
|
296
|
+
|
|
297
|
+
kind_map = {"client": SpanKind.CLIENT, "server": SpanKind.SERVER, "producer": SpanKind.PRODUCER, "consumer": SpanKind.CONSUMER}
|
|
298
|
+
span_kind = kind_map.get((kind or "").lower(), SpanKind.INTERNAL)
|
|
299
|
+
safe_attrs = {k: self._safe_value(v) for k, v in (attributes or {}).items()} or None
|
|
300
|
+
|
|
301
|
+
span = tracer.start_span(name, kind=span_kind, attributes=safe_attrs)
|
|
302
|
+
ctx = trace.set_span_in_context(span)
|
|
303
|
+
token = context.attach(ctx)
|
|
304
|
+
|
|
305
|
+
return OTELSpanContext(span, token)
|
|
306
|
+
except Exception:
|
|
307
|
+
return lib.NoOpSpanContext()
|
|
308
|
+
|
|
309
|
+
def add_breadcrumb(self, message: str, category: str, data: typing.Dict[str, typing.Any] = None, level: str = "info") -> None:
|
|
310
|
+
def _add():
|
|
311
|
+
from opentelemetry import trace
|
|
312
|
+
span = trace.get_current_span()
|
|
313
|
+
if span and span.is_recording():
|
|
314
|
+
attrs = {"category": category or "default", "level": level or "info", **{k: self._safe_value(v) for k, v in (data or {}).items()}}
|
|
315
|
+
span.add_event(message, attributes=attrs)
|
|
316
|
+
failsafe(_add)
|
|
317
|
+
|
|
318
|
+
def record_metric(self, name: str, value: float, unit: str = None, tags: typing.Dict[str, str] = None, metric_type: str = "counter") -> None:
|
|
319
|
+
def _record():
|
|
320
|
+
meter = self._get_meter()
|
|
321
|
+
if not meter:
|
|
322
|
+
return
|
|
323
|
+
attrs = {k: str(v) for k, v in (tags or {}).items()}
|
|
324
|
+
if metric_type == "counter":
|
|
325
|
+
meter.create_counter(name, unit=unit or "1").add(max(0, int(value)), attributes=attrs)
|
|
326
|
+
elif metric_type == "gauge":
|
|
327
|
+
meter.create_up_down_counter(name, unit=unit or "1").add(int(value), attributes=attrs)
|
|
328
|
+
elif metric_type == "distribution":
|
|
329
|
+
meter.create_histogram(name, unit=unit or "ms").record(value, attributes=attrs)
|
|
330
|
+
failsafe(_record)
|
|
331
|
+
|
|
332
|
+
def capture_exception(self, exception: Exception, context: typing.Dict[str, typing.Any] = None, tags: typing.Dict[str, str] = None) -> None:
|
|
333
|
+
def _capture():
|
|
334
|
+
from opentelemetry import trace
|
|
335
|
+
span = trace.get_current_span()
|
|
336
|
+
if span and span.is_recording():
|
|
337
|
+
span.record_exception(exception)
|
|
338
|
+
[span.set_attribute(f"context.{k}", self._safe_value(v)) for k, v in (context or {}).items()]
|
|
339
|
+
[span.set_attribute(k, v) for k, v in (tags or {}).items()]
|
|
340
|
+
failsafe(_capture)
|
|
341
|
+
|
|
342
|
+
def set_context(self, name: str, data: typing.Dict[str, typing.Any]) -> None:
|
|
343
|
+
def _set():
|
|
344
|
+
from opentelemetry import trace
|
|
345
|
+
span = trace.get_current_span()
|
|
346
|
+
if span and span.is_recording():
|
|
347
|
+
[span.set_attribute(f"{name}.{k}", self._safe_value(v)) for k, v in (data or {}).items()]
|
|
348
|
+
failsafe(_set)
|
|
349
|
+
|
|
350
|
+
def set_tag(self, key: str, value: str) -> None:
|
|
351
|
+
def _set():
|
|
352
|
+
from opentelemetry import trace
|
|
353
|
+
span = trace.get_current_span()
|
|
354
|
+
span and span.is_recording() and span.set_attribute(key, str(value) if value is not None else "")
|
|
355
|
+
failsafe(_set)
|
|
356
|
+
|
|
357
|
+
def set_user(self, user_id: str = None, email: str = None, username: str = None, ip_address: str = None, data: typing.Dict[str, typing.Any] = None) -> None:
|
|
358
|
+
def _set():
|
|
359
|
+
from opentelemetry import trace
|
|
360
|
+
span = trace.get_current_span()
|
|
361
|
+
if span and span.is_recording():
|
|
362
|
+
user_id and span.set_attribute("enduser.id", user_id)
|
|
363
|
+
email and span.set_attribute("enduser.email", email)
|
|
364
|
+
username and span.set_attribute("enduser.username", username)
|
|
365
|
+
ip_address and span.set_attribute("client.address", ip_address)
|
|
366
|
+
[span.set_attribute(f"enduser.{k}", self._safe_value(v)) for k, v in (data or {}).items()]
|
|
367
|
+
failsafe(_set)
|
|
368
|
+
|
|
369
|
+
def start_transaction(self, name: str, op: str = None, attributes: typing.Dict[str, typing.Any] = None) -> lib.SpanContext:
|
|
370
|
+
attrs = {**(attributes or {}), **({"operation": op} if op else {})}
|
|
371
|
+
return self.start_span(name, attributes=attrs or None, kind="server")
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# =============================================================================
|
|
375
|
+
# Datadog Implementation
|
|
376
|
+
# =============================================================================
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class DatadogSpanContext(lib.SpanContext):
|
|
380
|
+
def __init__(self, span):
|
|
381
|
+
self._span = span
|
|
382
|
+
|
|
383
|
+
def __enter__(self) -> "DatadogSpanContext":
|
|
384
|
+
self._span and failsafe(lambda: self._span.__enter__())
|
|
385
|
+
return self
|
|
386
|
+
|
|
387
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
388
|
+
if not self._span:
|
|
389
|
+
return
|
|
390
|
+
exc_val and failsafe(lambda: self._span.set_exc_info(exc_type, exc_val, exc_tb))
|
|
391
|
+
failsafe(lambda: self._span.__exit__(exc_type, exc_val, exc_tb))
|
|
392
|
+
|
|
393
|
+
def _safe_value(self, value: typing.Any) -> typing.Any:
|
|
394
|
+
return value if isinstance(value, (str, int, float, bool)) else str(value) if value is not None else ""
|
|
395
|
+
|
|
396
|
+
def set_attribute(self, key: str, value: typing.Any) -> None:
|
|
397
|
+
self._span and key and failsafe(lambda: self._span.set_tag(key, self._safe_value(value)))
|
|
398
|
+
|
|
399
|
+
def set_status(self, status: str, message: str = None) -> None:
|
|
400
|
+
def _set():
|
|
401
|
+
if (status or "ok").lower() == "error":
|
|
402
|
+
self._span.error = 1
|
|
403
|
+
message and self._span.set_tag("error.message", message)
|
|
404
|
+
else:
|
|
405
|
+
self._span.error = 0
|
|
406
|
+
self._span and failsafe(_set)
|
|
407
|
+
|
|
408
|
+
def record_exception(self, exception: Exception) -> None:
|
|
409
|
+
def _record():
|
|
410
|
+
import sys
|
|
411
|
+
info = sys.exc_info()
|
|
412
|
+
self._span.set_exc_info(info[0], info[1], info[2]) if info[1] is exception else self._span.set_exc_info(type(exception), exception, None)
|
|
413
|
+
self._span and exception and failsafe(_record)
|
|
414
|
+
|
|
415
|
+
def add_event(self, name: str, attributes: typing.Dict[str, typing.Any] = None) -> None:
|
|
416
|
+
self._span and name and failsafe(lambda: self._span.set_tag(f"event.{name}", str(attributes or {})))
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
class DatadogTelemetry(lib.Telemetry):
|
|
420
|
+
"""Datadog-backed telemetry. Requires: ddtrace >= 2.0.0"""
|
|
421
|
+
|
|
422
|
+
def __init__(self):
|
|
423
|
+
self._tracer = None
|
|
424
|
+
|
|
425
|
+
def _get_tracer(self):
|
|
426
|
+
if not self._tracer:
|
|
427
|
+
self._tracer = failsafe(lambda: __import__("ddtrace").tracer)
|
|
428
|
+
return self._tracer
|
|
429
|
+
|
|
430
|
+
def _safe_value(self, value: typing.Any) -> typing.Any:
|
|
431
|
+
return value if isinstance(value, (str, int, float, bool)) else str(value) if value is not None else ""
|
|
432
|
+
|
|
433
|
+
def start_span(self, name: str, attributes: typing.Dict[str, typing.Any] = None, kind: str = None) -> lib.SpanContext:
|
|
434
|
+
try:
|
|
435
|
+
tracer = self._get_tracer()
|
|
436
|
+
if not tracer:
|
|
437
|
+
return lib.NoOpSpanContext()
|
|
438
|
+
|
|
439
|
+
kind_map = {"client": "http", "server": "web", "consumer": "worker", "producer": "worker", "internal": "custom"}
|
|
440
|
+
span = tracer.trace(name, service="karrio", span_type=kind_map.get((kind or "").lower()))
|
|
441
|
+
[span.set_tag(k, self._safe_value(v)) for k, v in (attributes or {}).items()]
|
|
442
|
+
|
|
443
|
+
return DatadogSpanContext(span)
|
|
444
|
+
except Exception:
|
|
445
|
+
return lib.NoOpSpanContext()
|
|
446
|
+
|
|
447
|
+
def add_breadcrumb(self, message: str, category: str, data: typing.Dict[str, typing.Any] = None, level: str = "info") -> None:
|
|
448
|
+
def _add():
|
|
449
|
+
tracer = self._get_tracer()
|
|
450
|
+
span = tracer and tracer.current_span()
|
|
451
|
+
if span:
|
|
452
|
+
cat = category or "default"
|
|
453
|
+
span.set_tag(f"breadcrumb.{cat}", message)
|
|
454
|
+
[span.set_tag(f"breadcrumb.{cat}.{k}", str(v)) for k, v in (data or {}).items()]
|
|
455
|
+
failsafe(_add)
|
|
456
|
+
|
|
457
|
+
def record_metric(self, name: str, value: float, unit: str = None, tags: typing.Dict[str, str] = None, metric_type: str = "counter") -> None:
|
|
458
|
+
def _record():
|
|
459
|
+
try:
|
|
460
|
+
from datadog import statsd
|
|
461
|
+
dd_tags = [f"{k}:{v}" for k, v in (tags or {}).items()]
|
|
462
|
+
{"counter": statsd.increment, "gauge": statsd.gauge, "distribution": statsd.distribution}.get(
|
|
463
|
+
metric_type, statsd.increment
|
|
464
|
+
)(name, value, tags=dd_tags)
|
|
465
|
+
except ImportError:
|
|
466
|
+
tracer = self._get_tracer()
|
|
467
|
+
span = tracer and tracer.current_span()
|
|
468
|
+
span and span.set_metric(name, value)
|
|
469
|
+
failsafe(_record)
|
|
470
|
+
|
|
471
|
+
def capture_exception(self, exception: Exception, context: typing.Dict[str, typing.Any] = None, tags: typing.Dict[str, str] = None) -> None:
|
|
472
|
+
def _capture():
|
|
473
|
+
import sys
|
|
474
|
+
tracer = self._get_tracer()
|
|
475
|
+
span = tracer and tracer.current_span()
|
|
476
|
+
if span:
|
|
477
|
+
info = sys.exc_info()
|
|
478
|
+
span.set_exc_info(info[0], info[1], info[2]) if info[1] is exception else span.set_exc_info(type(exception), exception, None)
|
|
479
|
+
[span.set_tag(f"context.{k}", str(v)) for k, v in (context or {}).items()]
|
|
480
|
+
[span.set_tag(k, v) for k, v in (tags or {}).items()]
|
|
481
|
+
failsafe(_capture)
|
|
482
|
+
|
|
483
|
+
def set_context(self, name: str, data: typing.Dict[str, typing.Any]) -> None:
|
|
484
|
+
def _set():
|
|
485
|
+
tracer = self._get_tracer()
|
|
486
|
+
span = tracer and tracer.current_span()
|
|
487
|
+
span and [span.set_tag(f"{name}.{k}", str(v)) for k, v in (data or {}).items()]
|
|
488
|
+
failsafe(_set)
|
|
489
|
+
|
|
490
|
+
def set_tag(self, key: str, value: str) -> None:
|
|
491
|
+
def _set():
|
|
492
|
+
tracer = self._get_tracer()
|
|
493
|
+
span = tracer and tracer.current_span()
|
|
494
|
+
span and span.set_tag(key, str(value) if value is not None else "")
|
|
495
|
+
failsafe(_set)
|
|
496
|
+
|
|
497
|
+
def set_user(self, user_id: str = None, email: str = None, username: str = None, ip_address: str = None, data: typing.Dict[str, typing.Any] = None) -> None:
|
|
498
|
+
def _set():
|
|
499
|
+
try:
|
|
500
|
+
from ddtrace import tracer
|
|
501
|
+
from ddtrace.contrib.trace_utils import set_user
|
|
502
|
+
if any([user_id, email, username]):
|
|
503
|
+
set_user(tracer, user_id=user_id, email=email, name=username)
|
|
504
|
+
return
|
|
505
|
+
except (ImportError, AttributeError):
|
|
506
|
+
pass
|
|
507
|
+
tracer = self._get_tracer()
|
|
508
|
+
span = tracer and tracer.current_span()
|
|
509
|
+
if span:
|
|
510
|
+
user_id and span.set_tag("usr.id", user_id)
|
|
511
|
+
email and span.set_tag("usr.email", email)
|
|
512
|
+
username and span.set_tag("usr.name", username)
|
|
513
|
+
ip_address and span.set_tag("http.client_ip", ip_address)
|
|
514
|
+
[span.set_tag(f"usr.{k}", str(v)) for k, v in (data or {}).items()]
|
|
515
|
+
failsafe(_set)
|
|
516
|
+
|
|
517
|
+
def start_transaction(self, name: str, op: str = None, attributes: typing.Dict[str, typing.Any] = None) -> lib.SpanContext:
|
|
518
|
+
attrs = {**(attributes or {}), **({"operation": op} if op else {})}
|
|
519
|
+
return self.start_span(name, attributes=attrs or None, kind="server")
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
# =============================================================================
|
|
523
|
+
# Background Task Utilities
|
|
524
|
+
# =============================================================================
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def create_task_tracer(task_name: str = None, context: typing.Dict[str, typing.Any] = None) -> lib.Tracer:
|
|
528
|
+
"""Create a Tracer with telemetry for background tasks (Huey/Celery).
|
|
529
|
+
|
|
530
|
+
Works correctly without Django HTTP request context.
|
|
531
|
+
"""
|
|
532
|
+
tracer = lib.Tracer()
|
|
533
|
+
tracer.set_telemetry(get_telemetry_instance())
|
|
534
|
+
tracer.set_tag("execution.type", "background_task")
|
|
535
|
+
task_name and tracer.set_tag("task.name", task_name)
|
|
536
|
+
|
|
537
|
+
ctx = context or {}
|
|
538
|
+
ctx.get("user_id") and tracer.set_user(user_id=str(ctx["user_id"]))
|
|
539
|
+
ctx.get("org_id") and tracer.set_tag("org.id", str(ctx["org_id"]))
|
|
540
|
+
ctx.get("test_mode") is not None and tracer.set_tag("test_mode", str(ctx["test_mode"]).lower())
|
|
541
|
+
|
|
542
|
+
return tracer
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def with_task_telemetry(task_name: str = None):
|
|
546
|
+
"""Decorator that adds telemetry instrumentation to background tasks."""
|
|
547
|
+
def decorator(func):
|
|
548
|
+
@functools.wraps(func)
|
|
549
|
+
def wrapper(*args, **kwargs):
|
|
550
|
+
op_name = task_name or func.__name__
|
|
551
|
+
telemetry = get_telemetry_instance()
|
|
552
|
+
|
|
553
|
+
telemetry.add_breadcrumb(f"Starting background task: {op_name}", "task", {"task_name": op_name}, "info")
|
|
554
|
+
|
|
555
|
+
with telemetry.start_span(f"task.{op_name}", kind="consumer") as span:
|
|
556
|
+
span.set_attribute("task.name", op_name)
|
|
557
|
+
span.set_attribute("task.type", "background")
|
|
558
|
+
|
|
559
|
+
try:
|
|
560
|
+
result = func(*args, **kwargs)
|
|
561
|
+
span.set_status("ok")
|
|
562
|
+
telemetry.record_metric(f"karrio_task_{op_name}_success", 1, tags={"task_name": op_name})
|
|
563
|
+
return result
|
|
564
|
+
|
|
565
|
+
except Exception as e:
|
|
566
|
+
span.set_status("error", str(e))
|
|
567
|
+
span.record_exception(e)
|
|
568
|
+
telemetry.record_metric(f"karrio_task_{op_name}_error", 1, tags={"task_name": op_name, "error_type": type(e).__name__})
|
|
569
|
+
telemetry.capture_exception(e, context={"task_name": op_name}, tags={"task_name": op_name})
|
|
570
|
+
raise
|
|
571
|
+
|
|
572
|
+
return wrapper
|
|
573
|
+
return decorator
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Backward-compatible exports for karrio.server.core.tests
|
|
2
|
+
# This allows existing imports like:
|
|
3
|
+
# from karrio.server.core.tests import APITestCase
|
|
4
|
+
# to continue working
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
logging.disable(logging.CRITICAL)
|
|
9
|
+
|
|
10
|
+
# Import test classes explicitly to enable Django's test discovery
|
|
11
|
+
from karrio.server.core.tests.test_exception_level import (
|
|
12
|
+
TestGetDefaultLevel,
|
|
13
|
+
TestAPIException,
|
|
14
|
+
TestIndexedAPIException,
|
|
15
|
+
TestErrorLevelDefaults,
|
|
16
|
+
TestErrorDatatype,
|
|
17
|
+
)
|
|
18
|
+
from karrio.server.core.tests.test_resource_token import (
|
|
19
|
+
TestResourceAccessTokenUnit,
|
|
20
|
+
TestResourceTokenAPI,
|
|
21
|
+
TestDocumentDownloadWithAPIToken,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# Import our custom APITestCase (must be last to avoid being overridden)
|
|
25
|
+
from karrio.server.core.tests.base import APITestCase
|
|
26
|
+
|
|
27
|
+
__all__ = ["APITestCase"]
|
|
@@ -1,19 +1,16 @@
|
|
|
1
|
-
import logging
|
|
2
1
|
from django.contrib.auth import get_user_model
|
|
2
|
+
from django.urls import reverse
|
|
3
3
|
from rest_framework.test import APITestCase as BaseAPITestCase, APIClient
|
|
4
|
+
from karrio.server.core.logging import logger
|
|
4
5
|
|
|
5
6
|
from karrio.server.user.models import Token
|
|
6
|
-
import karrio.server.iam.permissions as iam
|
|
7
7
|
import karrio.server.providers.models as providers
|
|
8
8
|
|
|
9
|
-
logger = logging.getLogger(__name__)
|
|
10
|
-
iam.setup_groups()
|
|
11
|
-
|
|
12
9
|
|
|
13
10
|
class APITestCase(BaseAPITestCase):
|
|
14
11
|
def setUp(self) -> None:
|
|
15
12
|
self.maxDiff = None
|
|
16
|
-
|
|
13
|
+
# Loguru is already configured globally in settings
|
|
17
14
|
|
|
18
15
|
# Setup user and API Token.
|
|
19
16
|
self.user = get_user_model().objects.create_superuser(
|
|
@@ -92,7 +89,9 @@ class APITestCase(BaseAPITestCase):
|
|
|
92
89
|
is_ok = f"{response.status_code}".startswith("2")
|
|
93
90
|
|
|
94
91
|
if is_ok is False or response.data.get("errors") is not None:
|
|
95
|
-
|
|
92
|
+
logger.error("Response has errors",
|
|
93
|
+
status_code=response.status_code,
|
|
94
|
+
response_data=response.data)
|
|
96
95
|
|
|
97
96
|
self.assertTrue(is_ok)
|
|
98
97
|
assert response.data.get("errors") is None
|