tp-trace 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {tp_trace-0.2.0 → tp_trace-0.3.0}/PKG-INFO +3 -3
- {tp_trace-0.2.0 → tp_trace-0.3.0}/README.md +1 -1
- {tp_trace-0.2.0 → tp_trace-0.3.0}/pyproject.toml +1 -1
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/__init__.py +7 -1
- tp_trace-0.3.0/src/tp_trace/compat/__init__.py +1 -0
- tp_trace-0.3.0/src/tp_trace/compat/attributes.py +191 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/config.py +31 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/exporter.py +3 -87
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/django/cache.py +5 -1
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/django/request.py +83 -25
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/django/view.py +5 -5
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/postgres/cursor.py +5 -3
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/metrics.py +324 -9
- tp_trace-0.3.0/src/tp_trace/normalize.py +107 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/red.py +71 -19
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/route_context.py +2 -97
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/sanitize.py +126 -23
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/tracing.py +8 -2
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/version.py +1 -1
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace.egg-info/PKG-INFO +3 -3
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace.egg-info/SOURCES.txt +7 -0
- tp_trace-0.3.0/tests/test_cardinality_diagnostics.py +386 -0
- tp_trace-0.3.0/tests/test_django_safety_regressions.py +106 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_init.py +1 -1
- {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_metrics.py +48 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_n_plus_one.py +52 -3
- {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_n_plus_one_dashboard_contract.py +39 -2
- {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_postgres.py +18 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_red.py +70 -7
- {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_red_dashboard_contract.py +203 -2
- {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_safety.py +62 -1
- {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_sample_telemetry.py +1 -1
- tp_trace-0.3.0/tests/test_sanitization_regressions.py +164 -0
- tp_trace-0.3.0/tests/test_shared_config.py +57 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_traffic_generator.py +1 -1
- {tp_trace-0.2.0 → tp_trace-0.3.0}/setup.cfg +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/config_access.py +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/__init__.py +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/base.py +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/boto/__init__.py +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/boto/integration.py +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/celery/__init__.py +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/celery/context.py +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/celery/instrumentor.py +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/celery/integration.py +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/django/__init__.py +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/django/auth.py +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/django/compat.py +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/django/integration.py +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/django/middleware.py +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/django/template.py +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/manager.py +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/postgres/__init__.py +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/postgres/integration.py +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/redis/__init__.py +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/redis/integration.py +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/requests/__init__.py +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/requests/client.py +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/requests/integration.py +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/propagator.py +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/safety.py +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/sampler.py +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace.egg-info/dependency_links.txt +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace.egg-info/requires.txt +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace.egg-info/top_level.txt +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_base_integration.py +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_boto.py +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_celery_integration.py +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_celery_probe.py +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_django.py +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_django_advanced.py +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_django_migration_regressions.py +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_red_comparison.py +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_redis.py +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_requests.py +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_sampling_rules.py +0 -0
- {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_smoke_probe.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: tp_trace
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: tp_trace: High-performance Python Observability SDK for OpenTelemetry
|
|
5
5
|
Requires-Python: >=3.8
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -38,7 +38,7 @@ The SDK exports OTLP traces to the team's telemetry endpoint. Tempo stores the t
|
|
|
38
38
|
|
|
39
39
|
## Start here
|
|
40
40
|
|
|
41
|
-
- [Run the local development stack](dev/README.md)
|
|
41
|
+
- [Run the local development stack](dev/README.md) (`make rebuild` for the first start, `make up` thereafter)
|
|
42
42
|
- [Tutorial: see your first trace](docs/tutorials/first-trace.md)
|
|
43
43
|
- [How-to: configure tp_trace](docs/how-to/configure-tp-trace.md)
|
|
44
44
|
- [How-to: investigate a slow or failing request](docs/how-to/investigate-a-request.md)
|
|
@@ -6,7 +6,7 @@ The SDK exports OTLP traces to the team's telemetry endpoint. Tempo stores the t
|
|
|
6
6
|
|
|
7
7
|
## Start here
|
|
8
8
|
|
|
9
|
-
- [Run the local development stack](dev/README.md)
|
|
9
|
+
- [Run the local development stack](dev/README.md) (`make rebuild` for the first start, `make up` thereafter)
|
|
10
10
|
- [Tutorial: see your first trace](docs/tutorials/first-trace.md)
|
|
11
11
|
- [How-to: configure tp_trace](docs/how-to/configure-tp-trace.md)
|
|
12
12
|
- [How-to: investigate a slow or failing request](docs/how-to/investigate-a-request.md)
|
|
@@ -202,7 +202,13 @@ def init(
|
|
|
202
202
|
|
|
203
203
|
# Register RED metrics aggregator: computes 100% RED metrics for all dependencies
|
|
204
204
|
try:
|
|
205
|
-
provider.add_span_processor(
|
|
205
|
+
provider.add_span_processor(
|
|
206
|
+
RedSpanProcessor(
|
|
207
|
+
cardinality_limit_per_service=config.operation_cardinality_limit,
|
|
208
|
+
route_limit=config.route_cardinality_limit,
|
|
209
|
+
service_limit=config.service_cardinality_limit,
|
|
210
|
+
)
|
|
211
|
+
)
|
|
206
212
|
except Exception as exc:
|
|
207
213
|
logger.debug("tp_trace: Failed to register RedSpanProcessor: %s", exc)
|
|
208
214
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Compatibility shims for OpenTelemetry internals."""
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Compatibility layer for mutating span attributes across OTel SDK versions.
|
|
2
|
+
|
|
3
|
+
OpenTelemetry Python SDK exposes no public API to mutate attributes on an ended
|
|
4
|
+
ReadableSpan. tp_trace only does this as a last resort for third-party
|
|
5
|
+
instrumentors that add attributes after our control points. All access to
|
|
6
|
+
private internals goes through this module with version checks and a kill-switch.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from typing import Any, Dict, MutableMapping, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("tp_trace.compat.attributes")
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from importlib import metadata
|
|
17
|
+
|
|
18
|
+
_OTEL_SDK_VERSION = metadata.version("opentelemetry-sdk")
|
|
19
|
+
except Exception:
|
|
20
|
+
_OTEL_SDK_VERSION = "unknown"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _parse_version(v: str) -> Optional[Tuple[int, int, int]]:
|
|
24
|
+
if not v or v == "unknown":
|
|
25
|
+
return None
|
|
26
|
+
# Strip common suffixes like b0, rc1
|
|
27
|
+
try:
|
|
28
|
+
# Take only digits and dots
|
|
29
|
+
parts = []
|
|
30
|
+
for token in v.replace("-", ".").replace("_", ".").split("."):
|
|
31
|
+
# Extract leading digits
|
|
32
|
+
num = ""
|
|
33
|
+
for ch in token:
|
|
34
|
+
if ch.isdigit():
|
|
35
|
+
num += ch
|
|
36
|
+
else:
|
|
37
|
+
break
|
|
38
|
+
if num:
|
|
39
|
+
parts.append(int(num))
|
|
40
|
+
if len(parts) >= 3:
|
|
41
|
+
break
|
|
42
|
+
if len(parts) >= 2:
|
|
43
|
+
while len(parts) < 3:
|
|
44
|
+
parts.append(0)
|
|
45
|
+
return (parts[0], parts[1], parts[2])
|
|
46
|
+
except Exception:
|
|
47
|
+
pass
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_OTEL_SDK_VER_TUPLE = _parse_version(_OTEL_SDK_VERSION)
|
|
52
|
+
|
|
53
|
+
_ATTR_MUTATION_DISABLED = os.environ.get("TP_TRACE_DISABLE_ATTR_MUTATION", "").strip().lower() in (
|
|
54
|
+
"1",
|
|
55
|
+
"true",
|
|
56
|
+
"yes",
|
|
57
|
+
"on",
|
|
58
|
+
)
|
|
59
|
+
_WARNED_DISABLED = False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _sdk_version_ok() -> bool:
|
|
63
|
+
global _WARNED_DISABLED
|
|
64
|
+
if _ATTR_MUTATION_DISABLED:
|
|
65
|
+
if not _WARNED_DISABLED:
|
|
66
|
+
logger.debug("tp_trace: attribute mutation disabled via TP_TRACE_DISABLE_ATTR_MUTATION")
|
|
67
|
+
_WARNED_DISABLED = True
|
|
68
|
+
return False
|
|
69
|
+
if _OTEL_SDK_VER_TUPLE is None:
|
|
70
|
+
if not _WARNED_DISABLED:
|
|
71
|
+
logger.debug("tp_trace: could not parse opentelemetry-sdk version; disabling ended-span attribute mutation")
|
|
72
|
+
_WARNED_DISABLED = True
|
|
73
|
+
return False
|
|
74
|
+
try:
|
|
75
|
+
major, minor, _ = _OTEL_SDK_VER_TUPLE
|
|
76
|
+
if (major, minor) < (1, 20):
|
|
77
|
+
if not _WARNED_DISABLED:
|
|
78
|
+
logger.debug("tp_trace: opentelemetry-sdk %s too old for ended-span mutation", _OTEL_SDK_VERSION)
|
|
79
|
+
_WARNED_DISABLED = True
|
|
80
|
+
return False
|
|
81
|
+
# Don't fail closed on newer versions - rely on structure checks
|
|
82
|
+
return True
|
|
83
|
+
except Exception:
|
|
84
|
+
if not _WARNED_DISABLED:
|
|
85
|
+
logger.debug("tp_trace: attribute mutation disabled due to version check failure")
|
|
86
|
+
_WARNED_DISABLED = True
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _is_recording_span(span: Any) -> bool:
|
|
91
|
+
try:
|
|
92
|
+
if hasattr(span, "is_recording") and span.is_recording():
|
|
93
|
+
return True
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _get_attrs_obj(span: Any) -> Any:
|
|
100
|
+
attrs = getattr(span, "_attributes", None)
|
|
101
|
+
if attrs is not None:
|
|
102
|
+
return attrs
|
|
103
|
+
attrs = getattr(span, "attributes", None)
|
|
104
|
+
return attrs
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _find_mutable_dict(attrs_obj: Any) -> Optional[MutableMapping]:
|
|
108
|
+
if attrs_obj is None:
|
|
109
|
+
return None
|
|
110
|
+
if isinstance(attrs_obj, dict):
|
|
111
|
+
return attrs_obj
|
|
112
|
+
# Prefer known internal names for BoundedAttributes
|
|
113
|
+
for name in ("_dict",):
|
|
114
|
+
try:
|
|
115
|
+
cand = getattr(attrs_obj, name, None)
|
|
116
|
+
if isinstance(cand, dict):
|
|
117
|
+
return cand
|
|
118
|
+
if isinstance(cand, MutableMapping):
|
|
119
|
+
return cand
|
|
120
|
+
except Exception:
|
|
121
|
+
continue
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def get_mutable_attributes(span: Any) -> Optional[MutableMapping]:
|
|
126
|
+
"""Get a mutable attribute mapping if possible."""
|
|
127
|
+
if not _sdk_version_ok():
|
|
128
|
+
return None
|
|
129
|
+
attrs_obj = _get_attrs_obj(span)
|
|
130
|
+
target = _find_mutable_dict(attrs_obj)
|
|
131
|
+
if target is not None:
|
|
132
|
+
return target
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def set_attribute_safe(span: Any, key: str, value: Any) -> bool:
|
|
137
|
+
# For recording spans, use public API. For ended spans, write via compat first.
|
|
138
|
+
try:
|
|
139
|
+
if _is_recording_span(span):
|
|
140
|
+
try:
|
|
141
|
+
if hasattr(span, "set_attribute"):
|
|
142
|
+
span.set_attribute(key, value)
|
|
143
|
+
return True
|
|
144
|
+
except Exception:
|
|
145
|
+
pass
|
|
146
|
+
return False
|
|
147
|
+
except Exception:
|
|
148
|
+
pass
|
|
149
|
+
target = get_mutable_attributes(span)
|
|
150
|
+
if target is not None:
|
|
151
|
+
try:
|
|
152
|
+
target[key] = value
|
|
153
|
+
return True
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def delete_attribute_safe(span: Any, key: str) -> bool:
|
|
160
|
+
target = get_mutable_attributes(span)
|
|
161
|
+
if target is None:
|
|
162
|
+
return False
|
|
163
|
+
try:
|
|
164
|
+
if key in target:
|
|
165
|
+
del target[key]
|
|
166
|
+
return True
|
|
167
|
+
except Exception:
|
|
168
|
+
pass
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def get_attributes_view(span: Any) -> Dict[str, Any]:
|
|
173
|
+
try:
|
|
174
|
+
attrs = getattr(span, "attributes", None)
|
|
175
|
+
if attrs is not None:
|
|
176
|
+
try:
|
|
177
|
+
return dict(attrs)
|
|
178
|
+
except Exception:
|
|
179
|
+
pass
|
|
180
|
+
except Exception:
|
|
181
|
+
pass
|
|
182
|
+
try:
|
|
183
|
+
attrs_obj = _get_attrs_obj(span)
|
|
184
|
+
if attrs_obj is not None:
|
|
185
|
+
try:
|
|
186
|
+
return dict(attrs_obj)
|
|
187
|
+
except Exception:
|
|
188
|
+
pass
|
|
189
|
+
except Exception:
|
|
190
|
+
pass
|
|
191
|
+
return {}
|
|
@@ -10,6 +10,10 @@ from tp_trace.version import __version__
|
|
|
10
10
|
|
|
11
11
|
logger = logging.getLogger("tp_trace.config")
|
|
12
12
|
|
|
13
|
+
DEFAULT_OPERATION_CARDINALITY_LIMIT = 1000
|
|
14
|
+
DEFAULT_ROUTE_CARDINALITY_LIMIT = 5000
|
|
15
|
+
DEFAULT_SERVICE_CARDINALITY_LIMIT = 16
|
|
16
|
+
|
|
13
17
|
# Deprecated inputs that name the *project* after a *service*. Warned once each so
|
|
14
18
|
# operators learn the canonical name without log spam.
|
|
15
19
|
_warned_project_aliases: set = set()
|
|
@@ -262,6 +266,9 @@ class SDKConfig:
|
|
|
262
266
|
query_mode: str = "expose"
|
|
263
267
|
n_plus_one_duplicate_threshold: int = 15
|
|
264
268
|
n_plus_one_max_fingerprints: int = 256
|
|
269
|
+
operation_cardinality_limit: int = DEFAULT_OPERATION_CARDINALITY_LIMIT
|
|
270
|
+
route_cardinality_limit: int = DEFAULT_ROUTE_CARDINALITY_LIMIT
|
|
271
|
+
service_cardinality_limit: int = DEFAULT_SERVICE_CARDINALITY_LIMIT
|
|
265
272
|
|
|
266
273
|
@classmethod
|
|
267
274
|
def from_env_and_kwargs(
|
|
@@ -295,6 +302,9 @@ class SDKConfig:
|
|
|
295
302
|
query_mode: Optional[str] = None,
|
|
296
303
|
n_plus_one_duplicate_threshold: Optional[int] = None,
|
|
297
304
|
n_plus_one_max_fingerprints: Optional[int] = None,
|
|
305
|
+
operation_cardinality_limit: Optional[int] = None,
|
|
306
|
+
route_cardinality_limit: Optional[int] = None,
|
|
307
|
+
service_cardinality_limit: Optional[int] = None,
|
|
298
308
|
**extra: Any,
|
|
299
309
|
) -> "SDKConfig":
|
|
300
310
|
"""Build SDKConfig by prioritizing explicit kwargs over environment variables."""
|
|
@@ -420,6 +430,24 @@ class SDKConfig:
|
|
|
420
430
|
except Exception:
|
|
421
431
|
resolved_metric_cardinality_limit = 5000
|
|
422
432
|
|
|
433
|
+
resolved_operation_cardinality_limit = _parse_positive_int_default(
|
|
434
|
+
operation_cardinality_limit if operation_cardinality_limit is not None else os.getenv("TP_TRACE_OPERATION_CARDINALITY_LIMIT"),
|
|
435
|
+
default=DEFAULT_OPERATION_CARDINALITY_LIMIT,
|
|
436
|
+
field_name="operation_cardinality_limit",
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
resolved_route_cardinality_limit = _parse_positive_int_default(
|
|
440
|
+
route_cardinality_limit if route_cardinality_limit is not None else os.getenv("TP_TRACE_ROUTE_CARDINALITY_LIMIT"),
|
|
441
|
+
default=DEFAULT_ROUTE_CARDINALITY_LIMIT,
|
|
442
|
+
field_name="route_cardinality_limit",
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
resolved_service_cardinality_limit = _parse_positive_int_default(
|
|
446
|
+
service_cardinality_limit if service_cardinality_limit is not None else os.getenv("TP_TRACE_SERVICE_CARDINALITY_LIMIT"),
|
|
447
|
+
default=DEFAULT_SERVICE_CARDINALITY_LIMIT,
|
|
448
|
+
field_name="service_cardinality_limit",
|
|
449
|
+
)
|
|
450
|
+
|
|
423
451
|
resolved_service_instance_id = (
|
|
424
452
|
service_instance_id
|
|
425
453
|
or extra.get("service_instance_id")
|
|
@@ -752,6 +780,9 @@ class SDKConfig:
|
|
|
752
780
|
metrics_enabled=resolved_metrics_enabled,
|
|
753
781
|
metrics_export_interval_millis=resolved_metrics_interval,
|
|
754
782
|
metric_cardinality_limit=resolved_metric_cardinality_limit,
|
|
783
|
+
operation_cardinality_limit=resolved_operation_cardinality_limit,
|
|
784
|
+
route_cardinality_limit=resolved_route_cardinality_limit,
|
|
785
|
+
service_cardinality_limit=resolved_service_cardinality_limit,
|
|
755
786
|
service_instance_id=resolved_service_instance_id,
|
|
756
787
|
redis_mask_endpoint=resolved_redis_mask_endpoint,
|
|
757
788
|
redis_mask_command=resolved_redis_mask_command,
|
|
@@ -9,98 +9,14 @@ import time
|
|
|
9
9
|
from typing import Any, Optional
|
|
10
10
|
from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
|
|
11
11
|
|
|
12
|
+
from tp_trace.normalize import normalize_span_attributes
|
|
13
|
+
|
|
12
14
|
logger = logging.getLogger("tp_trace.exporter")
|
|
13
15
|
|
|
14
16
|
|
|
15
17
|
def _ensure_normalized_attributes(span: Any) -> None:
|
|
16
18
|
try:
|
|
17
|
-
|
|
18
|
-
if attrs is None:
|
|
19
|
-
attrs = getattr(span, "attributes", None)
|
|
20
|
-
target_dict = getattr(attrs, "_dict", None)
|
|
21
|
-
if target_dict is None and hasattr(attrs, "__setitem__"):
|
|
22
|
-
target_dict = attrs
|
|
23
|
-
|
|
24
|
-
if target_dict is not None:
|
|
25
|
-
is_owned_celery = bool(
|
|
26
|
-
target_dict.get("tp_trace.celery.owned") or (attrs and attrs.get("tp_trace.celery.owned"))
|
|
27
|
-
)
|
|
28
|
-
if is_owned_celery:
|
|
29
|
-
for bad_key in (
|
|
30
|
-
"http.method",
|
|
31
|
-
"http.request.method",
|
|
32
|
-
"http.route",
|
|
33
|
-
"http.route_template",
|
|
34
|
-
"django.route",
|
|
35
|
-
):
|
|
36
|
-
if bad_key in target_dict:
|
|
37
|
-
try:
|
|
38
|
-
del target_dict[bad_key]
|
|
39
|
-
except Exception:
|
|
40
|
-
pass
|
|
41
|
-
if not target_dict.get("normalized.service"):
|
|
42
|
-
target_dict["normalized.service"] = "celery"
|
|
43
|
-
if not target_dict.get("normalized.operation"):
|
|
44
|
-
from opentelemetry.trace import SpanKind
|
|
45
|
-
|
|
46
|
-
action = "apply_async" if getattr(span, "kind", None) == SpanKind.PRODUCER else "run"
|
|
47
|
-
target_dict["normalized.operation"] = f"celery.{action}.unknown"
|
|
48
|
-
return
|
|
49
|
-
|
|
50
|
-
if not target_dict.get("normalized.service"):
|
|
51
|
-
svc = (
|
|
52
|
-
target_dict.get("peer.service")
|
|
53
|
-
or target_dict.get("db.system")
|
|
54
|
-
or target_dict.get("rpc.system")
|
|
55
|
-
or target_dict.get("component")
|
|
56
|
-
)
|
|
57
|
-
if svc == "postgresql":
|
|
58
|
-
svc = "postgres"
|
|
59
|
-
elif svc == "aws-api":
|
|
60
|
-
svc = "aws-s3"
|
|
61
|
-
target_dict["normalized.service"] = str(svc or "django")
|
|
62
|
-
|
|
63
|
-
svc = target_dict.get("normalized.service")
|
|
64
|
-
if not target_dict.get("normalized.operation"):
|
|
65
|
-
op = None
|
|
66
|
-
if svc == "redis" or target_dict.get("db.system") == "redis":
|
|
67
|
-
if "db.redis.pipeline_length" in target_dict:
|
|
68
|
-
op = "redis.pipeline"
|
|
69
|
-
elif target_dict.get("db.operation"):
|
|
70
|
-
op = f"redis.{str(target_dict.get('db.operation')).lower()}"
|
|
71
|
-
elif getattr(span, "name", ""):
|
|
72
|
-
raw = (
|
|
73
|
-
getattr(span, "name", "")
|
|
74
|
-
.replace("🔸", "")
|
|
75
|
-
.replace("REDIS:", "")
|
|
76
|
-
.replace("redis:", "")
|
|
77
|
-
.strip()
|
|
78
|
-
.lower()
|
|
79
|
-
)
|
|
80
|
-
op = f"redis.{raw}" if raw else "redis.command"
|
|
81
|
-
else:
|
|
82
|
-
op = "redis.command"
|
|
83
|
-
elif svc in ("postgres", "pgbouncer") or target_dict.get("db.system") in ("postgres", "postgresql"):
|
|
84
|
-
op = target_dict.get("db.statement") or "postgres.query"
|
|
85
|
-
elif svc == "django":
|
|
86
|
-
route_val = target_dict.get("http.route_template") or target_dict.get("http.route")
|
|
87
|
-
if target_dict.get("http.method") and route_val:
|
|
88
|
-
op = f"{target_dict.get('http.method')} {route_val}"
|
|
89
|
-
else:
|
|
90
|
-
op = getattr(span, "name", "")
|
|
91
|
-
elif target_dict.get("rpc.service") and target_dict.get("rpc.method"):
|
|
92
|
-
op = f"{target_dict.get('rpc.service')}.{target_dict.get('rpc.method')}"
|
|
93
|
-
elif target_dict.get("db.statement"):
|
|
94
|
-
op = target_dict.get("db.statement")
|
|
95
|
-
elif target_dict.get("http.method") and (
|
|
96
|
-
target_dict.get("http.route_template") or target_dict.get("http.route")
|
|
97
|
-
):
|
|
98
|
-
route_val = target_dict.get("http.route_template") or target_dict.get("http.route")
|
|
99
|
-
op = f"{target_dict.get('http.method')} {route_val}"
|
|
100
|
-
else:
|
|
101
|
-
op = getattr(span, "name", "")
|
|
102
|
-
if op:
|
|
103
|
-
target_dict["normalized.operation"] = str(op)
|
|
19
|
+
normalize_span_attributes(span)
|
|
104
20
|
except Exception:
|
|
105
21
|
pass
|
|
106
22
|
|
|
@@ -81,6 +81,8 @@ def make_traced_cache_op(op_name: str):
|
|
|
81
81
|
key = kwargs["key"]
|
|
82
82
|
elif "keys" in kwargs:
|
|
83
83
|
key = kwargs["keys"]
|
|
84
|
+
elif op_name == "set_many":
|
|
85
|
+
key = kwargs.get("data")
|
|
84
86
|
|
|
85
87
|
span_attrs = {
|
|
86
88
|
"django.cache.operation": op_name,
|
|
@@ -146,7 +148,9 @@ def make_traced_cache_op(op_name: str):
|
|
|
146
148
|
|
|
147
149
|
if key is not None:
|
|
148
150
|
try:
|
|
149
|
-
if isinstance(key,
|
|
151
|
+
if isinstance(key, dict):
|
|
152
|
+
span_attrs["django.cache.key"] = ", ".join(str(k) for k in key)
|
|
153
|
+
elif isinstance(key, (list, tuple, set)):
|
|
150
154
|
span_attrs["django.cache.key"] = ", ".join(str(k) for k in key)
|
|
151
155
|
else:
|
|
152
156
|
span_attrs["django.cache.key"] = str(key)
|
|
@@ -474,7 +474,11 @@ def _evaluate_repeated_queries(span, cfg, final_route, method, duration_ms):
|
|
|
474
474
|
safe_set_attribute(span, "n_plus_one.severity", severity)
|
|
475
475
|
safe_set_attribute(span, "n_plus_one.severity_reason", severity_reason)
|
|
476
476
|
safe_set_attribute(span, "n_plus_one.tracker_overflowed", stats.tracker_overflowed)
|
|
477
|
-
|
|
477
|
+
# The request-finalization hook can run more than once for a span when
|
|
478
|
+
# Django and the OTel middleware both finalize it. Keep the anomaly
|
|
479
|
+
# metric request-scoped even if evaluation is repeated.
|
|
480
|
+
already_recorded = bool(getattr(span, "_tp_trace_affected_metric_recorded", False))
|
|
481
|
+
if final_route and final_route != "__unmatched__" and not str(final_route).isdigit() and not already_recorded:
|
|
478
482
|
from tp_trace.metrics import record_repeated_query_affected_request
|
|
479
483
|
|
|
480
484
|
record_repeated_query_affected_request(
|
|
@@ -483,6 +487,10 @@ def _evaluate_repeated_queries(span, cfg, final_route, method, duration_ms):
|
|
|
483
487
|
service="django",
|
|
484
488
|
extra_attributes=get_current_tags(),
|
|
485
489
|
)
|
|
490
|
+
try:
|
|
491
|
+
setattr(span, "_tp_trace_affected_metric_recorded", True)
|
|
492
|
+
except Exception:
|
|
493
|
+
pass
|
|
486
494
|
except Exception as n_plus_one_exc:
|
|
487
495
|
logger.debug("Failed to evaluate/record repeated query anomaly: %s", n_plus_one_exc)
|
|
488
496
|
|
|
@@ -581,9 +589,26 @@ def _tp_trace_django_request_hook(span: Any, request: Any) -> None:
|
|
|
581
589
|
safe_set_attribute(span, "http.user_agent", ua)
|
|
582
590
|
|
|
583
591
|
if query_mode == "strip" or not url_query:
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
592
|
+
try:
|
|
593
|
+
from tp_trace.compat.attributes import get_mutable_attributes
|
|
594
|
+
|
|
595
|
+
target = get_mutable_attributes(span)
|
|
596
|
+
if target is not None:
|
|
597
|
+
try:
|
|
598
|
+
target.pop("url.query", None)
|
|
599
|
+
except Exception:
|
|
600
|
+
try:
|
|
601
|
+
del target["url.query"]
|
|
602
|
+
except Exception:
|
|
603
|
+
pass
|
|
604
|
+
elif hasattr(span, "is_recording") and span.is_recording():
|
|
605
|
+
if hasattr(span, "set_attribute"):
|
|
606
|
+
try:
|
|
607
|
+
span.set_attribute("url.query", "")
|
|
608
|
+
except Exception:
|
|
609
|
+
pass
|
|
610
|
+
except Exception:
|
|
611
|
+
pass
|
|
587
612
|
else:
|
|
588
613
|
safe_set_attribute(span, "url.query", url_query)
|
|
589
614
|
|
|
@@ -630,6 +655,12 @@ def _tp_trace_django_request_hook(span: Any, request: Any) -> None:
|
|
|
630
655
|
logger.debug("Error in django request hook", exc_info=True)
|
|
631
656
|
|
|
632
657
|
|
|
658
|
+
def _request_method(request: Any) -> str:
|
|
659
|
+
"""Normalize telemetry metadata without changing the request object."""
|
|
660
|
+
method = getattr(request, "method", "GET")
|
|
661
|
+
return method.upper() if isinstance(method, str) and method else "GET"
|
|
662
|
+
|
|
663
|
+
|
|
633
664
|
def _tp_trace_django_response_hook(span: Any, request: Any, response: Any) -> None:
|
|
634
665
|
"""Official OTel DjangoInstrumentor response hook.
|
|
635
666
|
|
|
@@ -637,25 +668,27 @@ def _tp_trace_django_response_hook(span: Any, request: Any, response: Any) -> No
|
|
|
637
668
|
(X-Trace-ID, X-Span-ID, traceparent), user attribution, repeated query
|
|
638
669
|
anomaly detection, request metrics, and resets context tokens.
|
|
639
670
|
"""
|
|
640
|
-
|
|
641
|
-
tracker_token = ctx[0] if ctx else None
|
|
642
|
-
route_token = ctx[1] if ctx else None
|
|
643
|
-
view_token = ctx[2] if ctx else None
|
|
644
|
-
start_time = ctx[3] if ctx else None
|
|
645
|
-
preresolved_route = ctx[4] if ctx else None
|
|
646
|
-
|
|
671
|
+
tracker_token = route_token = view_token = None
|
|
647
672
|
try:
|
|
673
|
+
ctx = getattr(request, "_tp_request_context", None)
|
|
674
|
+
tracker_token = ctx[0] if ctx else None
|
|
675
|
+
route_token = ctx[1] if ctx else None
|
|
676
|
+
view_token = ctx[2] if ctx else None
|
|
677
|
+
start_time = ctx[3] if ctx else None
|
|
678
|
+
preresolved_route = ctx[4] if ctx else None
|
|
679
|
+
|
|
648
680
|
if span is None:
|
|
649
681
|
return
|
|
650
682
|
|
|
651
683
|
cfg = attempt(_get_config, default=None, _label="request_config")
|
|
652
|
-
method =
|
|
684
|
+
method = attempt(_request_method, request, default="GET", _label="request.method")
|
|
653
685
|
path = getattr(request, "path", "/")
|
|
654
686
|
|
|
655
687
|
duration_ms = ((time.monotonic() - start_time) * 1000.0) if start_time else 0.0
|
|
656
688
|
safe_set_attribute(span, "http.request.duration_ms", duration_ms)
|
|
657
689
|
|
|
658
690
|
status_code = attempt(getattr, response, "status_code", default=200, _label="response.status_code")
|
|
691
|
+
status_code = attempt(int, status_code, default=200, _label="response.status_code.int")
|
|
659
692
|
error = status_code >= 500
|
|
660
693
|
|
|
661
694
|
norm_route = attempt(_normalize_route, request, path, default="__unmatched__", _label="normalize_route")
|
|
@@ -697,9 +730,26 @@ def _tp_trace_django_response_hook(span: Any, request: Any, response: Any) -> No
|
|
|
697
730
|
safe_set_attribute(span, "url.path", url_path)
|
|
698
731
|
safe_set_attribute(span, "http.target", target_url)
|
|
699
732
|
if query_mode == "strip" or not url_query:
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
733
|
+
try:
|
|
734
|
+
from tp_trace.compat.attributes import get_mutable_attributes
|
|
735
|
+
|
|
736
|
+
target = get_mutable_attributes(span)
|
|
737
|
+
if target is not None:
|
|
738
|
+
try:
|
|
739
|
+
target.pop("url.query", None)
|
|
740
|
+
except Exception:
|
|
741
|
+
try:
|
|
742
|
+
del target["url.query"]
|
|
743
|
+
except Exception:
|
|
744
|
+
pass
|
|
745
|
+
elif hasattr(span, "is_recording") and span.is_recording():
|
|
746
|
+
if hasattr(span, "set_attribute"):
|
|
747
|
+
try:
|
|
748
|
+
span.set_attribute("url.query", "")
|
|
749
|
+
except Exception:
|
|
750
|
+
pass
|
|
751
|
+
except Exception:
|
|
752
|
+
pass
|
|
703
753
|
else:
|
|
704
754
|
safe_set_attribute(span, "url.query", url_query)
|
|
705
755
|
|
|
@@ -776,7 +826,16 @@ def _tp_trace_django_response_hook(span: Any, request: Any, response: Any) -> No
|
|
|
776
826
|
|
|
777
827
|
attempt(_set_headers, _label="trace_headers")
|
|
778
828
|
|
|
779
|
-
|
|
829
|
+
# OTel/Django integrations may invoke the response hook twice for one
|
|
830
|
+
# request. Evaluate and emit request-scoped anomaly metrics once.
|
|
831
|
+
if not getattr(request, "_tp_trace_n_plus_one_evaluated", False):
|
|
832
|
+
try:
|
|
833
|
+
setattr(request, "_tp_trace_n_plus_one_evaluated", True)
|
|
834
|
+
except Exception:
|
|
835
|
+
pass
|
|
836
|
+
_evaluate_repeated_queries(span, cfg, final_route, method, duration_ms)
|
|
837
|
+
except Exception:
|
|
838
|
+
logger.debug("Error in django response hook", exc_info=True)
|
|
780
839
|
finally:
|
|
781
840
|
if span is not None:
|
|
782
841
|
try:
|
|
@@ -786,14 +845,13 @@ def _tp_trace_django_response_hook(span: Any, request: Any, response: Any) -> No
|
|
|
786
845
|
except Exception:
|
|
787
846
|
pass
|
|
788
847
|
if tracker_token is not None:
|
|
789
|
-
reset_request_db_tracker
|
|
848
|
+
attempt(reset_request_db_tracker, tracker_token, _label="reset_db_tracker")
|
|
790
849
|
if view_token is not None:
|
|
791
|
-
reset_request_view
|
|
850
|
+
attempt(reset_request_view, view_token, _label="reset_view")
|
|
792
851
|
if route_token is not None:
|
|
793
|
-
reset_request_route
|
|
794
|
-
reset_request_tags
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
pass
|
|
852
|
+
attempt(reset_request_route, route_token, _label="reset_route")
|
|
853
|
+
attempt(reset_request_tags, _label="reset_tags")
|
|
854
|
+
try:
|
|
855
|
+
delattr(request, "_tp_request_context")
|
|
856
|
+
except Exception:
|
|
857
|
+
pass
|
|
@@ -14,7 +14,7 @@ from tp_trace.safety import UNTRACED, attempt, safe_enrich, safe_set_attribute,
|
|
|
14
14
|
from tp_trace.tracing import traced_span
|
|
15
15
|
from tp_trace.config_access import get_config as _get_config, set_config as set_config
|
|
16
16
|
|
|
17
|
-
from .request import _format_view_from_match, _normalize_route, _resolve_view_name
|
|
17
|
+
from .request import _format_view_from_match, _normalize_route, _request_method, _resolve_view_name
|
|
18
18
|
|
|
19
19
|
_in_view_dispatch: ContextVar[bool] = ContextVar("in_view_dispatch", default=False)
|
|
20
20
|
|
|
@@ -64,12 +64,12 @@ def traced_get_response(wrapped: Callable, instance: Any, args: Any, kwargs: Any
|
|
|
64
64
|
tracer_name="tp_trace.django",
|
|
65
65
|
) as span:
|
|
66
66
|
_apply_tags(span)
|
|
67
|
-
method = attempt(
|
|
67
|
+
method = attempt(_request_method, request, default="GET", _label="request.method")
|
|
68
68
|
pre_route = attempt(_normalize_route, request, initial_route, default=initial_route, _label="normalize_route")
|
|
69
69
|
route_token = None
|
|
70
70
|
if pre_route and pre_route != "__unmatched__":
|
|
71
71
|
route_token = set_request_route(pre_route, method)
|
|
72
|
-
match = getattr
|
|
72
|
+
match = attempt(getattr, request, "resolver_match", default=None, _label="resolver_match")
|
|
73
73
|
resolved_view = (
|
|
74
74
|
attempt(_format_view_from_match, match, method, default=None, _label="view_format") if match else None
|
|
75
75
|
)
|
|
@@ -165,7 +165,7 @@ def _build_dispatch_plan(instance: Any, wrapped: Callable, args: Any, kwargs: An
|
|
|
165
165
|
)
|
|
166
166
|
|
|
167
167
|
request = args[0] if args else kwargs.get("request")
|
|
168
|
-
method = attempt(
|
|
168
|
+
method = attempt(_request_method, request, default="GET", _label="request.method")
|
|
169
169
|
method = method.lower() if isinstance(method, str) else "get"
|
|
170
170
|
|
|
171
171
|
dispatch_cls = attempt(
|
|
@@ -249,7 +249,7 @@ def traced_view_dispatch(wrapped: Callable, instance: Any, args: Any, kwargs: An
|
|
|
249
249
|
_label="normalize_route",
|
|
250
250
|
)
|
|
251
251
|
if norm_route and norm_route != "__unmatched__":
|
|
252
|
-
req_method = attempt(
|
|
252
|
+
req_method = attempt(_request_method, request, default="GET", _label="request.method")
|
|
253
253
|
route_token = set_request_route(norm_route, req_method)
|
|
254
254
|
safe_set_attribute(dispatch_span, "http.route", norm_route)
|
|
255
255
|
|