babelqueue 1.7.0__tar.gz → 1.8.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 (67) hide show
  1. {babelqueue-1.7.0 → babelqueue-1.8.0}/.github/workflows/ci.yml +3 -3
  2. {babelqueue-1.7.0 → babelqueue-1.8.0}/PKG-INFO +3 -1
  3. {babelqueue-1.7.0 → babelqueue-1.8.0}/pyproject.toml +2 -1
  4. babelqueue-1.8.0/src/babelqueue/otel.py +176 -0
  5. babelqueue-1.8.0/tests/test_otel.py +131 -0
  6. {babelqueue-1.7.0 → babelqueue-1.8.0}/.github/FUNDING.yml +0 -0
  7. {babelqueue-1.7.0 → babelqueue-1.8.0}/.github/workflows/release.yml +0 -0
  8. {babelqueue-1.7.0 → babelqueue-1.8.0}/.gitignore +0 -0
  9. {babelqueue-1.7.0 → babelqueue-1.8.0}/CHANGELOG.md +0 -0
  10. {babelqueue-1.7.0 → babelqueue-1.8.0}/LICENSE +0 -0
  11. {babelqueue-1.7.0 → babelqueue-1.8.0}/README.md +0 -0
  12. {babelqueue-1.7.0 → babelqueue-1.8.0}/src/babelqueue/__init__.py +0 -0
  13. {babelqueue-1.7.0 → babelqueue-1.8.0}/src/babelqueue/app.py +0 -0
  14. {babelqueue-1.7.0 → babelqueue-1.8.0}/src/babelqueue/artemis_transport.py +0 -0
  15. {babelqueue-1.7.0 → babelqueue-1.8.0}/src/babelqueue/asb_transport.py +0 -0
  16. {babelqueue-1.7.0 → babelqueue-1.8.0}/src/babelqueue/celery.py +0 -0
  17. {babelqueue-1.7.0 → babelqueue-1.8.0}/src/babelqueue/codec.py +0 -0
  18. {babelqueue-1.7.0 → babelqueue-1.8.0}/src/babelqueue/contracts.py +0 -0
  19. {babelqueue-1.7.0 → babelqueue-1.8.0}/src/babelqueue/dead_letter.py +0 -0
  20. {babelqueue-1.7.0 → babelqueue-1.8.0}/src/babelqueue/django/__init__.py +0 -0
  21. {babelqueue-1.7.0 → babelqueue-1.8.0}/src/babelqueue/django/apps.py +0 -0
  22. {babelqueue-1.7.0 → babelqueue-1.8.0}/src/babelqueue/django/management/__init__.py +0 -0
  23. {babelqueue-1.7.0 → babelqueue-1.8.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
  24. {babelqueue-1.7.0 → babelqueue-1.8.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
  25. {babelqueue-1.7.0 → babelqueue-1.8.0}/src/babelqueue/exceptions.py +0 -0
  26. {babelqueue-1.7.0 → babelqueue-1.8.0}/src/babelqueue/idempotency.py +0 -0
  27. {babelqueue-1.7.0 → babelqueue-1.8.0}/src/babelqueue/kafka_transport.py +0 -0
  28. {babelqueue-1.7.0 → babelqueue-1.8.0}/src/babelqueue/pika_transport.py +0 -0
  29. {babelqueue-1.7.0 → babelqueue-1.8.0}/src/babelqueue/pulsar_transport.py +0 -0
  30. {babelqueue-1.7.0 → babelqueue-1.8.0}/src/babelqueue/py.typed +0 -0
  31. {babelqueue-1.7.0 → babelqueue-1.8.0}/src/babelqueue/redis_transport.py +0 -0
  32. {babelqueue-1.7.0 → babelqueue-1.8.0}/src/babelqueue/routing.py +0 -0
  33. {babelqueue-1.7.0 → babelqueue-1.8.0}/src/babelqueue/schema.py +0 -0
  34. {babelqueue-1.7.0 → babelqueue-1.8.0}/src/babelqueue/sqs_transport.py +0 -0
  35. {babelqueue-1.7.0 → babelqueue-1.8.0}/src/babelqueue/transport.py +0 -0
  36. {babelqueue-1.7.0 → babelqueue-1.8.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
  37. {babelqueue-1.7.0 → babelqueue-1.8.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
  38. {babelqueue-1.7.0 → babelqueue-1.8.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
  39. {babelqueue-1.7.0 → babelqueue-1.8.0}/tests/conformance/fixtures/order-created.json +0 -0
  40. {babelqueue-1.7.0 → babelqueue-1.8.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
  41. {babelqueue-1.7.0 → babelqueue-1.8.0}/tests/conformance/fixtures/urn-alias.json +0 -0
  42. {babelqueue-1.7.0 → babelqueue-1.8.0}/tests/conformance/manifest.json +0 -0
  43. {babelqueue-1.7.0 → babelqueue-1.8.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
  44. {babelqueue-1.7.0 → babelqueue-1.8.0}/tests/fixtures/dead-lettered.json +0 -0
  45. {babelqueue-1.7.0 → babelqueue-1.8.0}/tests/fixtures/order-created.json +0 -0
  46. {babelqueue-1.7.0 → babelqueue-1.8.0}/tests/test_app.py +0 -0
  47. {babelqueue-1.7.0 → babelqueue-1.8.0}/tests/test_artemis_conformance.py +0 -0
  48. {babelqueue-1.7.0 → babelqueue-1.8.0}/tests/test_artemis_transport.py +0 -0
  49. {babelqueue-1.7.0 → babelqueue-1.8.0}/tests/test_asb_conformance.py +0 -0
  50. {babelqueue-1.7.0 → babelqueue-1.8.0}/tests/test_asb_transport.py +0 -0
  51. {babelqueue-1.7.0 → babelqueue-1.8.0}/tests/test_celery.py +0 -0
  52. {babelqueue-1.7.0 → babelqueue-1.8.0}/tests/test_codec.py +0 -0
  53. {babelqueue-1.7.0 → babelqueue-1.8.0}/tests/test_conformance.py +0 -0
  54. {babelqueue-1.7.0 → babelqueue-1.8.0}/tests/test_dead_letter.py +0 -0
  55. {babelqueue-1.7.0 → babelqueue-1.8.0}/tests/test_django.py +0 -0
  56. {babelqueue-1.7.0 → babelqueue-1.8.0}/tests/test_idempotency.py +0 -0
  57. {babelqueue-1.7.0 → babelqueue-1.8.0}/tests/test_kafka_conformance.py +0 -0
  58. {babelqueue-1.7.0 → babelqueue-1.8.0}/tests/test_kafka_transport.py +0 -0
  59. {babelqueue-1.7.0 → babelqueue-1.8.0}/tests/test_overhead.py +0 -0
  60. {babelqueue-1.7.0 → babelqueue-1.8.0}/tests/test_pika_transport.py +0 -0
  61. {babelqueue-1.7.0 → babelqueue-1.8.0}/tests/test_pulsar_conformance.py +0 -0
  62. {babelqueue-1.7.0 → babelqueue-1.8.0}/tests/test_pulsar_transport.py +0 -0
  63. {babelqueue-1.7.0 → babelqueue-1.8.0}/tests/test_redis_transport.py +0 -0
  64. {babelqueue-1.7.0 → babelqueue-1.8.0}/tests/test_schema.py +0 -0
  65. {babelqueue-1.7.0 → babelqueue-1.8.0}/tests/test_schema_conformance.py +0 -0
  66. {babelqueue-1.7.0 → babelqueue-1.8.0}/tests/test_sqs_conformance.py +0 -0
  67. {babelqueue-1.7.0 → babelqueue-1.8.0}/tests/test_sqs_transport.py +0 -0
@@ -27,7 +27,7 @@ jobs:
27
27
  - name: Install (with Celery + Django adapters)
28
28
  run: |
29
29
  python -m pip install --upgrade pip
30
- pip install -e ".[dev,celery,django]"
30
+ pip install -e ".[dev,celery,django,otel]" opentelemetry-sdk
31
31
 
32
32
  - name: Run tests
33
33
  run: pytest
@@ -44,7 +44,7 @@ jobs:
44
44
  - name: Install (dev + all adapters for type context)
45
45
  run: |
46
46
  python -m pip install --upgrade pip
47
- pip install -e ".[dev,celery,django,redis,amqp,sqs,azureservicebus,artemis]"
47
+ pip install -e ".[dev,celery,django,redis,amqp,sqs,azureservicebus,artemis,otel]" opentelemetry-sdk
48
48
  - name: Ruff
49
49
  run: ruff check src tests
50
50
  - name: Mypy
@@ -89,7 +89,7 @@ jobs:
89
89
  - name: Install (all adapters — full coverage with brokers)
90
90
  run: |
91
91
  python -m pip install --upgrade pip
92
- pip install -e ".[redis,amqp,sqs,azureservicebus,artemis,celery,django,dev]"
92
+ pip install -e ".[redis,amqp,sqs,azureservicebus,artemis,celery,django,dev,otel]" opentelemetry-sdk
93
93
 
94
94
  - name: Wait for ElasticMQ
95
95
  run: |
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: babelqueue
3
- Version: 1.7.0
3
+ Version: 1.8.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
@@ -40,6 +40,8 @@ Provides-Extra: django
40
40
  Requires-Dist: django>=4.2; extra == 'django'
41
41
  Provides-Extra: kafka
42
42
  Requires-Dist: confluent-kafka>=2.3; extra == 'kafka'
43
+ Provides-Extra: otel
44
+ Requires-Dist: opentelemetry-api>=1.20; extra == 'otel'
43
45
  Provides-Extra: pulsar
44
46
  Requires-Dist: pulsar-client>=3.4; extra == 'pulsar'
45
47
  Provides-Extra: redis
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "babelqueue"
7
- version = "1.7.0"
7
+ version = "1.8.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"
@@ -36,6 +36,7 @@ azureservicebus = ["azure-servicebus>=7.11", "azure-identity>=1.15"]
36
36
  pulsar = ["pulsar-client>=3.4"]
37
37
  kafka = ["confluent-kafka>=2.3"]
38
38
  artemis = ["python-qpid-proton>=0.39"]
39
+ otel = ["opentelemetry-api>=1.20"]
39
40
  celery = ["celery>=5"]
40
41
  django = ["django>=4.2"]
41
42
  dev = ["pytest>=7", "pytest-cov>=4", "mypy>=1.8", "ruff>=0.5"]
@@ -0,0 +1,176 @@
1
+ """Optional OpenTelemetry tracing (ADR-0025): produce/consume spans correlated across hops.
2
+
3
+ The Python mirror of the Go ``babelqueue-go/otel`` module. It emits a CONSUMER span per
4
+ handled message and a PRODUCER span per publish, correlating them across every hop and SDK
5
+ through the envelope's ``trace_id`` — a UUID, which maps 1:1 to a 128-bit OTel trace id. The
6
+ wire envelope is untouched (GR-1) and the core never imports OpenTelemetry: this module is
7
+ only importable with the ``[otel]`` extra (``pip install babelqueue[otel]``), exactly like the
8
+ optional transport drivers.
9
+
10
+ from opentelemetry import trace
11
+ from babelqueue import BabelQueue, otel
12
+
13
+ tracer = trace.get_tracer("orders")
14
+ app = BabelQueue("redis://localhost:6379/0", queue="orders")
15
+ app.register("urn:babel:orders:created", otel.wrap_handler(tracer, on_order_created))
16
+ # producer side:
17
+ otel.publish(tracer, app, "urn:babel:orders:created", {"order_id": 1042})
18
+
19
+ Every hop that shares a ``trace_id`` shares one OTel trace. Exact cross-hop *span*
20
+ parent-child linkage (W3C ``traceparent`` as a transport header) is a documented follow-up.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import hashlib
26
+ import inspect
27
+ from typing import Any, Callable, Mapping, Optional
28
+
29
+ from opentelemetry.context import Context
30
+ from opentelemetry.trace import (
31
+ NonRecordingSpan,
32
+ SpanContext,
33
+ SpanKind,
34
+ TraceFlags,
35
+ Tracer,
36
+ set_span_in_context,
37
+ )
38
+
39
+ Handler = Callable[..., None]
40
+
41
+ _SYSTEM = "babelqueue"
42
+ _MASK_128 = (1 << 128) - 1
43
+
44
+
45
+ def trace_id_of(trace_id: str) -> int:
46
+ """Map an envelope ``trace_id`` to a deterministic 128-bit OTel trace id.
47
+
48
+ A UUID maps to its 16 raw bytes; any other string is hashed (SHA-256, first 16 bytes).
49
+ The result is never zero (OTel's invalid trace id). The inverse of :func:`uuid_of` for
50
+ the UUID case.
51
+ """
52
+ raw = _uuid_bytes(trace_id)
53
+ if raw is not None:
54
+ n = int.from_bytes(raw, "big")
55
+ if n != 0:
56
+ return n
57
+ digest = hashlib.sha256(trace_id.encode("utf-8")).digest()[:16]
58
+ return int.from_bytes(digest, "big") or 1
59
+
60
+
61
+ def uuid_of(trace_id_int: int) -> str:
62
+ """Format a 128-bit OTel trace id as a canonical UUID string.
63
+
64
+ The form a producer stamps into the message's ``trace_id`` so a consumer can recover the
65
+ same trace id via :func:`trace_id_of`.
66
+ """
67
+ h = format(trace_id_int & _MASK_128, "032x")
68
+ return f"{h[0:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:32]}"
69
+
70
+
71
+ def _uuid_bytes(s: str) -> Optional[bytes]:
72
+ h = s.replace("-", "")
73
+ if len(h) != 32:
74
+ return None
75
+ try:
76
+ return bytes.fromhex(h)
77
+ except ValueError:
78
+ return None
79
+
80
+
81
+ def _span_id_of(trace_id: str) -> int:
82
+ """Derive a deterministic, non-zero 64-bit span id so the remote parent context is valid
83
+ (a span needs a valid parent to inherit a specific trace)."""
84
+ digest = hashlib.sha256(("babelqueue-span:" + trace_id).encode("utf-8")).digest()[:8]
85
+ return int.from_bytes(digest, "big") or 1
86
+
87
+
88
+ def _parent_context(trace_id: str) -> Context:
89
+ sc = SpanContext(
90
+ trace_id=trace_id_of(trace_id),
91
+ span_id=_span_id_of(trace_id),
92
+ is_remote=True,
93
+ trace_flags=TraceFlags(TraceFlags.SAMPLED),
94
+ )
95
+ return set_span_in_context(NonRecordingSpan(sc))
96
+
97
+
98
+ def _wants_envelope(fn: Handler) -> bool:
99
+ """True if ``fn`` takes a 3rd positional arg (the full envelope) or ``*args``."""
100
+ try:
101
+ params = list(inspect.signature(fn).parameters.values())
102
+ except (TypeError, ValueError): # pragma: no cover - builtins / C callables
103
+ return False
104
+ positional = [
105
+ p for p in params if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
106
+ ]
107
+ return any(p.kind == p.VAR_POSITIONAL for p in params) or len(positional) >= 3
108
+
109
+
110
+ def wrap_handler(tracer: Tracer, handler: Handler) -> Handler:
111
+ """Wrap a consume handler to emit a CONSUMER span per message, in the OTel trace derived
112
+ from the envelope's ``trace_id``.
113
+
114
+ Register it like any handler: ``app.register(urn, wrap_handler(tracer, handler))``. The
115
+ wrapper's ``*args`` signature makes the runtime pass the full envelope (``data, meta,
116
+ envelope``), so it can read ``trace_id``/``job`` even when the inner handler only wants
117
+ ``(data, meta)``. A raising handler records the exception on the span and re-raises, so the
118
+ runtime's retry / dead-letter path still applies.
119
+ """
120
+
121
+ def wrapped(*args: Any) -> None:
122
+ envelope = args[2] if len(args) > 2 and isinstance(args[2], Mapping) else {}
123
+ meta = args[1] if len(args) > 1 and isinstance(args[1], Mapping) else {}
124
+ trace_id = str(envelope.get("trace_id") or "")
125
+ urn = str(envelope.get("job") or envelope.get("urn") or "")
126
+
127
+ attributes: dict[str, Any] = {
128
+ "messaging.system": _SYSTEM,
129
+ "messaging.operation": "process",
130
+ "messaging.destination.name": str(meta.get("queue") or ""),
131
+ "messaging.message.id": str(meta.get("id") or ""),
132
+ "messaging.message.conversation_id": trace_id,
133
+ "messaging.babelqueue.attempts": int(envelope.get("attempts", 0) or 0),
134
+ }
135
+ context = _parent_context(trace_id) if trace_id else None
136
+
137
+ with tracer.start_as_current_span(
138
+ "process " + urn,
139
+ context=context,
140
+ kind=SpanKind.CONSUMER,
141
+ attributes=attributes,
142
+ ):
143
+ if _wants_envelope(handler):
144
+ handler(*args)
145
+ else:
146
+ handler(args[0], args[1])
147
+
148
+ return wrapped
149
+
150
+
151
+ def publish(
152
+ tracer: Tracer,
153
+ app: Any,
154
+ urn: str,
155
+ data: Mapping[str, Any],
156
+ *,
157
+ queue: Optional[str] = None,
158
+ ) -> str:
159
+ """Publish via a PRODUCER span ``publish <urn>``, carrying the active trace's id into the
160
+ message's ``trace_id`` so the downstream consumer recovers the same trace.
161
+
162
+ Behaves like ``app.publish`` (returns the message id); ``app`` is any object exposing
163
+ ``publish(urn, data, *, queue=None, trace_id=None) -> str``.
164
+ """
165
+ attributes = {
166
+ "messaging.system": _SYSTEM,
167
+ "messaging.operation": "publish",
168
+ "messaging.destination.name": urn,
169
+ }
170
+ with tracer.start_as_current_span(
171
+ "publish " + urn, kind=SpanKind.PRODUCER, attributes=attributes
172
+ ) as span:
173
+ trace_id = uuid_of(span.get_span_context().trace_id)
174
+ message_id = app.publish(urn, data, queue=queue, trace_id=trace_id)
175
+ span.set_attribute("messaging.message.id", message_id)
176
+ return message_id
@@ -0,0 +1,131 @@
1
+ """Tests for the optional OpenTelemetry module (ADR-0025).
2
+
3
+ Skipped when OpenTelemetry is not installed (the ``[otel]`` extra); CI installs it so these
4
+ run and count toward coverage.
5
+ """
6
+
7
+ import pytest
8
+
9
+ pytest.importorskip("opentelemetry")
10
+
11
+ from opentelemetry.sdk.trace import TracerProvider # noqa: E402
12
+ from opentelemetry.sdk.trace.export import SimpleSpanProcessor # noqa: E402
13
+ from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( # noqa: E402
14
+ InMemorySpanExporter,
15
+ )
16
+ from opentelemetry.trace import SpanKind # noqa: E402
17
+
18
+ from babelqueue import otel # noqa: E402
19
+
20
+ _TRACE_ID = "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b"
21
+
22
+
23
+ def _recorder():
24
+ exporter = InMemorySpanExporter()
25
+ provider = TracerProvider()
26
+ provider.add_span_processor(SimpleSpanProcessor(exporter))
27
+ return provider.get_tracer("test"), exporter
28
+
29
+
30
+ def _envelope(trace_id=_TRACE_ID, attempts=0):
31
+ return {
32
+ "job": "urn:babel:orders:created",
33
+ "trace_id": trace_id,
34
+ "data": {"order_id": 1},
35
+ "meta": {"id": "m1", "queue": "orders"},
36
+ "attempts": attempts,
37
+ }
38
+
39
+
40
+ class FakeApp:
41
+ """An object exposing publish(urn, data, *, queue, trace_id) -> str, recording calls."""
42
+
43
+ def __init__(self):
44
+ self.calls = []
45
+
46
+ def publish(self, urn, data, *, queue=None, trace_id=None):
47
+ self.calls.append({"urn": urn, "data": data, "queue": queue, "trace_id": trace_id})
48
+ return "msg-123"
49
+
50
+
51
+ def test_trace_id_round_trip():
52
+ tid = otel.trace_id_of(_TRACE_ID)
53
+ assert tid != 0
54
+ assert otel.uuid_of(tid) == _TRACE_ID
55
+ # a non-uuid trace_id maps deterministically to a valid, distinct trace id
56
+ assert otel.trace_id_of("not-a-uuid") == otel.trace_id_of("not-a-uuid")
57
+ assert otel.trace_id_of("not-a-uuid") != 0
58
+ assert otel.trace_id_of("not-a-uuid") != tid
59
+ # 32 chars but not hex → not a UUID, so it is hashed
60
+ assert otel.trace_id_of("z" * 32) != 0
61
+
62
+
63
+ def test_wrap_handler_span_in_trace_with_attrs():
64
+ tracer, exporter = _recorder()
65
+ seen = {}
66
+
67
+ def handler(data, meta, envelope):
68
+ seen["called"] = True
69
+
70
+ env = _envelope()
71
+ otel.wrap_handler(tracer, handler)(env["data"], env["meta"], env)
72
+
73
+ assert seen.get("called")
74
+ spans = exporter.get_finished_spans()
75
+ assert len(spans) == 1
76
+ span = spans[0]
77
+ assert span.name == "process urn:babel:orders:created"
78
+ assert span.kind == SpanKind.CONSUMER
79
+ assert span.context.trace_id == otel.trace_id_of(_TRACE_ID)
80
+ assert span.attributes["messaging.message.conversation_id"] == _TRACE_ID
81
+ assert span.attributes["messaging.message.id"] == "m1"
82
+ assert span.attributes["messaging.destination.name"] == "orders"
83
+
84
+
85
+ def test_wrap_handler_two_arg_inner():
86
+ tracer, exporter = _recorder()
87
+ got = {}
88
+
89
+ def handler(data, meta): # only two positional args
90
+ got["data"] = data
91
+
92
+ env = _envelope()
93
+ # the runtime passes (data, meta, envelope); the wrapper forwards only what the inner wants
94
+ otel.wrap_handler(tracer, handler)(env["data"], env["meta"], env)
95
+
96
+ assert got["data"] == env["data"]
97
+ assert len(exporter.get_finished_spans()) == 1
98
+
99
+
100
+ def test_wrap_handler_records_error():
101
+ tracer, exporter = _recorder()
102
+
103
+ def handler(data, meta, envelope):
104
+ raise ValueError("boom")
105
+
106
+ env = _envelope()
107
+ with pytest.raises(ValueError):
108
+ otel.wrap_handler(tracer, handler)(env["data"], env["meta"], env)
109
+
110
+ span = exporter.get_finished_spans()[0]
111
+ assert span.status.status_code.name == "ERROR"
112
+ assert len(span.events) >= 1 # the recorded exception
113
+
114
+
115
+ def test_publish_stamps_trace_id_from_span():
116
+ tracer, exporter = _recorder()
117
+ app = FakeApp()
118
+
119
+ message_id = otel.publish(tracer, app, "urn:babel:orders:created", {"order_id": 7})
120
+
121
+ assert message_id == "msg-123"
122
+ spans = exporter.get_finished_spans()
123
+ assert len(spans) == 1
124
+ span = spans[0]
125
+ assert span.kind == SpanKind.PRODUCER
126
+ assert span.attributes["messaging.message.id"] == "msg-123"
127
+
128
+ stamped = app.calls[0]["trace_id"]
129
+ # the published trace_id encodes the producer span's trace, so a consumer recovers it
130
+ assert stamped == otel.uuid_of(span.context.trace_id)
131
+ assert otel.trace_id_of(stamped) == span.context.trace_id
File without changes
File without changes
File without changes
File without changes
File without changes