babelqueue 1.9.0__tar.gz → 1.11.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. {babelqueue-1.9.0 → babelqueue-1.11.0}/CHANGELOG.md +25 -0
  2. {babelqueue-1.9.0 → babelqueue-1.11.0}/PKG-INFO +37 -1
  3. {babelqueue-1.9.0 → babelqueue-1.11.0}/README.md +36 -0
  4. {babelqueue-1.9.0 → babelqueue-1.11.0}/pyproject.toml +1 -1
  5. {babelqueue-1.9.0 → babelqueue-1.11.0}/src/babelqueue/__init__.py +12 -3
  6. {babelqueue-1.9.0 → babelqueue-1.11.0}/src/babelqueue/app.py +55 -13
  7. babelqueue-1.11.0/src/babelqueue/headers.py +70 -0
  8. babelqueue-1.11.0/src/babelqueue/otel.py +265 -0
  9. {babelqueue-1.9.0 → babelqueue-1.11.0}/src/babelqueue/pika_transport.py +46 -10
  10. {babelqueue-1.9.0 → babelqueue-1.11.0}/src/babelqueue/redis_transport.py +103 -2
  11. {babelqueue-1.9.0 → babelqueue-1.11.0}/src/babelqueue/redrive.py +25 -8
  12. babelqueue-1.11.0/src/babelqueue/replay.py +74 -0
  13. {babelqueue-1.9.0 → babelqueue-1.11.0}/src/babelqueue/sqs_transport.py +60 -2
  14. {babelqueue-1.9.0 → babelqueue-1.11.0}/src/babelqueue/transport.py +28 -3
  15. babelqueue-1.11.0/tests/test_headers.py +112 -0
  16. babelqueue-1.11.0/tests/test_otel.py +263 -0
  17. babelqueue-1.11.0/tests/test_pika_transport.py +195 -0
  18. {babelqueue-1.9.0 → babelqueue-1.11.0}/tests/test_redis_transport.py +110 -2
  19. {babelqueue-1.9.0 → babelqueue-1.11.0}/tests/test_redrive.py +1 -0
  20. babelqueue-1.11.0/tests/test_replay.py +124 -0
  21. {babelqueue-1.9.0 → babelqueue-1.11.0}/tests/test_sqs_transport.py +69 -1
  22. babelqueue-1.9.0/src/babelqueue/otel.py +0 -176
  23. babelqueue-1.9.0/tests/test_otel.py +0 -131
  24. babelqueue-1.9.0/tests/test_pika_transport.py +0 -98
  25. {babelqueue-1.9.0 → babelqueue-1.11.0}/.github/FUNDING.yml +0 -0
  26. {babelqueue-1.9.0 → babelqueue-1.11.0}/.github/workflows/ci.yml +0 -0
  27. {babelqueue-1.9.0 → babelqueue-1.11.0}/.github/workflows/release.yml +0 -0
  28. {babelqueue-1.9.0 → babelqueue-1.11.0}/.gitignore +0 -0
  29. {babelqueue-1.9.0 → babelqueue-1.11.0}/LICENSE +0 -0
  30. {babelqueue-1.9.0 → babelqueue-1.11.0}/src/babelqueue/artemis_transport.py +0 -0
  31. {babelqueue-1.9.0 → babelqueue-1.11.0}/src/babelqueue/asb_transport.py +0 -0
  32. {babelqueue-1.9.0 → babelqueue-1.11.0}/src/babelqueue/celery.py +0 -0
  33. {babelqueue-1.9.0 → babelqueue-1.11.0}/src/babelqueue/codec.py +0 -0
  34. {babelqueue-1.9.0 → babelqueue-1.11.0}/src/babelqueue/contracts.py +0 -0
  35. {babelqueue-1.9.0 → babelqueue-1.11.0}/src/babelqueue/dead_letter.py +0 -0
  36. {babelqueue-1.9.0 → babelqueue-1.11.0}/src/babelqueue/django/__init__.py +0 -0
  37. {babelqueue-1.9.0 → babelqueue-1.11.0}/src/babelqueue/django/apps.py +0 -0
  38. {babelqueue-1.9.0 → babelqueue-1.11.0}/src/babelqueue/django/management/__init__.py +0 -0
  39. {babelqueue-1.9.0 → babelqueue-1.11.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
  40. {babelqueue-1.9.0 → babelqueue-1.11.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
  41. {babelqueue-1.9.0 → babelqueue-1.11.0}/src/babelqueue/exceptions.py +0 -0
  42. {babelqueue-1.9.0 → babelqueue-1.11.0}/src/babelqueue/idempotency.py +0 -0
  43. {babelqueue-1.9.0 → babelqueue-1.11.0}/src/babelqueue/kafka_transport.py +0 -0
  44. {babelqueue-1.9.0 → babelqueue-1.11.0}/src/babelqueue/pulsar_transport.py +0 -0
  45. {babelqueue-1.9.0 → babelqueue-1.11.0}/src/babelqueue/py.typed +0 -0
  46. {babelqueue-1.9.0 → babelqueue-1.11.0}/src/babelqueue/routing.py +0 -0
  47. {babelqueue-1.9.0 → babelqueue-1.11.0}/src/babelqueue/schema.py +0 -0
  48. {babelqueue-1.9.0 → babelqueue-1.11.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
  49. {babelqueue-1.9.0 → babelqueue-1.11.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
  50. {babelqueue-1.9.0 → babelqueue-1.11.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
  51. {babelqueue-1.9.0 → babelqueue-1.11.0}/tests/conformance/fixtures/order-created.json +0 -0
  52. {babelqueue-1.9.0 → babelqueue-1.11.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
  53. {babelqueue-1.9.0 → babelqueue-1.11.0}/tests/conformance/fixtures/urn-alias.json +0 -0
  54. {babelqueue-1.9.0 → babelqueue-1.11.0}/tests/conformance/manifest.json +0 -0
  55. {babelqueue-1.9.0 → babelqueue-1.11.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
  56. {babelqueue-1.9.0 → babelqueue-1.11.0}/tests/fixtures/dead-lettered.json +0 -0
  57. {babelqueue-1.9.0 → babelqueue-1.11.0}/tests/fixtures/order-created.json +0 -0
  58. {babelqueue-1.9.0 → babelqueue-1.11.0}/tests/test_app.py +0 -0
  59. {babelqueue-1.9.0 → babelqueue-1.11.0}/tests/test_artemis_conformance.py +0 -0
  60. {babelqueue-1.9.0 → babelqueue-1.11.0}/tests/test_artemis_transport.py +0 -0
  61. {babelqueue-1.9.0 → babelqueue-1.11.0}/tests/test_asb_conformance.py +0 -0
  62. {babelqueue-1.9.0 → babelqueue-1.11.0}/tests/test_asb_transport.py +0 -0
  63. {babelqueue-1.9.0 → babelqueue-1.11.0}/tests/test_celery.py +0 -0
  64. {babelqueue-1.9.0 → babelqueue-1.11.0}/tests/test_codec.py +0 -0
  65. {babelqueue-1.9.0 → babelqueue-1.11.0}/tests/test_conformance.py +0 -0
  66. {babelqueue-1.9.0 → babelqueue-1.11.0}/tests/test_dead_letter.py +0 -0
  67. {babelqueue-1.9.0 → babelqueue-1.11.0}/tests/test_django.py +0 -0
  68. {babelqueue-1.9.0 → babelqueue-1.11.0}/tests/test_idempotency.py +0 -0
  69. {babelqueue-1.9.0 → babelqueue-1.11.0}/tests/test_kafka_conformance.py +0 -0
  70. {babelqueue-1.9.0 → babelqueue-1.11.0}/tests/test_kafka_transport.py +0 -0
  71. {babelqueue-1.9.0 → babelqueue-1.11.0}/tests/test_overhead.py +0 -0
  72. {babelqueue-1.9.0 → babelqueue-1.11.0}/tests/test_pulsar_conformance.py +0 -0
  73. {babelqueue-1.9.0 → babelqueue-1.11.0}/tests/test_pulsar_transport.py +0 -0
  74. {babelqueue-1.9.0 → babelqueue-1.11.0}/tests/test_schema.py +0 -0
  75. {babelqueue-1.9.0 → babelqueue-1.11.0}/tests/test_schema_conformance.py +0 -0
  76. {babelqueue-1.9.0 → babelqueue-1.11.0}/tests/test_sqs_conformance.py +0 -0
@@ -9,6 +9,31 @@ The envelope wire format is versioned separately by `meta.schema_version`
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [1.11.0] - 2026-06-21
13
+
14
+ ### Added
15
+ - **W3C `traceparent` span-context propagation** (OpenTelemetry v0.2, ADR-0028) — the optional
16
+ `babelqueue.otel` module now carries true cross-hop **span** parent-child linkage, not just
17
+ shared-`trace_id` correlation. On publish, `otel.publish` injects the active span context as a
18
+ W3C `traceparent` **transport header** (and still stamps `trace_id` for the v0.1 fallback); on
19
+ consume, `otel.wrap_handler` extracts it and starts the CONSUMER span as a true **child** of the
20
+ producer span. With no `traceparent` present it falls back to the v0.1 `trace_id`-derived parent,
21
+ so it is a strict, backward-compatible upgrade — no regression. The header rides **out of band**
22
+ via a new dependency-free core seam — `BabelQueue.publish_with_headers(urn, data, headers, …)`
23
+ (produce side) and `babelqueue.headers_from_context()` (consume side, surfaced by the runtime) —
24
+ so the wire envelope stays **frozen** (`schema_version: 1`, GR-1) and the core stays
25
+ zero-dependency (OTel remains the optional `[otel]` extra, GR-7). `traceparent` is carried on the
26
+ **in-memory** (reference), **Redis** (a transport-owned `__bq_frame` JSON frame with bare-value
27
+ back-compat, so cross-version queues interoperate; degrades to a bare publish in Laravel-compat
28
+ mode), **RabbitMQ** (native AMQP header table, beside the contract `x-*` headers) and **SQS**
29
+ (native `MessageAttributes`, beside the contract `bq-*` attributes) transports; where a transport
30
+ can't carry headers, propagation degrades cleanly to v0.1 `trace_id` correlation with no error.
31
+ A plain `publish` is byte-identical to before. Unit-tested without a broker (frame round-trip +
32
+ bare back-compat, header merge/extract per transport, and an in-memory producer→consumer
33
+ parent-child end-to-end with the OTel SDK's `InMemorySpanExporter`); broker-gated integration
34
+ tests assert a published `traceparent` arrives on the consumed message's headers beside the
35
+ unchanged body. The envelope is unchanged; this is purely additive. Ships as a MINOR.
36
+
12
37
  ## [1.6.0] - 2026-06-14
13
38
 
14
39
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: babelqueue
3
- Version: 1.9.0
3
+ Version: 1.11.0
4
4
  Summary: Polyglot Queues, Simplified — the Python core: the canonical BabelQueue wire-envelope codec, contracts and dead-letter helpers.
5
5
  Project-URL: Homepage, https://babelqueue.com
6
6
  Project-URL: Source, https://github.com/BabelQueue/babelqueue-python
@@ -234,6 +234,42 @@ def on_created(data, meta): ...
234
234
  python manage.py babelqueue_worker --queue orders # run the consumer
235
235
  ```
236
236
 
237
+ ## OpenTelemetry tracing (optional)
238
+
239
+ `pip install "babelqueue[otel]"` adds the optional `babelqueue.otel` module — the core never
240
+ imports OpenTelemetry, so it stays zero-dependency. It emits a PRODUCER span per publish and a
241
+ CONSUMER span per handled message, correlated across every hop and SDK, at two layered levels:
242
+
243
+ - **`trace_id` correlation** (v0.1): the envelope's `trace_id` maps 1:1 to an OTel trace id, so
244
+ every hop that shares a `trace_id` shares one trace — with **zero** wire/transport change.
245
+ - **W3C `traceparent` span linkage** (v0.2): the producer also injects its active span context as
246
+ a `traceparent` **transport header** (beside the frozen envelope, never in it), so the consumer
247
+ starts its span as a true **child** of the producer span — real cross-hop parent-child linkage.
248
+ With no `traceparent` present it falls back to the v0.1 `trace_id` behaviour, so enabling it is a
249
+ strict, backward-compatible upgrade.
250
+
251
+ ```python
252
+ from opentelemetry import trace
253
+ from babelqueue import BabelQueue, otel
254
+
255
+ tracer = trace.get_tracer("orders")
256
+ app = BabelQueue("redis://localhost:6379/0", queue="orders")
257
+
258
+ # consumer: wrap_handler starts a CONSUMER span (child of the producer span when a
259
+ # traceparent rode along; else in the trace_id-derived trace)
260
+ app.register("urn:babel:orders:created", otel.wrap_handler(tracer, on_order_created))
261
+
262
+ # producer: otel.publish starts a PRODUCER span and carries traceparent + trace_id
263
+ otel.publish(tracer, app, "urn:babel:orders:created", {"order_id": 1042})
264
+ ```
265
+
266
+ The `traceparent` rides the out-of-band transport-header seam (`publish_with_headers` /
267
+ `headers_from_context`) — the same seam the replay-bypass marker uses — so the envelope stays
268
+ frozen (`schema_version: 1`). It is carried on the in-memory, Redis (a transport-owned JSON frame,
269
+ with bare-value back-compat), RabbitMQ (AMQP header table) and SQS (`MessageAttributes`)
270
+ transports; where a transport can't carry it, propagation degrades cleanly to v0.1 `trace_id`
271
+ correlation with no error.
272
+
237
273
  ## What's here
238
274
 
239
275
  The codec/contracts/dead-letter (zero-dep core), the `BabelQueue` runtime
@@ -182,6 +182,42 @@ def on_created(data, meta): ...
182
182
  python manage.py babelqueue_worker --queue orders # run the consumer
183
183
  ```
184
184
 
185
+ ## OpenTelemetry tracing (optional)
186
+
187
+ `pip install "babelqueue[otel]"` adds the optional `babelqueue.otel` module — the core never
188
+ imports OpenTelemetry, so it stays zero-dependency. It emits a PRODUCER span per publish and a
189
+ CONSUMER span per handled message, correlated across every hop and SDK, at two layered levels:
190
+
191
+ - **`trace_id` correlation** (v0.1): the envelope's `trace_id` maps 1:1 to an OTel trace id, so
192
+ every hop that shares a `trace_id` shares one trace — with **zero** wire/transport change.
193
+ - **W3C `traceparent` span linkage** (v0.2): the producer also injects its active span context as
194
+ a `traceparent` **transport header** (beside the frozen envelope, never in it), so the consumer
195
+ starts its span as a true **child** of the producer span — real cross-hop parent-child linkage.
196
+ With no `traceparent` present it falls back to the v0.1 `trace_id` behaviour, so enabling it is a
197
+ strict, backward-compatible upgrade.
198
+
199
+ ```python
200
+ from opentelemetry import trace
201
+ from babelqueue import BabelQueue, otel
202
+
203
+ tracer = trace.get_tracer("orders")
204
+ app = BabelQueue("redis://localhost:6379/0", queue="orders")
205
+
206
+ # consumer: wrap_handler starts a CONSUMER span (child of the producer span when a
207
+ # traceparent rode along; else in the trace_id-derived trace)
208
+ app.register("urn:babel:orders:created", otel.wrap_handler(tracer, on_order_created))
209
+
210
+ # producer: otel.publish starts a PRODUCER span and carries traceparent + trace_id
211
+ otel.publish(tracer, app, "urn:babel:orders:created", {"order_id": 1042})
212
+ ```
213
+
214
+ The `traceparent` rides the out-of-band transport-header seam (`publish_with_headers` /
215
+ `headers_from_context`) — the same seam the replay-bypass marker uses — so the envelope stays
216
+ frozen (`schema_version: 1`). It is carried on the in-memory, Redis (a transport-owned JSON frame,
217
+ with bare-value back-compat), RabbitMQ (AMQP header table) and SQS (`MessageAttributes`)
218
+ transports; where a transport can't carry it, propagation degrades cleanly to v0.1 `trace_id`
219
+ correlation with no error.
220
+
185
221
  ## What's here
186
222
 
187
223
  The codec/contracts/dead-letter (zero-dep core), the `BabelQueue` runtime
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "babelqueue"
7
- version = "1.9.0"
7
+ version = "1.11.0"
8
8
  description = "Polyglot Queues, Simplified — the Python core: the canonical BabelQueue wire-envelope codec, contracts and dead-letter helpers."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -11,16 +11,18 @@ and dead-letter helpers. Framework adapters (Celery, Django, ...) build on this.
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
- from . import dead_letter, idempotency, redrive
14
+ from . import dead_letter, headers, idempotency, redrive, replay
15
15
  from .app import BabelQueue
16
16
  from .codec import SCHEMA_VERSION, SOURCE_LANG, EnvelopeCodec
17
17
  from .contracts import HasTraceId, PolyglotMessage
18
+ from .headers import headers_from_context
18
19
  from .idempotency import IdempotencyStore, InMemoryStore
19
20
  from .exceptions import BabelQueueError, UnknownUrnError
21
+ from .replay import HEADER_REPLAY_BYPASS, bypass_external_effects, is_replay
20
22
  from .routing import UnknownUrnStrategy
21
- from .transport import InMemoryTransport, ReceivedMessage, Transport
23
+ from .transport import HeaderPublisher, InMemoryTransport, ReceivedMessage, Transport
22
24
 
23
- __version__ = "1.6.0"
25
+ __version__ = "1.11.0"
24
26
 
25
27
  __all__ = [
26
28
  "BabelQueue",
@@ -33,11 +35,18 @@ __all__ = [
33
35
  "Transport",
34
36
  "InMemoryTransport",
35
37
  "ReceivedMessage",
38
+ "HeaderPublisher",
36
39
  "BabelQueueError",
37
40
  "UnknownUrnError",
38
41
  "dead_letter",
42
+ "headers",
39
43
  "idempotency",
40
44
  "redrive",
45
+ "replay",
46
+ "is_replay",
47
+ "bypass_external_effects",
48
+ "headers_from_context",
49
+ "HEADER_REPLAY_BYPASS",
41
50
  "IdempotencyStore",
42
51
  "InMemoryStore",
43
52
  "__version__",
@@ -25,8 +25,10 @@ from typing import Any, Callable, Dict, Mapping, Optional
25
25
  from . import dead_letter
26
26
  from .codec import EnvelopeCodec
27
27
  from .exceptions import UnknownUrnError
28
+ from .headers import _headers_scope
29
+ from .replay import HEADER_REPLAY_BYPASS, _replay_scope
28
30
  from .routing import UnknownUrnStrategy
29
- from .transport import ReceivedMessage, Transport, make_transport
31
+ from .transport import HeaderPublisher, ReceivedMessage, Transport, make_transport
30
32
 
31
33
  Handler = Callable[..., None]
32
34
 
@@ -69,6 +71,37 @@ class BabelQueue:
69
71
  self.transport.publish(target, EnvelopeCodec.encode(envelope))
70
72
  return envelope["meta"]["id"]
71
73
 
74
+ def publish_with_headers(
75
+ self,
76
+ urn: str,
77
+ data: Mapping[str, Any],
78
+ headers: Mapping[str, str],
79
+ *,
80
+ queue: Optional[str] = None,
81
+ trace_id: Optional[str] = None,
82
+ ) -> str:
83
+ """Publish a message together with out-of-band transport ``headers``; returns its id.
84
+
85
+ The headers ride **beside** the frozen envelope (GR-1) on the transport's per-message
86
+ metadata channel — e.g. a W3C ``traceparent`` for cross-hop span linkage (ADR-0028) —
87
+ never inside it. It is the produce-side counterpart of the headers the runtime surfaces
88
+ to a handler via :func:`~babelqueue.headers.headers_from_context`.
89
+
90
+ When the transport implements :class:`~babelqueue.transport.HeaderPublisher` and
91
+ ``headers`` is non-empty, the headers are propagated; otherwise it transparently falls
92
+ back to a plain :meth:`publish` (the headers are dropped — no error, no regression),
93
+ exactly as :func:`~babelqueue.redrive.redrive` degrades. Passing empty headers is
94
+ equivalent to :meth:`publish`, so callers need not branch on transport capability.
95
+ """
96
+ target = queue or self.queue
97
+ envelope = EnvelopeCodec.make(urn, data, queue=target, trace_id=trace_id)
98
+ body = EnvelopeCodec.encode(envelope)
99
+ if headers and isinstance(self.transport, HeaderPublisher):
100
+ self.transport.publish_with_headers(target, body, dict(headers))
101
+ else:
102
+ self.transport.publish(target, body)
103
+ return envelope["meta"]["id"]
104
+
72
105
  # -- Register handlers --------------------------------------------------
73
106
 
74
107
  def handler(self, urn: str) -> Callable[[Handler], Handler]:
@@ -115,19 +148,28 @@ class BabelQueue:
115
148
  run = consume
116
149
 
117
150
  def dispatch(self, received: ReceivedMessage) -> None:
118
- """Route one reserved message to its handler and acknowledge it."""
119
- envelope = EnvelopeCodec.decode(received.body)
120
- urn = str(envelope.get("job") or envelope.get("urn") or "")
121
- handler = self._handlers.get(urn) if urn else None
151
+ """Route one reserved message to its handler and acknowledge it.
122
152
 
123
- try:
124
- if handler is None:
125
- self._route_unknown(urn, received, envelope)
126
- return
127
- self._invoke(handler, envelope)
128
- self.transport.ack(received)
129
- except Exception as exc: # noqa: BLE001 - one bad message must not kill the loop
130
- self._retry_or_dead_letter(received, envelope, exc)
153
+ The delivered message's out-of-band transport headers are surfaced onto the context for
154
+ the span of this dispatch (:func:`~babelqueue.headers.headers_from_context`), so a handler
155
+ or an optional wrapper (e.g. the ``otel`` module reading a W3C ``traceparent``, ADR-0028)
156
+ can read metadata that travels beside the frozen envelope (GR-1).
157
+ """
158
+ with _headers_scope(received.headers), _replay_scope(
159
+ bool(received.headers.get(HEADER_REPLAY_BYPASS))
160
+ ):
161
+ envelope = EnvelopeCodec.decode(received.body)
162
+ urn = str(envelope.get("job") or envelope.get("urn") or "")
163
+ handler = self._handlers.get(urn) if urn else None
164
+
165
+ try:
166
+ if handler is None:
167
+ self._route_unknown(urn, received, envelope)
168
+ return
169
+ self._invoke(handler, envelope)
170
+ self.transport.ack(received)
171
+ except Exception as exc: # noqa: BLE001 - one bad message must not kill the loop
172
+ self._retry_or_dead_letter(received, envelope, exc)
131
173
 
132
174
  # -- Internals ----------------------------------------------------------
133
175
 
@@ -0,0 +1,70 @@
1
+ """Consume-side out-of-band transport headers (the Python mirror of Go's ``headers.go``).
2
+
3
+ The runtime surfaces a delivered message's :attr:`~babelqueue.transport.ReceivedMessage.headers`
4
+ onto a :class:`contextvars.ContextVar` for the span of one dispatch, so a handler — or an
5
+ optional wrapper such as the ``otel`` module — can read per-message metadata that travels
6
+ **beside** the frozen envelope (GR-1), never in it. It is the consume-side counterpart of
7
+ :class:`~babelqueue.transport.HeaderPublisher`.
8
+
9
+ This is the same out-of-band seam the replay-bypass marker rides (ADR-0027); ADR-0028's W3C
10
+ ``traceparent`` (for cross-hop span parent-child linkage) is the second rider on it. The header
11
+ map is read-only — treat it as immutable. Adds only :mod:`contextvars` + a plain ``dict`` (no
12
+ dependency), exactly like :mod:`babelqueue.replay`.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import contextlib
18
+ import contextvars
19
+ from typing import Dict, Iterator, Mapping, Optional
20
+
21
+ #: The delivered message's out-of-band transport headers, for the span of one dispatch.
22
+ #: Defaults to an empty mapping so :func:`headers_from_context` is always nil-safe.
23
+ _headers_var: contextvars.ContextVar[Mapping[str, str]] = contextvars.ContextVar(
24
+ "babelqueue_headers", default={}
25
+ )
26
+
27
+
28
+ def headers_from_context() -> Mapping[str, str]:
29
+ """Return the out-of-band transport headers that arrived with the message currently being
30
+ handled, or an empty mapping when none were carried (or the transport surfaces none).
31
+
32
+ The returned mapping is read-only — do not mutate it. It is the consume-side counterpart of
33
+ :class:`~babelqueue.transport.HeaderPublisher`: a handler or an optional wrapper (e.g. the
34
+ ``otel`` module's :func:`~babelqueue.otel.wrap_handler`) reads per-message metadata that
35
+ travels beside the frozen envelope, never in it (GR-1).
36
+ """
37
+ return _headers_var.get()
38
+
39
+
40
+ @contextlib.contextmanager
41
+ def _headers_scope(headers: Optional[Mapping[str, str]]) -> Iterator[None]:
42
+ """Internal: surface ``headers`` on the context for the span of one dispatch, then reset.
43
+
44
+ A nil/empty map is fine; reads stay nil-safe. The runtime calls this in
45
+ :meth:`~babelqueue.app.BabelQueue.dispatch` so wrappers can read the delivered headers.
46
+ """
47
+ scoped: Mapping[str, str] = headers or {}
48
+ token = _headers_var.set(scoped)
49
+ try:
50
+ yield
51
+ finally:
52
+ _headers_var.reset(token)
53
+
54
+
55
+ def merge_headers(*sources: Optional[Mapping[str, str]]) -> Dict[str, str]:
56
+ """Combine header maps into a single ``dict[str, str]``, dropping blank keys and blank values.
57
+
58
+ Later sources win a key collision. Returns a fresh dict (callers may mutate it freely). Used
59
+ to merge an injected ``traceparent`` onto a transport's contract headers without clobbering
60
+ them — the contract keys are passed *last* so they win (mirrors the Go merge-not-clobber).
61
+ """
62
+ out: Dict[str, str] = {}
63
+ for source in sources:
64
+ if not source:
65
+ continue
66
+ for key, value in source.items():
67
+ if not key or value is None or value == "":
68
+ continue
69
+ out[str(key)] = str(value)
70
+ return out
@@ -0,0 +1,265 @@
1
+ """Optional OpenTelemetry tracing: produce/consume spans correlated across hops, with true
2
+ cross-hop span parent-child linkage.
3
+
4
+ The Python mirror of the Go ``babelqueue-go/otel`` module. It emits a CONSUMER span per
5
+ handled message and a PRODUCER span per publish. Cross-hop trace propagation works at two
6
+ layered levels:
7
+
8
+ * **trace_id ↔ TraceID** (ADR-0025, v0.1): the envelope's ``trace_id`` — a UUID — maps 1:1 to a
9
+ 128-bit OTel trace id, so every hop that shares a ``trace_id`` shares one OTel trace
10
+ (correlation + per-hop timing) with **zero** wire/transport change.
11
+ * **W3C ``traceparent``** (ADR-0028, v0.2): the producer also injects the active span context as
12
+ a ``traceparent`` transport header (beside the frozen envelope, never in it), so the consumer
13
+ starts its span as a **true child** of the producer span — real cross-hop parent-child linkage.
14
+ This rides the out-of-band :class:`~babelqueue.transport.HeaderPublisher` /
15
+ :func:`~babelqueue.headers.headers_from_context` seam (ADR-0027) and is available on any
16
+ transport that carries headers. With no ``traceparent`` present it falls back to the v0.1
17
+ ``trace_id`` behaviour — a strict, backward-compatible upgrade (no regression).
18
+
19
+ The wire envelope is untouched (GR-1) and the core never imports OpenTelemetry: this module is
20
+ only importable with the ``[otel]`` extra (``pip install babelqueue[otel]``), exactly like the
21
+ optional transport drivers.
22
+
23
+ from opentelemetry import trace
24
+ from babelqueue import BabelQueue, otel
25
+
26
+ tracer = trace.get_tracer("orders")
27
+ app = BabelQueue("redis://localhost:6379/0", queue="orders")
28
+ app.register("urn:babel:orders:created", otel.wrap_handler(tracer, on_order_created))
29
+ # producer side:
30
+ otel.publish(tracer, app, "urn:babel:orders:created", {"order_id": 1042})
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import hashlib
36
+ import inspect
37
+ from typing import Any, Callable, Dict, Mapping, Optional
38
+
39
+ from opentelemetry.context import Context
40
+ from opentelemetry.trace import (
41
+ NonRecordingSpan,
42
+ SpanContext,
43
+ SpanKind,
44
+ TraceFlags,
45
+ Tracer,
46
+ get_current_span,
47
+ set_span_in_context,
48
+ )
49
+ from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
50
+
51
+ from .headers import headers_from_context, merge_headers
52
+
53
+ Handler = Callable[..., None]
54
+
55
+ _SYSTEM = "babelqueue"
56
+ _MASK_128 = (1 << 128) - 1
57
+
58
+ #: The out-of-band transport headers that carry W3C Trace Context across a hop (ADR-0028). They
59
+ #: ride beside the frozen envelope on the transport's per-message metadata channel — the same
60
+ #: seam as the replay-bypass marker (ADR-0027) — so a consumer starts its span as a true child of
61
+ #: the producer's span, not merely share the ``trace_id``-derived trace. The envelope is untouched
62
+ #: (GR-1). ``traceparent``/``tracestate`` are exactly the W3C wire format, so a babelqueue header
63
+ #: interoperates with any OTel SDK or W3C-compliant peer.
64
+ HEADER_TRACEPARENT = "traceparent"
65
+ HEADER_TRACESTATE = "tracestate"
66
+
67
+ #: The W3C Trace Context propagator. It reads/writes the ``traceparent`` (and ``tracestate``)
68
+ #: headers — the exact wire format ADR-0028 names — so a babelqueue header interoperates with any
69
+ #: W3C peer. The propagator's default getter/setter operate on a plain ``dict``/``Mapping``, which
70
+ #: is exactly the shape of the SDK-owned transport-header map, so no custom carrier is needed.
71
+ _propagator = TraceContextTextMapPropagator()
72
+
73
+
74
+ def _inject_traceparent(context: Optional[Context]) -> Dict[str, str]:
75
+ """Write the active span context (in ``context``) as W3C ``traceparent`` (and ``tracestate``)
76
+ into a fresh header map. The producer half: the result is handed to a
77
+ :class:`~babelqueue.transport.HeaderPublisher` so the consumer can reconstruct the remote
78
+ parent. With no valid span context the propagator writes nothing and the map stays empty (so a
79
+ no-trace publish stays header-free)."""
80
+ carrier: Dict[str, str] = {}
81
+ _propagator.inject(carrier, context=context)
82
+ return carrier
83
+
84
+
85
+ def _remote_parent_from_headers() -> Optional[Context]:
86
+ """Extract a W3C ``traceparent`` from the out-of-band transport headers surfaced on the
87
+ current context (:func:`~babelqueue.headers.headers_from_context`) and return a
88
+ :class:`~opentelemetry.context.Context` carrying the remote parent span context, or ``None``
89
+ when no valid ``traceparent`` is present.
90
+
91
+ The consumer half of true cross-hop parent-child linkage: a span started from the returned
92
+ context is a child of the producer's span (remote parent). ``None`` signals the caller to fall
93
+ back to the v0.1 ``trace_id``-derived parent (ADR-0025 Option 1)."""
94
+ headers = headers_from_context()
95
+ if not headers.get(HEADER_TRACEPARENT):
96
+ return None
97
+ extracted = _propagator.extract(dict(headers))
98
+ span_context = get_current_span(extracted).get_span_context()
99
+ if not span_context.is_valid:
100
+ return None
101
+ return extracted
102
+
103
+
104
+ def trace_id_of(trace_id: str) -> int:
105
+ """Map an envelope ``trace_id`` to a deterministic 128-bit OTel trace id.
106
+
107
+ A UUID maps to its 16 raw bytes; any other string is hashed (SHA-256, first 16 bytes).
108
+ The result is never zero (OTel's invalid trace id). The inverse of :func:`uuid_of` for
109
+ the UUID case.
110
+ """
111
+ raw = _uuid_bytes(trace_id)
112
+ if raw is not None:
113
+ n = int.from_bytes(raw, "big")
114
+ if n != 0:
115
+ return n
116
+ digest = hashlib.sha256(trace_id.encode("utf-8")).digest()[:16]
117
+ return int.from_bytes(digest, "big") or 1
118
+
119
+
120
+ def uuid_of(trace_id_int: int) -> str:
121
+ """Format a 128-bit OTel trace id as a canonical UUID string.
122
+
123
+ The form a producer stamps into the message's ``trace_id`` so a consumer can recover the
124
+ same trace id via :func:`trace_id_of`.
125
+ """
126
+ h = format(trace_id_int & _MASK_128, "032x")
127
+ return f"{h[0:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:32]}"
128
+
129
+
130
+ def _uuid_bytes(s: str) -> Optional[bytes]:
131
+ h = s.replace("-", "")
132
+ if len(h) != 32:
133
+ return None
134
+ try:
135
+ return bytes.fromhex(h)
136
+ except ValueError:
137
+ return None
138
+
139
+
140
+ def _span_id_of(trace_id: str) -> int:
141
+ """Derive a deterministic, non-zero 64-bit span id so the remote parent context is valid
142
+ (a span needs a valid parent to inherit a specific trace)."""
143
+ digest = hashlib.sha256(("babelqueue-span:" + trace_id).encode("utf-8")).digest()[:8]
144
+ return int.from_bytes(digest, "big") or 1
145
+
146
+
147
+ def _parent_context(trace_id: str) -> Context:
148
+ sc = SpanContext(
149
+ trace_id=trace_id_of(trace_id),
150
+ span_id=_span_id_of(trace_id),
151
+ is_remote=True,
152
+ trace_flags=TraceFlags(TraceFlags.SAMPLED),
153
+ )
154
+ return set_span_in_context(NonRecordingSpan(sc))
155
+
156
+
157
+ def _wants_envelope(fn: Handler) -> bool:
158
+ """True if ``fn`` takes a 3rd positional arg (the full envelope) or ``*args``."""
159
+ try:
160
+ params = list(inspect.signature(fn).parameters.values())
161
+ except (TypeError, ValueError): # pragma: no cover - builtins / C callables
162
+ return False
163
+ positional = [
164
+ p for p in params if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
165
+ ]
166
+ return any(p.kind == p.VAR_POSITIONAL for p in params) or len(positional) >= 3
167
+
168
+
169
+ def wrap_handler(tracer: Tracer, handler: Handler) -> Handler:
170
+ """Wrap a consume handler to emit a CONSUMER span per message.
171
+
172
+ Register it like any handler: ``app.register(urn, wrap_handler(tracer, handler))``. The
173
+ wrapper's ``*args`` signature makes the runtime pass the full envelope (``data, meta,
174
+ envelope``), so it can read ``trace_id``/``job`` even when the inner handler only wants
175
+ ``(data, meta)``. A raising handler records the exception on the span and re-raises, so the
176
+ runtime's retry / dead-letter path still applies.
177
+
178
+ **Parent selection** (ADR-0028): when the producer carried a W3C ``traceparent`` on the
179
+ transport (surfaced by the runtime via :func:`~babelqueue.headers.headers_from_context`), the
180
+ span is started as a true **child** of the producer's span — real cross-hop parent-child
181
+ linkage with per-hop span timing. With no ``traceparent`` present it falls back to the v0.1
182
+ behaviour: a remote parent derived from the envelope's ``trace_id`` (ADR-0025 Option 1), which
183
+ shares the trace but not the exact span link. So enabling propagation is a strict,
184
+ backward-compatible upgrade — no regression for messages produced without it.
185
+ """
186
+
187
+ def wrapped(*args: Any) -> None:
188
+ envelope = args[2] if len(args) > 2 and isinstance(args[2], Mapping) else {}
189
+ meta = args[1] if len(args) > 1 and isinstance(args[1], Mapping) else {}
190
+ trace_id = str(envelope.get("trace_id") or "")
191
+ urn = str(envelope.get("job") or envelope.get("urn") or "")
192
+
193
+ attributes: dict[str, Any] = {
194
+ "messaging.system": _SYSTEM,
195
+ "messaging.operation": "process",
196
+ "messaging.destination.name": str(meta.get("queue") or ""),
197
+ "messaging.message.id": str(meta.get("id") or ""),
198
+ "messaging.message.conversation_id": trace_id,
199
+ "messaging.babelqueue.attempts": int(envelope.get("attempts", 0) or 0),
200
+ }
201
+ # Prefer a true remote parent from a carried W3C traceparent (v0.2); else fall back to the
202
+ # trace_id-derived parent (v0.1). A header-less / malformed traceparent yields None.
203
+ context = _remote_parent_from_headers()
204
+ if context is None and trace_id:
205
+ context = _parent_context(trace_id)
206
+
207
+ with tracer.start_as_current_span(
208
+ "process " + urn,
209
+ context=context,
210
+ kind=SpanKind.CONSUMER,
211
+ attributes=attributes,
212
+ ):
213
+ if _wants_envelope(handler):
214
+ handler(*args)
215
+ else:
216
+ handler(args[0], args[1])
217
+
218
+ return wrapped
219
+
220
+
221
+ def publish(
222
+ tracer: Tracer,
223
+ app: Any,
224
+ urn: str,
225
+ data: Mapping[str, Any],
226
+ *,
227
+ queue: Optional[str] = None,
228
+ ) -> str:
229
+ """Publish via a PRODUCER span ``publish <urn>``, propagating the trace downstream two ways
230
+ (ADR-0028):
231
+
232
+ * It injects the active span context as a W3C ``traceparent`` (and ``tracestate``) onto the
233
+ outgoing transport headers, so a consumer can start its span as a true **child** of this
234
+ producer span — real cross-hop parent-child linkage. The header rides beside the frozen
235
+ envelope, never in it (GR-1), via ``app.publish_with_headers``; if the transport can't carry
236
+ headers it degrades to a plain publish (the ``traceparent`` is simply not propagated — no
237
+ error).
238
+ * It also carries the active trace's id into the message's ``trace_id`` (the v0.1 behaviour),
239
+ so even a consumer that ignores the header — or a transport that drops it — still recovers
240
+ the same trace (correlation without exact span linkage).
241
+
242
+ Behaves like ``app.publish`` (returns the message id). ``app`` is any object exposing
243
+ ``publish(urn, data, *, queue=None, trace_id=None) -> str``; when it also exposes
244
+ ``publish_with_headers(urn, data, headers, *, queue=None, trace_id=None) -> str`` the
245
+ ``traceparent`` is propagated, otherwise it transparently falls back to ``publish``.
246
+ """
247
+ attributes = {
248
+ "messaging.system": _SYSTEM,
249
+ "messaging.operation": "publish",
250
+ "messaging.destination.name": urn,
251
+ }
252
+ with tracer.start_as_current_span(
253
+ "publish " + urn, kind=SpanKind.PRODUCER, attributes=attributes
254
+ ) as span:
255
+ trace_id = uuid_of(span.get_span_context().trace_id)
256
+ headers = merge_headers(_inject_traceparent(set_span_in_context(span)))
257
+ publish_with_headers = getattr(app, "publish_with_headers", None)
258
+ if headers and callable(publish_with_headers):
259
+ message_id = publish_with_headers(
260
+ urn, data, headers, queue=queue, trace_id=trace_id
261
+ )
262
+ else:
263
+ message_id = app.publish(urn, data, queue=queue, trace_id=trace_id)
264
+ span.set_attribute("messaging.message.id", message_id)
265
+ return message_id