babelqueue 1.10.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.
- {babelqueue-1.10.0 → babelqueue-1.11.0}/CHANGELOG.md +25 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/PKG-INFO +37 -1
- {babelqueue-1.10.0 → babelqueue-1.11.0}/README.md +36 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/pyproject.toml +1 -1
- {babelqueue-1.10.0 → babelqueue-1.11.0}/src/babelqueue/__init__.py +5 -2
- {babelqueue-1.10.0 → babelqueue-1.11.0}/src/babelqueue/app.py +43 -3
- babelqueue-1.11.0/src/babelqueue/headers.py +70 -0
- babelqueue-1.11.0/src/babelqueue/otel.py +265 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/src/babelqueue/pika_transport.py +46 -10
- {babelqueue-1.10.0 → babelqueue-1.11.0}/src/babelqueue/redis_transport.py +103 -2
- {babelqueue-1.10.0 → babelqueue-1.11.0}/src/babelqueue/sqs_transport.py +60 -2
- babelqueue-1.11.0/tests/test_headers.py +112 -0
- babelqueue-1.11.0/tests/test_otel.py +263 -0
- babelqueue-1.11.0/tests/test_pika_transport.py +195 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/tests/test_redis_transport.py +110 -2
- {babelqueue-1.10.0 → babelqueue-1.11.0}/tests/test_sqs_transport.py +69 -1
- babelqueue-1.10.0/src/babelqueue/otel.py +0 -176
- babelqueue-1.10.0/tests/test_otel.py +0 -131
- babelqueue-1.10.0/tests/test_pika_transport.py +0 -98
- {babelqueue-1.10.0 → babelqueue-1.11.0}/.github/FUNDING.yml +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/.github/workflows/ci.yml +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/.github/workflows/release.yml +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/.gitignore +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/LICENSE +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/src/babelqueue/artemis_transport.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/src/babelqueue/asb_transport.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/src/babelqueue/celery.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/src/babelqueue/codec.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/src/babelqueue/contracts.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/src/babelqueue/dead_letter.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/src/babelqueue/django/__init__.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/src/babelqueue/django/apps.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/src/babelqueue/django/management/__init__.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/src/babelqueue/exceptions.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/src/babelqueue/idempotency.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/src/babelqueue/kafka_transport.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/src/babelqueue/pulsar_transport.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/src/babelqueue/py.typed +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/src/babelqueue/redrive.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/src/babelqueue/replay.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/src/babelqueue/routing.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/src/babelqueue/schema.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/src/babelqueue/transport.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/tests/conformance/fixtures/order-created.json +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/tests/conformance/fixtures/urn-alias.json +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/tests/conformance/manifest.json +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/tests/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/tests/fixtures/order-created.json +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/tests/test_app.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/tests/test_artemis_conformance.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/tests/test_artemis_transport.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/tests/test_asb_conformance.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/tests/test_asb_transport.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/tests/test_celery.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/tests/test_codec.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/tests/test_conformance.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/tests/test_dead_letter.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/tests/test_django.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/tests/test_idempotency.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/tests/test_kafka_conformance.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/tests/test_kafka_transport.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/tests/test_overhead.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/tests/test_pulsar_conformance.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/tests/test_pulsar_transport.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/tests/test_redrive.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/tests/test_replay.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/tests/test_schema.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.11.0}/tests/test_schema_conformance.py +0 -0
- {babelqueue-1.10.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.
|
|
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.
|
|
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,17 +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, replay
|
|
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
|
|
20
21
|
from .replay import HEADER_REPLAY_BYPASS, bypass_external_effects, is_replay
|
|
21
22
|
from .routing import UnknownUrnStrategy
|
|
22
23
|
from .transport import HeaderPublisher, InMemoryTransport, ReceivedMessage, Transport
|
|
23
24
|
|
|
24
|
-
__version__ = "1.
|
|
25
|
+
__version__ = "1.11.0"
|
|
25
26
|
|
|
26
27
|
__all__ = [
|
|
27
28
|
"BabelQueue",
|
|
@@ -38,11 +39,13 @@ __all__ = [
|
|
|
38
39
|
"BabelQueueError",
|
|
39
40
|
"UnknownUrnError",
|
|
40
41
|
"dead_letter",
|
|
42
|
+
"headers",
|
|
41
43
|
"idempotency",
|
|
42
44
|
"redrive",
|
|
43
45
|
"replay",
|
|
44
46
|
"is_replay",
|
|
45
47
|
"bypass_external_effects",
|
|
48
|
+
"headers_from_context",
|
|
46
49
|
"HEADER_REPLAY_BYPASS",
|
|
47
50
|
"IdempotencyStore",
|
|
48
51
|
"InMemoryStore",
|
|
@@ -25,9 +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
|
|
28
29
|
from .replay import HEADER_REPLAY_BYPASS, _replay_scope
|
|
29
30
|
from .routing import UnknownUrnStrategy
|
|
30
|
-
from .transport import ReceivedMessage, Transport, make_transport
|
|
31
|
+
from .transport import HeaderPublisher, ReceivedMessage, Transport, make_transport
|
|
31
32
|
|
|
32
33
|
Handler = Callable[..., None]
|
|
33
34
|
|
|
@@ -70,6 +71,37 @@ class BabelQueue:
|
|
|
70
71
|
self.transport.publish(target, EnvelopeCodec.encode(envelope))
|
|
71
72
|
return envelope["meta"]["id"]
|
|
72
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
|
+
|
|
73
105
|
# -- Register handlers --------------------------------------------------
|
|
74
106
|
|
|
75
107
|
def handler(self, urn: str) -> Callable[[Handler], Handler]:
|
|
@@ -116,8 +148,16 @@ class BabelQueue:
|
|
|
116
148
|
run = consume
|
|
117
149
|
|
|
118
150
|
def dispatch(self, received: ReceivedMessage) -> None:
|
|
119
|
-
"""Route one reserved message to its handler and acknowledge it.
|
|
120
|
-
|
|
151
|
+
"""Route one reserved message to its handler and acknowledge it.
|
|
152
|
+
|
|
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
|
+
):
|
|
121
161
|
envelope = EnvelopeCodec.decode(received.body)
|
|
122
162
|
urn = str(envelope.get("job") or envelope.get("urn") or "")
|
|
123
163
|
handler = self._handlers.get(urn) if urn else None
|
|
@@ -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
|