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.
Files changed (77) hide show
  1. {tp_trace-0.2.0 → tp_trace-0.3.0}/PKG-INFO +3 -3
  2. {tp_trace-0.2.0 → tp_trace-0.3.0}/README.md +1 -1
  3. {tp_trace-0.2.0 → tp_trace-0.3.0}/pyproject.toml +1 -1
  4. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/__init__.py +7 -1
  5. tp_trace-0.3.0/src/tp_trace/compat/__init__.py +1 -0
  6. tp_trace-0.3.0/src/tp_trace/compat/attributes.py +191 -0
  7. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/config.py +31 -0
  8. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/exporter.py +3 -87
  9. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/django/cache.py +5 -1
  10. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/django/request.py +83 -25
  11. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/django/view.py +5 -5
  12. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/postgres/cursor.py +5 -3
  13. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/metrics.py +324 -9
  14. tp_trace-0.3.0/src/tp_trace/normalize.py +107 -0
  15. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/red.py +71 -19
  16. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/route_context.py +2 -97
  17. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/sanitize.py +126 -23
  18. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/tracing.py +8 -2
  19. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/version.py +1 -1
  20. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace.egg-info/PKG-INFO +3 -3
  21. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace.egg-info/SOURCES.txt +7 -0
  22. tp_trace-0.3.0/tests/test_cardinality_diagnostics.py +386 -0
  23. tp_trace-0.3.0/tests/test_django_safety_regressions.py +106 -0
  24. {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_init.py +1 -1
  25. {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_metrics.py +48 -0
  26. {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_n_plus_one.py +52 -3
  27. {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_n_plus_one_dashboard_contract.py +39 -2
  28. {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_postgres.py +18 -0
  29. {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_red.py +70 -7
  30. {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_red_dashboard_contract.py +203 -2
  31. {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_safety.py +62 -1
  32. {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_sample_telemetry.py +1 -1
  33. tp_trace-0.3.0/tests/test_sanitization_regressions.py +164 -0
  34. tp_trace-0.3.0/tests/test_shared_config.py +57 -0
  35. {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_traffic_generator.py +1 -1
  36. {tp_trace-0.2.0 → tp_trace-0.3.0}/setup.cfg +0 -0
  37. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/config_access.py +0 -0
  38. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/__init__.py +0 -0
  39. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/base.py +0 -0
  40. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/boto/__init__.py +0 -0
  41. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/boto/integration.py +0 -0
  42. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/celery/__init__.py +0 -0
  43. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/celery/context.py +0 -0
  44. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/celery/instrumentor.py +0 -0
  45. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/celery/integration.py +0 -0
  46. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/django/__init__.py +0 -0
  47. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/django/auth.py +0 -0
  48. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/django/compat.py +0 -0
  49. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/django/integration.py +0 -0
  50. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/django/middleware.py +0 -0
  51. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/django/template.py +0 -0
  52. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/manager.py +0 -0
  53. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/postgres/__init__.py +0 -0
  54. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/postgres/integration.py +0 -0
  55. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/redis/__init__.py +0 -0
  56. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/redis/integration.py +0 -0
  57. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/requests/__init__.py +0 -0
  58. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/requests/client.py +0 -0
  59. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/integrations/requests/integration.py +0 -0
  60. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/propagator.py +0 -0
  61. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/safety.py +0 -0
  62. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace/sampler.py +0 -0
  63. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace.egg-info/dependency_links.txt +0 -0
  64. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace.egg-info/requires.txt +0 -0
  65. {tp_trace-0.2.0 → tp_trace-0.3.0}/src/tp_trace.egg-info/top_level.txt +0 -0
  66. {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_base_integration.py +0 -0
  67. {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_boto.py +0 -0
  68. {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_celery_integration.py +0 -0
  69. {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_celery_probe.py +0 -0
  70. {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_django.py +0 -0
  71. {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_django_advanced.py +0 -0
  72. {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_django_migration_regressions.py +0 -0
  73. {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_red_comparison.py +0 -0
  74. {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_redis.py +0 -0
  75. {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_requests.py +0 -0
  76. {tp_trace-0.2.0 → tp_trace-0.3.0}/tests/test_sampling_rules.py +0 -0
  77. {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
1
+ Metadata-Version: 2.4
2
2
  Name: tp_trace
3
- Version: 0.2.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)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tp_trace"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "tp_trace: High-performance Python Observability SDK for OpenTelemetry"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -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(RedSpanProcessor())
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
- attrs = getattr(span, "_attributes", None)
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, (list, tuple, set)):
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
- if final_route and final_route != "__unmatched__" and not str(final_route).isdigit():
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
- safe_set_attribute(span, "url.query", "")
585
- if hasattr(span, "_attributes") and hasattr(span._attributes, "pop"):
586
- span._attributes.pop("url.query", None)
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
- ctx = getattr(request, "_tp_request_context", None)
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 = getattr(request, "method", "GET").upper()
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
- safe_set_attribute(span, "url.query", "")
701
- if hasattr(span, "_attributes") and hasattr(span._attributes, "pop"):
702
- span._attributes.pop("url.query", None)
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
- _evaluate_repeated_queries(span, cfg, final_route, method, duration_ms)
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(tracker_token)
848
+ attempt(reset_request_db_tracker, tracker_token, _label="reset_db_tracker")
790
849
  if view_token is not None:
791
- reset_request_view(view_token)
850
+ attempt(reset_request_view, view_token, _label="reset_view")
792
851
  if route_token is not None:
793
- reset_request_route(route_token)
794
- reset_request_tags()
795
- if hasattr(request, "_tp_request_context"):
796
- try:
797
- delattr(request, "_tp_request_context")
798
- except Exception:
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(getattr, request, "method", default="GET", _label="request.method")
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(request, "resolver_match", None)
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(getattr, request, "method", default="GET", _label="request.method")
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(getattr, request, "method", default="GET", _label="request.method")
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