tp-trace 0.1.6__tar.gz → 0.2.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 (76) hide show
  1. {tp_trace-0.1.6 → tp_trace-0.2.0}/PKG-INFO +5 -2
  2. {tp_trace-0.1.6 → tp_trace-0.2.0}/pyproject.toml +4 -1
  3. {tp_trace-0.1.6 → tp_trace-0.2.0}/src/tp_trace/__init__.py +39 -6
  4. {tp_trace-0.1.6 → tp_trace-0.2.0}/src/tp_trace/config.py +94 -0
  5. {tp_trace-0.1.6 → tp_trace-0.2.0}/src/tp_trace/integrations/base.py +22 -1
  6. {tp_trace-0.1.6 → tp_trace-0.2.0}/src/tp_trace/integrations/celery/context.py +1 -1
  7. {tp_trace-0.1.6 → tp_trace-0.2.0}/src/tp_trace/integrations/celery/instrumentor.py +89 -13
  8. tp_trace-0.2.0/src/tp_trace/integrations/django/__init__.py +15 -0
  9. {tp_trace-0.1.6 → tp_trace-0.2.0}/src/tp_trace/integrations/django/cache.py +7 -4
  10. {tp_trace-0.1.6 → tp_trace-0.2.0}/src/tp_trace/integrations/django/integration.py +55 -7
  11. tp_trace-0.2.0/src/tp_trace/integrations/django/middleware.py +422 -0
  12. tp_trace-0.2.0/src/tp_trace/integrations/django/request.py +799 -0
  13. {tp_trace-0.1.6 → tp_trace-0.2.0}/src/tp_trace/integrations/django/view.py +94 -71
  14. {tp_trace-0.1.6 → tp_trace-0.2.0}/src/tp_trace/integrations/manager.py +80 -68
  15. {tp_trace-0.1.6 → tp_trace-0.2.0}/src/tp_trace/integrations/postgres/cursor.py +27 -9
  16. tp_trace-0.2.0/src/tp_trace/metrics.py +606 -0
  17. tp_trace-0.2.0/src/tp_trace/propagator.py +63 -0
  18. tp_trace-0.2.0/src/tp_trace/red.py +265 -0
  19. {tp_trace-0.1.6 → tp_trace-0.2.0}/src/tp_trace/route_context.py +152 -1
  20. {tp_trace-0.1.6 → tp_trace-0.2.0}/src/tp_trace/sampler.py +91 -10
  21. {tp_trace-0.1.6 → tp_trace-0.2.0}/src/tp_trace/sanitize.py +12 -1
  22. {tp_trace-0.1.6 → tp_trace-0.2.0}/src/tp_trace/tracing.py +4 -1
  23. {tp_trace-0.1.6 → tp_trace-0.2.0}/src/tp_trace/version.py +1 -1
  24. {tp_trace-0.1.6 → tp_trace-0.2.0}/src/tp_trace.egg-info/PKG-INFO +5 -2
  25. {tp_trace-0.1.6 → tp_trace-0.2.0}/src/tp_trace.egg-info/SOURCES.txt +11 -2
  26. {tp_trace-0.1.6 → tp_trace-0.2.0}/src/tp_trace.egg-info/requires.txt +5 -0
  27. {tp_trace-0.1.6 → tp_trace-0.2.0}/tests/test_celery_integration.py +207 -10
  28. {tp_trace-0.1.6 → tp_trace-0.2.0}/tests/test_django.py +101 -3
  29. {tp_trace-0.1.6 → tp_trace-0.2.0}/tests/test_django_advanced.py +116 -6
  30. tp_trace-0.2.0/tests/test_django_migration_regressions.py +352 -0
  31. {tp_trace-0.1.6 → tp_trace-0.2.0}/tests/test_init.py +48 -8
  32. tp_trace-0.2.0/tests/test_metrics.py +966 -0
  33. tp_trace-0.2.0/tests/test_n_plus_one.py +612 -0
  34. tp_trace-0.2.0/tests/test_n_plus_one_dashboard_contract.py +173 -0
  35. {tp_trace-0.1.6 → tp_trace-0.2.0}/tests/test_postgres.py +36 -11
  36. tp_trace-0.2.0/tests/test_red.py +817 -0
  37. tp_trace-0.2.0/tests/test_red_comparison.py +113 -0
  38. tp_trace-0.2.0/tests/test_red_dashboard_contract.py +345 -0
  39. {tp_trace-0.1.6 → tp_trace-0.2.0}/tests/test_safety.py +440 -67
  40. {tp_trace-0.1.6 → tp_trace-0.2.0}/tests/test_sampling_rules.py +78 -9
  41. tp_trace-0.2.0/tests/test_smoke_probe.py +82 -0
  42. tp_trace-0.2.0/tests/test_traffic_generator.py +105 -0
  43. tp_trace-0.1.6/src/tp_trace/integrations/django/__init__.py +0 -17
  44. tp_trace-0.1.6/src/tp_trace/integrations/django/middleware.py +0 -228
  45. tp_trace-0.1.6/src/tp_trace/integrations/django/middleware_init.py +0 -91
  46. tp_trace-0.1.6/src/tp_trace/integrations/django/request.py +0 -667
  47. tp_trace-0.1.6/src/tp_trace/metrics.py +0 -258
  48. tp_trace-0.1.6/tests/test_metrics.py +0 -372
  49. {tp_trace-0.1.6 → tp_trace-0.2.0}/README.md +0 -0
  50. {tp_trace-0.1.6 → tp_trace-0.2.0}/setup.cfg +0 -0
  51. {tp_trace-0.1.6 → tp_trace-0.2.0}/src/tp_trace/config_access.py +0 -0
  52. {tp_trace-0.1.6 → tp_trace-0.2.0}/src/tp_trace/exporter.py +0 -0
  53. {tp_trace-0.1.6 → tp_trace-0.2.0}/src/tp_trace/integrations/__init__.py +0 -0
  54. {tp_trace-0.1.6 → tp_trace-0.2.0}/src/tp_trace/integrations/boto/__init__.py +0 -0
  55. {tp_trace-0.1.6 → tp_trace-0.2.0}/src/tp_trace/integrations/boto/integration.py +0 -0
  56. {tp_trace-0.1.6 → tp_trace-0.2.0}/src/tp_trace/integrations/celery/__init__.py +0 -0
  57. {tp_trace-0.1.6 → tp_trace-0.2.0}/src/tp_trace/integrations/celery/integration.py +0 -0
  58. {tp_trace-0.1.6 → tp_trace-0.2.0}/src/tp_trace/integrations/django/auth.py +0 -0
  59. {tp_trace-0.1.6 → tp_trace-0.2.0}/src/tp_trace/integrations/django/compat.py +0 -0
  60. {tp_trace-0.1.6 → tp_trace-0.2.0}/src/tp_trace/integrations/django/template.py +0 -0
  61. {tp_trace-0.1.6 → tp_trace-0.2.0}/src/tp_trace/integrations/postgres/__init__.py +0 -0
  62. {tp_trace-0.1.6 → tp_trace-0.2.0}/src/tp_trace/integrations/postgres/integration.py +0 -0
  63. {tp_trace-0.1.6 → tp_trace-0.2.0}/src/tp_trace/integrations/redis/__init__.py +0 -0
  64. {tp_trace-0.1.6 → tp_trace-0.2.0}/src/tp_trace/integrations/redis/integration.py +0 -0
  65. {tp_trace-0.1.6 → tp_trace-0.2.0}/src/tp_trace/integrations/requests/__init__.py +0 -0
  66. {tp_trace-0.1.6 → tp_trace-0.2.0}/src/tp_trace/integrations/requests/client.py +0 -0
  67. {tp_trace-0.1.6 → tp_trace-0.2.0}/src/tp_trace/integrations/requests/integration.py +0 -0
  68. {tp_trace-0.1.6 → tp_trace-0.2.0}/src/tp_trace/safety.py +0 -0
  69. {tp_trace-0.1.6 → tp_trace-0.2.0}/src/tp_trace.egg-info/dependency_links.txt +0 -0
  70. {tp_trace-0.1.6 → tp_trace-0.2.0}/src/tp_trace.egg-info/top_level.txt +0 -0
  71. {tp_trace-0.1.6 → tp_trace-0.2.0}/tests/test_base_integration.py +0 -0
  72. {tp_trace-0.1.6 → tp_trace-0.2.0}/tests/test_boto.py +0 -0
  73. {tp_trace-0.1.6 → tp_trace-0.2.0}/tests/test_celery_probe.py +0 -0
  74. {tp_trace-0.1.6 → tp_trace-0.2.0}/tests/test_redis.py +0 -0
  75. {tp_trace-0.1.6 → tp_trace-0.2.0}/tests/test_requests.py +0 -0
  76. {tp_trace-0.1.6 → tp_trace-0.2.0}/tests/test_sample_telemetry.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.1
2
2
  Name: tp_trace
3
- Version: 0.1.6
3
+ Version: 0.2.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
@@ -12,9 +12,12 @@ Requires-Dist: opentelemetry-instrumentation-psycopg2>=0.44b0
12
12
  Requires-Dist: opentelemetry-instrumentation-redis>=0.44b0
13
13
  Requires-Dist: opentelemetry-instrumentation-botocore>=0.44b0
14
14
  Requires-Dist: opentelemetry-instrumentation-celery>=0.54b1
15
+ Requires-Dist: opentelemetry-instrumentation-django>=0.44b0
15
16
  Requires-Dist: wrapt>=1.14.0
17
+ Requires-Dist: urllib3<2,>=1.26.0; python_version < "3.9"
16
18
  Provides-Extra: django
17
19
  Requires-Dist: django>=4.2; extra == "django"
20
+ Requires-Dist: opentelemetry-instrumentation-django>=0.44b0; extra == "django"
18
21
  Provides-Extra: dev
19
22
  Requires-Dist: pytest>=7.0.0; extra == "dev"
20
23
  Requires-Dist: botocore>=1.37.38; extra == "dev"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tp_trace"
7
- version = "0.1.6"
7
+ version = "0.2.0"
8
8
  description = "tp_trace: High-performance Python Observability SDK for OpenTelemetry"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -17,12 +17,15 @@ dependencies = [
17
17
  "opentelemetry-instrumentation-redis>=0.44b0",
18
18
  "opentelemetry-instrumentation-botocore>=0.44b0",
19
19
  "opentelemetry-instrumentation-celery>=0.54b1",
20
+ "opentelemetry-instrumentation-django>=0.44b0",
20
21
  "wrapt>=1.14.0",
22
+ "urllib3>=1.26.0,<2; python_version < '3.9'",
21
23
  ]
22
24
 
23
25
  [project.optional-dependencies]
24
26
  django = [
25
27
  "django>=4.2",
28
+ "opentelemetry-instrumentation-django>=0.44b0",
26
29
  ]
27
30
  dev = [
28
31
  "pytest>=7.0.0",
@@ -18,11 +18,11 @@ from opentelemetry.sdk.trace.sampling import ALWAYS_OFF, Sampler
18
18
  from opentelemetry.trace import get_current_span, get_tracer
19
19
  from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
20
20
  from opentelemetry.baggage.propagation import W3CBaggagePropagator
21
- from opentelemetry.propagators.composite import CompositePropagator
22
21
  from opentelemetry.propagate import set_global_textmap
23
22
 
24
23
  from tp_trace.config import SDKConfig
25
24
  from tp_trace.exporter import SafeSpanExporter
25
+ from tp_trace.red import RedSpanProcessor, SampledOnlySpanProcessor
26
26
  from tp_trace.route_context import RouteEnrichingSpanProcessor
27
27
  from tp_trace.integrations import BaseIntegration, get_integration_manager
28
28
  from tp_trace.sanitize import sanitize_query_string, sanitize_sql, sanitize_url
@@ -42,6 +42,8 @@ __all__ = [
42
42
  "BaseIntegration",
43
43
  "get_integration_manager",
44
44
  "RouteEnrichingSpanProcessor",
45
+ "RedSpanProcessor",
46
+ "SampledOnlySpanProcessor",
45
47
  "SafeSpanExporter",
46
48
  ]
47
49
 
@@ -198,6 +200,12 @@ def init(
198
200
  except Exception as exc:
199
201
  logger.debug("tp_trace: Failed to register RouteEnrichingSpanProcessor: %s", exc)
200
202
 
203
+ # Register RED metrics aggregator: computes 100% RED metrics for all dependencies
204
+ try:
205
+ provider.add_span_processor(RedSpanProcessor())
206
+ except Exception as exc:
207
+ logger.debug("tp_trace: Failed to register RedSpanProcessor: %s", exc)
208
+
201
209
  # Configure Exporter and Processor if not disabled
202
210
  if not config.disabled:
203
211
  if exporter is None:
@@ -234,18 +242,21 @@ def init(
234
242
  else:
235
243
  span_processor = SimpleSpanProcessor(safe_exporter)
236
244
 
237
- provider.add_span_processor(span_processor)
245
+ # Gate exports: only spans with trace_flags.sampled=True are serialized/transmitted
246
+ provider.add_span_processor(SampledOnlySpanProcessor(span_processor))
238
247
 
239
248
  # Set as global tracer provider
240
249
  trace.set_tracer_provider(provider)
241
250
  _ACTIVE_PROVIDER = provider
242
251
  _INITIALIZED = True
243
252
 
244
- # Setup W3C Trace Context and Baggage composite propagator
253
+ # Setup W3C Trace Context and Baggage composite propagator wrapped with TpTrace filtering
254
+ from tp_trace.propagator import TpTraceFilteringPropagator
255
+
245
256
  try:
246
- set_global_textmap(CompositePropagator([TraceContextTextMapPropagator(), W3CBaggagePropagator()]))
257
+ set_global_textmap(TpTraceFilteringPropagator([TraceContextTextMapPropagator(), W3CBaggagePropagator()]))
247
258
  except Exception:
248
- set_global_textmap(TraceContextTextMapPropagator())
259
+ set_global_textmap(TpTraceFilteringPropagator([TraceContextTextMapPropagator()]))
249
260
 
250
261
  # Auto-patch installed integrations if requested
251
262
  if config.auto_patch and not config.disabled:
@@ -335,8 +346,11 @@ def _reset_for_testing() -> None:
335
346
  except Exception:
336
347
  pass
337
348
  try:
338
- from tp_trace.metrics import set_metrics_recorder
349
+ from tp_trace.metrics import get_metrics_recorder, set_metrics_recorder
339
350
 
351
+ rec = get_metrics_recorder()
352
+ if rec is not None:
353
+ rec.shutdown()
340
354
  set_metrics_recorder(None)
341
355
  except Exception:
342
356
  pass
@@ -366,9 +380,28 @@ def set_attribute(key: str, value: Any) -> None:
366
380
  set_tag(key, value)
367
381
 
368
382
 
383
+ def set_view(view_name: Optional[str]) -> Optional[Any]:
384
+ """Set the active view name on the in-flight context and propagate to child spans / Celery tasks.
385
+
386
+ Returns a token that can be passed to reset_view if manual scope management is needed.
387
+ """
388
+ from tp_trace.route_context import set_request_view
389
+
390
+ return set_request_view(view_name)
391
+
392
+
393
+ def reset_view(token: Optional[Any] = None) -> None:
394
+ """Reset the active view name on the in-flight context."""
395
+ from tp_trace.route_context import reset_request_view
396
+
397
+ reset_request_view(token)
398
+
399
+
369
400
  __all__ = [
370
401
  "set_tag",
371
402
  "set_attribute",
403
+ "set_view",
404
+ "reset_view",
372
405
  "init",
373
406
  "patch_all",
374
407
  "get_config",
@@ -76,6 +76,38 @@ def _parse_cmd_max_length(val: Any) -> int:
76
76
  return n
77
77
 
78
78
 
79
+ def _parse_positive_int_default(val: Any, default: int, min_val: int = 1, field_name: str = "") -> int:
80
+ """Non-throwing positive integer parser.
81
+
82
+ Catches type, format, and range errors, emits debug log, and returns default.
83
+ """
84
+ if val is None:
85
+ return default
86
+ try:
87
+ n = int(val)
88
+ if n >= min_val:
89
+ return n
90
+ logger.debug("tp_trace: %s=%r below minimum %d, falling back to %d", field_name, val, min_val, default)
91
+ return default
92
+ except (TypeError, ValueError) as exc:
93
+ logger.debug("tp_trace: %s=%r invalid integer (%s), falling back to %d", field_name, val, exc, default)
94
+ return default
95
+
96
+
97
+ def _parse_nonnegative_float_default(val: Any, default: float, field_name: str = "") -> float:
98
+ """Parse a finite non-negative float, falling back safely on invalid input."""
99
+ if val is None:
100
+ return default
101
+ try:
102
+ number = float(val)
103
+ if math.isfinite(number) and number >= 0:
104
+ return number
105
+ except (TypeError, ValueError):
106
+ pass
107
+ logger.debug("tp_trace: %s=%r invalid duration, falling back to %s", field_name, val, default)
108
+ return default
109
+
110
+
79
111
  def _parse_endpoint_sample_rules(val: Any) -> Dict[str, float]:
80
112
  """Parse and validate endpoint-specific sampling rules.
81
113
 
@@ -214,10 +246,13 @@ class SDKConfig:
214
246
  trusted_proxies: List[str] = field(default_factory=list)
215
247
  db_role_map: Dict[str, str] = field(default_factory=dict)
216
248
  auto_patch: bool = True
249
+ # Governs incoming HTTP request propagation (e.g. Django). Internal messaging (Celery) always extracts.
217
250
  extract_trace_context: Any = True
218
251
  metrics_endpoint: Optional[str] = None
219
252
  metrics_enabled: bool = True
220
253
  metrics_export_interval_millis: int = 60000
254
+ metric_cardinality_limit: int = 5000
255
+ service_instance_id: Optional[str] = None
221
256
  # Masking -> exposure toggles. Defaults expose infrastructure details
222
257
  # (Redis endpoint, Redis command contents, URL query strings), matching
223
258
  # dd-trace. Deployments that do NOT own the infra can restore masking.
@@ -225,6 +260,8 @@ class SDKConfig:
225
260
  redis_mask_command: bool = False
226
261
  redis_cmd_max_length: int = 1000
227
262
  query_mode: str = "expose"
263
+ n_plus_one_duplicate_threshold: int = 15
264
+ n_plus_one_max_fingerprints: int = 256
228
265
 
229
266
  @classmethod
230
267
  def from_env_and_kwargs(
@@ -250,10 +287,14 @@ class SDKConfig:
250
287
  metrics_endpoint: Optional[str] = None,
251
288
  metrics_enabled: Optional[bool] = None,
252
289
  metrics_export_interval_millis: Optional[int] = None,
290
+ metric_cardinality_limit: Optional[int] = None,
291
+ service_instance_id: Optional[str] = None,
253
292
  redis_mask_endpoint: Optional[bool] = None,
254
293
  redis_mask_command: Optional[bool] = None,
255
294
  redis_cmd_max_length: Optional[int] = None,
256
295
  query_mode: Optional[str] = None,
296
+ n_plus_one_duplicate_threshold: Optional[int] = None,
297
+ n_plus_one_max_fingerprints: Optional[int] = None,
257
298
  **extra: Any,
258
299
  ) -> "SDKConfig":
259
300
  """Build SDKConfig by prioritizing explicit kwargs over environment variables."""
@@ -368,6 +409,25 @@ class SDKConfig:
368
409
  except Exception:
369
410
  resolved_metrics_interval = 60000
370
411
 
412
+ try:
413
+ raw_cardinality_limit = (
414
+ metric_cardinality_limit
415
+ if metric_cardinality_limit is not None
416
+ else extra.get("metric_cardinality_limit")
417
+ or os.getenv("TP_TRACE_METRIC_CARDINALITY_LIMIT")
418
+ )
419
+ resolved_metric_cardinality_limit = max(1, int(raw_cardinality_limit)) if raw_cardinality_limit is not None else 5000
420
+ except Exception:
421
+ resolved_metric_cardinality_limit = 5000
422
+
423
+ resolved_service_instance_id = (
424
+ service_instance_id
425
+ or extra.get("service_instance_id")
426
+ or os.getenv("TP_TRACE_SERVICE_INSTANCE_ID")
427
+ or os.getenv("OTEL_SERVICE_INSTANCE_ID")
428
+ or None
429
+ )
430
+
371
431
  env_headers = _parse_headers(os.getenv("TP_TRACE_HEADERS") or os.getenv("OTEL_EXPORTER_OTLP_HEADERS"))
372
432
  merged_headers = dict(env_headers)
373
433
  if headers:
@@ -484,6 +544,13 @@ class SDKConfig:
484
544
  "AUTO_PATCH",
485
545
  "extract_trace_context",
486
546
  "EXTRACT_TRACE_CONTEXT",
547
+ "n_plus_one_duplicate_threshold",
548
+ "N_PLUS_ONE_DUPLICATE_THRESHOLD",
549
+ # Deprecated timing knobs are ignored rather than becoming resource tags.
550
+ "n_plus_one_min_cumulative_duration_ms",
551
+ "N_PLUS_ONE_MIN_CUMULATIVE_DURATION_MS",
552
+ "n_plus_one_max_fingerprints",
553
+ "N_PLUS_ONE_MAX_FINGERPRINTS",
487
554
  }
488
555
  for k, v in extra.items():
489
556
  if k not in KNOWN_EXTRA_KEYS and not k.startswith("_"):
@@ -633,6 +700,29 @@ class SDKConfig:
633
700
  raw_query_mode = query_mode if query_mode is not None else os.getenv("TP_TRACE_QUERY_MODE")
634
701
  resolved_query_mode = _parse_query_mode(raw_query_mode)
635
702
 
703
+ raw_n_plus_one_dup = (
704
+ n_plus_one_duplicate_threshold
705
+ if n_plus_one_duplicate_threshold is not None
706
+ else extra.get("n_plus_one_duplicate_threshold")
707
+ if extra.get("n_plus_one_duplicate_threshold") is not None
708
+ else os.getenv("TP_TRACE_N_PLUS_ONE_DUPLICATE_THRESHOLD")
709
+ or os.getenv("TP_DOG_N_PLUS_ONE_DUPLICATE_THRESHOLD")
710
+ )
711
+ resolved_n_plus_one_dup = _parse_positive_int_default(
712
+ raw_n_plus_one_dup, default=15, min_val=2, field_name="n_plus_one_duplicate_threshold"
713
+ )
714
+
715
+ raw_n_plus_one_max_fp = (
716
+ n_plus_one_max_fingerprints
717
+ if n_plus_one_max_fingerprints is not None
718
+ else extra.get("n_plus_one_max_fingerprints")
719
+ if extra.get("n_plus_one_max_fingerprints") is not None
720
+ else os.getenv("TP_TRACE_N_PLUS_ONE_MAX_FINGERPRINTS") or os.getenv("TP_DOG_N_PLUS_ONE_MAX_FINGERPRINTS")
721
+ )
722
+ resolved_n_plus_one_max_fp = _parse_positive_int_default(
723
+ raw_n_plus_one_max_fp, default=256, min_val=1, field_name="n_plus_one_max_fingerprints"
724
+ )
725
+
636
726
  return cls(
637
727
  project_name=resolved_project,
638
728
  cluster_name=resolved_cluster,
@@ -661,8 +751,12 @@ class SDKConfig:
661
751
  metrics_endpoint=resolved_metrics_endpoint,
662
752
  metrics_enabled=resolved_metrics_enabled,
663
753
  metrics_export_interval_millis=resolved_metrics_interval,
754
+ metric_cardinality_limit=resolved_metric_cardinality_limit,
755
+ service_instance_id=resolved_service_instance_id,
664
756
  redis_mask_endpoint=resolved_redis_mask_endpoint,
665
757
  redis_mask_command=resolved_redis_mask_command,
666
758
  redis_cmd_max_length=resolved_cmd_max_length,
667
759
  query_mode=resolved_query_mode,
760
+ n_plus_one_duplicate_threshold=resolved_n_plus_one_dup,
761
+ n_plus_one_max_fingerprints=resolved_n_plus_one_max_fp,
668
762
  )
@@ -29,6 +29,14 @@ class BaseIntegration(abc.ABC):
29
29
  self._wrapped_keys: set = set()
30
30
  self._when_imported_hooks: List[Tuple[str, Callable]] = []
31
31
 
32
+ @property
33
+ def config(self) -> Optional[SDKConfig]:
34
+ return self._config
35
+
36
+ @config.setter
37
+ def config(self, value: Optional[SDKConfig]) -> None:
38
+ self._config = value
39
+
32
40
  @abc.abstractmethod
33
41
  def is_installed(self) -> bool:
34
42
  """Check if target library or framework is installed in the current environment."""
@@ -94,8 +102,10 @@ class BaseIntegration(abc.ABC):
94
102
 
95
103
  def unwrap_all(self) -> None:
96
104
  """Restores all wrapped targets to their original unwrapped states in reverse order."""
105
+ failed_targets = []
97
106
  while self._wrapped_targets:
98
107
  target_obj, attr_name, original = self._wrapped_targets.pop()
108
+ key = (id(target_obj), attr_name)
99
109
  try:
100
110
  current = getattr(target_obj, attr_name, None)
101
111
  if current is not None and hasattr(current, "__wrapped__"):
@@ -104,6 +114,7 @@ class BaseIntegration(abc.ABC):
104
114
  setattr(target_obj, attr_name, original)
105
115
  elif hasattr(target_obj, attr_name):
106
116
  delattr(target_obj, attr_name)
117
+ self._wrapped_keys.discard(key)
107
118
  except Exception as exc:
108
119
  logger.debug(
109
120
  "Error unwrapping %s on %s in %s integration: %s",
@@ -112,7 +123,10 @@ class BaseIntegration(abc.ABC):
112
123
  self.name,
113
124
  exc,
114
125
  )
115
- self._wrapped_keys.clear()
126
+ failed_targets.append((target_obj, attr_name, original))
127
+
128
+ if failed_targets:
129
+ self._wrapped_targets.extend(failed_targets)
116
130
 
117
131
  def when_imported(self, module_name: str, callback: Callable[..., Any]) -> None:
118
132
  """
@@ -166,6 +180,13 @@ class BaseIntegration(abc.ABC):
166
180
  return True
167
181
  try:
168
182
  self._remove_patch()
183
+ if self._wrapped_targets:
184
+ logger.debug(
185
+ "Failed to completely uninstrument %s; %d targets remained wrapped",
186
+ self.name,
187
+ len(self._wrapped_targets),
188
+ )
189
+ return False
169
190
  self._instrumented = False
170
191
  logger.debug("Successfully uninstrumented integration: %s", self.name)
171
192
  return True
@@ -12,7 +12,7 @@ from typing import FrozenSet, Iterable, NamedTuple, Optional
12
12
  class CeleryCreationMeta(NamedTuple):
13
13
  action: str # "apply_async" or "run"
14
14
  bounded_operation: str # "celery.apply_async.<admitted_name or unknown>"
15
- span_name: str # "🟣 celery.apply_async" or "🟣 celery.run"
15
+ span_name: str # "🟣 <task_name>.apply_async" or "🟣 <task_name>.run" (fallback generic)
16
16
 
17
17
 
18
18
  _CURRENT_CELERY_CREATION_META: contextvars.ContextVar[Optional[CeleryCreationMeta]] = contextvars.ContextVar(
@@ -64,10 +64,11 @@ class TpTraceCeleryInstrumentor(CeleryInstrumentor):
64
64
  task_name = getattr(task, "name", None)
65
65
 
66
66
  bounded_op = compute_bounded_operation("run", task_name, registered=task is not None)
67
+ span_name = f"🟣 {task_name}.run" if task_name and task_name != "unknown" else "🟣 celery.run"
67
68
  meta = CeleryCreationMeta(
68
69
  action="run",
69
70
  bounded_operation=bounded_op,
70
- span_name="🟣 celery.run",
71
+ span_name=span_name,
71
72
  )
72
73
 
73
74
  if task is None or task_id is None:
@@ -76,6 +77,26 @@ class TpTraceCeleryInstrumentor(CeleryInstrumentor):
76
77
  # creation. Keep our own restoration token for failures before that point.
77
78
  restore_token = context_api.attach(context_api.get_current())
78
79
  token = set_celery_creation_meta(meta)
80
+
81
+ # Defensive propagation support: Celery stores incoming message headers in task.request.headers.
82
+ # Upstream CeleryGetter does getattr(task.request, key). Ensure W3C propagation attributes
83
+ # on task.request are populated from task.request.headers if missing.
84
+ try:
85
+ req = getattr(task, "request", None)
86
+ if req is not None:
87
+ req_headers = getattr(req, "headers", None)
88
+ if req_headers is None and isinstance(req, dict):
89
+ req_headers = req.get("headers")
90
+ if isinstance(req_headers, dict):
91
+ for k in ("traceparent", "tracestate", "baggage"):
92
+ if k in req_headers and not hasattr(req, k):
93
+ try:
94
+ setattr(req, k, req_headers[k])
95
+ except Exception:
96
+ pass
97
+ except Exception:
98
+ pass
99
+
79
100
  try:
80
101
  try:
81
102
  super()._trace_prerun(*args, **kwargs)
@@ -109,7 +130,7 @@ class TpTraceCeleryInstrumentor(CeleryInstrumentor):
109
130
  if ctx is not None:
110
131
  span, _, _ = ctx
111
132
  if span is not None and getattr(span, "is_recording", lambda: True)():
112
- safe_update_name(span, "🟣 celery.run")
133
+ safe_update_name(span, span_name)
113
134
  safe_set_attribute(span, "celery.task_id", task_id)
114
135
  if task_name:
115
136
  safe_set_attribute(span, "celery.task_name", task_name)
@@ -126,6 +147,24 @@ class TpTraceCeleryInstrumentor(CeleryInstrumentor):
126
147
  rk = delivery_info.get("routing_key")
127
148
  if rk:
128
149
  safe_set_attribute(span, "celery.queue", str(rk))
150
+ req_headers = getattr(req, "headers", None)
151
+ if req_headers is None and isinstance(req, dict):
152
+ req_headers = req.get("headers")
153
+ trigger_view = None
154
+ if isinstance(req_headers, dict):
155
+ trigger_view = req_headers.get("tp_trigger_view")
156
+ if not trigger_view:
157
+ # Fallback for tasks executed eagerly in-process within the same thread
158
+ # (e.g. task_always_eager=True in tests or synchronous workflows),
159
+ # where task request headers may not have traversed amqp serialization.
160
+ try:
161
+ from tp_trace.route_context import get_current_view
162
+
163
+ trigger_view = get_current_view()
164
+ except Exception:
165
+ pass
166
+ if trigger_view:
167
+ safe_set_attribute(span, "celery.trigger.view", str(trigger_view))
129
168
  except Exception:
130
169
  logger.debug("Error during _trace_prerun enrichment", exc_info=True)
131
170
 
@@ -141,15 +180,32 @@ class TpTraceCeleryInstrumentor(CeleryInstrumentor):
141
180
  elif hasattr(sender, "name"):
142
181
  task_name = getattr(sender, "name")
143
182
  else:
144
- task_name = "unknown"
183
+ headers = kwargs.get("headers")
184
+ if isinstance(headers, dict) and headers.get("task"):
185
+ task_name = str(headers.get("task"))
186
+ else:
187
+ task_name = "unknown"
145
188
 
146
189
  bounded_op = compute_bounded_operation("apply_async", task_name, registered=task is not None)
190
+ span_name = f"🟣 {task_name}.apply_async" if task_name and task_name != "unknown" else "🟣 celery.apply_async"
147
191
  meta = CeleryCreationMeta(
148
192
  action="apply_async",
149
193
  bounded_operation=bounded_op,
150
- span_name="🟣 celery.apply_async",
194
+ span_name=span_name,
151
195
  )
152
196
 
197
+ trigger_view = None
198
+ try:
199
+ from tp_trace.route_context import get_current_view
200
+
201
+ trigger_view = get_current_view()
202
+ except Exception:
203
+ pass
204
+
205
+ headers = kwargs.get("headers")
206
+ if isinstance(headers, dict) and trigger_view:
207
+ headers["tp_trigger_view"] = trigger_view
208
+
153
209
  token = set_celery_creation_meta(meta)
154
210
  try:
155
211
  try:
@@ -160,6 +216,16 @@ class TpTraceCeleryInstrumentor(CeleryInstrumentor):
160
216
  logger.debug("Upstream _trace_before_publish failed", exc_info=True)
161
217
  return
162
218
 
219
+ # Defensive propagation injection: ensure trace context is injected even if headers dict
220
+ # was initially empty (upstream checks `if headers: inject(headers)`, skipping empty dicts).
221
+ try:
222
+ if isinstance(headers, dict) and "traceparent" not in headers:
223
+ from opentelemetry.propagate import inject
224
+
225
+ inject(headers)
226
+ except Exception:
227
+ pass
228
+
163
229
  # Only enrich a verified producer from upstream's context store.
164
230
  # Unknown-task producers can already have ended; the ambient span then
165
231
  # belongs to the application and must never be enriched or ended here.
@@ -168,9 +234,11 @@ class TpTraceCeleryInstrumentor(CeleryInstrumentor):
168
234
  ctx = utils.retrieve_context(task, task_id, is_publish=True)
169
235
  span = ctx[0] if ctx is not None else None
170
236
  if span is not None and getattr(span, "is_recording", lambda: True)():
171
- safe_update_name(span, "🟣 celery.apply_async")
237
+ safe_update_name(span, span_name)
172
238
  safe_set_attribute(span, "celery.task_id", task_id)
173
239
  safe_set_attribute(span, "celery.task_name", task_name)
240
+ if trigger_view:
241
+ safe_set_attribute(span, "celery.trigger.view", trigger_view)
174
242
 
175
243
  # Extract producer queue
176
244
  queue_name = None
@@ -191,13 +259,19 @@ class TpTraceCeleryInstrumentor(CeleryInstrumentor):
191
259
  logger.debug("Error during _trace_before_publish enrichment", exc_info=True)
192
260
 
193
261
  def _trace_after_publish(self, *args: Any, **kwargs: Any) -> None: # type: ignore[override]
194
- task = utils.retrieve_task_from_sender(kwargs)
195
- task_id = utils.retrieve_task_id_from_message(kwargs)
262
+ try:
263
+ task = utils.retrieve_task_from_sender(kwargs)
264
+ task_id = utils.retrieve_task_id_from_message(kwargs)
265
+ ctx = utils.retrieve_context(task, task_id, is_publish=True) if (task and task_id) else None
266
+ except Exception:
267
+ task = None
268
+ task_id = None
269
+ ctx = None
196
270
 
197
- ctx = utils.retrieve_context(task, task_id, is_publish=True) if (task and task_id) else None
198
271
  try:
199
272
  super()._trace_after_publish(*args, **kwargs)
200
- except Exception:
273
+ except Exception as exc:
274
+ logger.debug("Upstream _trace_after_publish failed: %s", exc, exc_info=True)
201
275
  if ctx is not None:
202
276
  _, activation, _ = ctx
203
277
  try:
@@ -206,7 +280,6 @@ class TpTraceCeleryInstrumentor(CeleryInstrumentor):
206
280
  pass
207
281
  if task and task_id:
208
282
  utils.detach_context(task, task_id, is_publish=True)
209
- raise
210
283
 
211
284
  def _trace_failure(self, *args: Any, **kwargs: Any) -> None: # type: ignore[override]
212
285
  upstream_failed = False
@@ -325,7 +398,8 @@ class TpTraceCeleryInstrumentor(CeleryInstrumentor):
325
398
  try:
326
399
  try:
327
400
  super()._trace_postrun(*args, **kwargs)
328
- except Exception:
401
+ except Exception as exc:
402
+ logger.debug("Upstream _trace_postrun failed: %s", exc, exc_info=True)
329
403
  # Upstream finalization failed: best-effort cleanup
330
404
  if ctx is not None:
331
405
  span, activation, token = ctx
@@ -345,11 +419,13 @@ class TpTraceCeleryInstrumentor(CeleryInstrumentor):
345
419
  context_api.detach(token) # type: ignore[arg-type]
346
420
  except Exception:
347
421
  pass
348
- raise
349
422
  finally:
350
423
  restore_token = self._prerun_context_tokens.pop(task_id, None) if task_id else None
351
424
  if restore_token is not None:
352
- context_api.detach(restore_token)
425
+ try:
426
+ context_api.detach(restore_token)
427
+ except Exception:
428
+ pass
353
429
  # Defensive timing map cleanup against leaks across all upstream versions
354
430
  if hasattr(self, "task_id_to_start_time") and isinstance(self.task_id_to_start_time, dict):
355
431
  if task_id:
@@ -0,0 +1,15 @@
1
+ """Django integration package — re-exports DjangoIntegration.
2
+
3
+ Layout follows:
4
+ integrations/django/
5
+ __init__.py — re-export
6
+ integration.py — DjangoIntegration orchestration
7
+ request.py — SERVER request hooks and OTel middleware enhancement
8
+ middleware.py — load_middleware wrapper
9
+ view.py — view span
10
+ template.py — template span
11
+ """
12
+
13
+ from .integration import DjangoIntegration
14
+
15
+ __all__ = ["DjangoIntegration"]
@@ -145,10 +145,13 @@ def make_traced_cache_op(op_name: str):
145
145
  pass
146
146
 
147
147
  if key is not None:
148
- if isinstance(key, (list, tuple, set)):
149
- span_attrs["django.cache.key"] = ", ".join(str(k) for k in key)
150
- else:
151
- span_attrs["django.cache.key"] = str(key)
148
+ try:
149
+ if isinstance(key, (list, tuple, set)):
150
+ span_attrs["django.cache.key"] = ", ".join(str(k) for k in key)
151
+ else:
152
+ span_attrs["django.cache.key"] = str(key)
153
+ except Exception:
154
+ pass
152
155
 
153
156
  token = _in_cache_span.set(True)
154
157
  try: