Skip to content

Commit 699f015

Browse files
committed
feat(otel): carry W3C traceparent via SQS MessageAttributes (ADR-0028)
Implements the babelqueue-core 1.5.0 header seam so traceparent rides the broker beside the frozen envelope (GR-1), merge-not-clobber + (Redis) bare-value back- compat; no header falls back to the v0.1 trace_id mapping. Core dep bumped to 1.5.0; package bumped to 1.1.0.
1 parent 3525438 commit 699f015

7 files changed

Lines changed: 391 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,26 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
77
The envelope wire format is versioned separately by `meta.schema_version`
88
(currently **1**) — see the contract at [babelqueue.com](https://babelqueue.com).
99

10+
## [Unreleased]
11+
12+
### Added
13+
- **OpenTelemetry `traceparent` transport wiring (ADR-0028, v0.2).** `SqsPublisher`
14+
gains `publishWithHeaders(Envelope, Map<String,String>)` — the produce-side seam the
15+
optional core `com.babelqueue.otel.HeaderSender` wires to: out-of-band headers (e.g. a
16+
W3C `traceparent`) ride on the SQS `MessageAttributes` channel **beside** the contract
17+
`bq-*` attributes (`SqsAttributes.projectWithHeaders`: the contract projection wins a
18+
key collision, merge in sorted order, bounded by the SQS 10-attribute cap), never inside
19+
the frozen envelope (GR-1). New `SqsHeaders.of(Message)` surfaces a delivered message's
20+
attributes as a `Map<String,String>`, the consume-side seam for
21+
`Tracing.wrapHandler(tracer, handler, Supplier)`, so a carried `traceparent` makes the
22+
consumer span a true child of the producer span. A header-less publish is byte-identical
23+
to before; `trace_id` is preserved (GR-4); `schema_version` stays **1**. No new runtime
24+
dependency — the header seam is a plain `Map<String,String>`; OpenTelemetry is needed
25+
only by callers who opt in.
26+
27+
### Changed
28+
- Require `com.babelqueue:babelqueue-core 1.5.0` (the out-of-band header-carrier seam).
29+
1030
## [1.0.0] - 2026-06-12
1131

1232
### Added

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,32 @@ redelivers it after the visibility timeout (at-least-once). The poll loop never
6363
a bad message — observe via `onError` / `onUnknownUrn`. The envelope is unchanged
6464
(`schema_version` stays `1`); SQS is purely additive.
6565

66+
## Trace propagation (OpenTelemetry `traceparent`, ADR-0028)
67+
68+
The optional core `com.babelqueue.otel` module can carry a W3C `traceparent` so a
69+
consumer span becomes a true child of the producer span — propagated **out of band** on
70+
the SQS `MessageAttributes` channel, beside the contract `bq-*` attributes (a contract
71+
attribute always wins a key collision; bounded by the SQS 10-attribute cap), never inside
72+
the frozen envelope (GR-1).
73+
74+
```java
75+
// produce: HeaderSender -> SqsPublisher.publishWithHeaders
76+
SqsPublisher publisher = SqsPublisher.create(sqs, url);
77+
Tracing.publish(tracer, "urn:babel:orders:created", Map.of("order_id", 1042), "orders",
78+
(envelope, headers) -> publisher.publishWithHeaders(envelope, headers));
79+
80+
// consume: surface the delivered attributes for wrapHandler's Supplier
81+
SqsConsumer.builder(sqs, url)
82+
.handler("urn:babel:orders:created", (env, message) ->
83+
Tracing.wrapHandler(tracer, h, () -> SqsHeaders.of(message)).handle(env))
84+
.build();
85+
```
86+
87+
A header-less `publish(...)` is byte-identical to before; with no `traceparent` the
88+
consumer falls back to the v0.1 `trace_id`-derived parent. Requires `babelqueue-core`
89+
≥ 1.5.0. No OpenTelemetry dependency is needed unless you opt in — the seam is a plain
90+
`Map<String,String>`.
91+
6692
## Build & test
6793

6894
```bash

pom.xml

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
<groupId>com.babelqueue</groupId>
88
<artifactId>babelqueue-sqs</artifactId>
9-
<version>1.0.0</version>
9+
<version>1.1.0</version>
1010
<packaging>jar</packaging>
1111

1212
<name>BabelQueue Amazon SQS</name>
@@ -49,8 +49,9 @@
4949
<maven.compiler.release>17</maven.compiler.release>
5050
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
5151
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
52-
<babelqueue-core.version>1.0.0</babelqueue-core.version>
52+
<babelqueue-core.version>1.5.0</babelqueue-core.version>
5353
<aws-sdk.version>2.25.0</aws-sdk.version>
54+
<opentelemetry.version>1.45.0</opentelemetry.version>
5455
<junit.version>5.10.3</junit.version>
5556
</properties>
5657

@@ -63,6 +64,13 @@
6364
<type>pom</type>
6465
<scope>import</scope>
6566
</dependency>
67+
<dependency>
68+
<groupId>io.opentelemetry</groupId>
69+
<artifactId>opentelemetry-bom</artifactId>
70+
<version>${opentelemetry.version}</version>
71+
<type>pom</type>
72+
<scope>import</scope>
73+
</dependency>
6674
</dependencies>
6775
</dependencyManagement>
6876

@@ -92,6 +100,23 @@
92100
<version>20240303</version>
93101
<scope>test</scope>
94102
</dependency>
103+
<!--
104+
The core's OTel module is optional (not transitive), so the end-to-end
105+
traceparent cross-hop test (Tracing.publish HeaderSender -> wrapHandler
106+
Supplier over this transport) pulls the SDK + in-memory exporter in test scope.
107+
Runtime use of this transport needs no OTel dependency at all — the
108+
header seam is a plain Map<String,String>.
109+
-->
110+
<dependency>
111+
<groupId>io.opentelemetry</groupId>
112+
<artifactId>opentelemetry-sdk</artifactId>
113+
<scope>test</scope>
114+
</dependency>
115+
<dependency>
116+
<groupId>io.opentelemetry</groupId>
117+
<artifactId>opentelemetry-sdk-testing</artifactId>
118+
<scope>test</scope>
119+
</dependency>
95120
</dependencies>
96121

97122
<build>

src/main/java/com/babelqueue/sqs/SqsAttributes.java

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,28 @@
11
package com.babelqueue.sqs;
22

33
import com.babelqueue.Envelope;
4+
import java.util.ArrayList;
45
import java.util.LinkedHashMap;
6+
import java.util.List;
57
import java.util.Map;
68
import software.amazon.awssdk.services.sqs.model.MessageAttributeValue;
79

810
/**
911
* Projects the envelope's contract fields onto native SQS {@code MessageAttributes} —
1012
* a redundant, routable view of the body (the body stays authoritative). Contract §3.2.
13+
*
14+
* <p>It also carries out-of-band transport headers (e.g. the W3C {@code traceparent},
15+
* ADR-0028) <b>beside</b> the contract {@code bq-*} attributes on the same channel
16+
* {@code bq-trace-id} already rides — never inside the frozen envelope (GR-1). The
17+
* contract projection always wins a key collision, and the merge stops at the SQS
18+
* 10-attribute ceiling so unbounded out-of-band headers can never crowd out the
19+
* contract attributes.
1120
*/
1221
final class SqsAttributes {
1322

23+
/** The SQS per-message cap on user {@code MessageAttributes}. */
24+
static final int MAX_ATTRIBUTES = 10;
25+
1426
private SqsAttributes() {}
1527

1628
static Map<String, MessageAttributeValue> project(Envelope envelope) {
@@ -26,6 +38,61 @@ static Map<String, MessageAttributeValue> project(Envelope envelope) {
2638
return attrs;
2739
}
2840

41+
/**
42+
* Projects the contract attributes and overlays the out-of-band string {@code headers}
43+
* beside them — for {@link SqsPublisher#publishWithHeaders}. A blank key or value is
44+
* skipped; a contract {@code bq-*} key already present always wins (the header is
45+
* dropped, never clobbering it); and once the message reaches {@link #MAX_ATTRIBUTES}
46+
* no further header is added. Keys are merged in sorted order so the bounded subset is
47+
* deterministic. With a {@code null}/empty map this is byte-identical to {@link #project}.
48+
*/
49+
static Map<String, MessageAttributeValue> projectWithHeaders(
50+
Envelope envelope, Map<String, String> headers) {
51+
Map<String, MessageAttributeValue> attrs = project(envelope);
52+
if (headers == null || headers.isEmpty()) {
53+
return attrs;
54+
}
55+
List<String> keys = new ArrayList<>(headers.keySet());
56+
keys.sort(String::compareTo);
57+
for (String key : keys) {
58+
if (attrs.size() >= MAX_ATTRIBUTES) {
59+
break; // respect the SQS 10-attribute ceiling
60+
}
61+
if (key == null || key.isEmpty() || attrs.containsKey(key)) {
62+
continue; // contract attribute wins a collision; skip blank keys
63+
}
64+
String value = headers.get(key);
65+
if (value == null || value.isEmpty()) {
66+
continue;
67+
}
68+
attrs.put(key, MessageAttributeValue.builder().dataType("String").stringValue(value).build());
69+
}
70+
return attrs;
71+
}
72+
73+
/**
74+
* Surfaces a delivered message's {@code MessageAttributes} as a flat
75+
* {@code Map<String, String>} (the consume-side counterpart of
76+
* {@link #projectWithHeaders}), reading each attribute's {@code stringValue}. Both the
77+
* contract {@code bq-*} attributes and out-of-band headers (e.g. {@code traceparent})
78+
* surface; the consumer picks the keys it needs. Blank values are dropped; an empty or
79+
* {@code null} input yields an empty map.
80+
*/
81+
static Map<String, String> extract(Map<String, MessageAttributeValue> attrs) {
82+
Map<String, String> out = new LinkedHashMap<>();
83+
if (attrs == null) {
84+
return out;
85+
}
86+
for (Map.Entry<String, MessageAttributeValue> e : attrs.entrySet()) {
87+
MessageAttributeValue v = e.getValue();
88+
String value = v == null ? null : v.stringValue();
89+
if (value != null && !value.isEmpty()) {
90+
out.put(e.getKey(), value);
91+
}
92+
}
93+
return out;
94+
}
95+
2996
private static void putString(Map<String, MessageAttributeValue> attrs, String key, String value) {
3097
if (value != null && !value.isEmpty()) {
3198
attrs.put(key, MessageAttributeValue.builder().dataType("String").stringValue(value).build());
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package com.babelqueue.sqs;
2+
3+
import java.util.Map;
4+
import software.amazon.awssdk.services.sqs.model.Message;
5+
import software.amazon.awssdk.services.sqs.model.MessageAttributeValue;
6+
7+
/**
8+
* Surfaces a delivered SQS message's {@code MessageAttributes} as a flat
9+
* {@code Map<String, String>} — the consume-side counterpart of
10+
* {@link SqsPublisher#publishWithHeaders}.
11+
*
12+
* <p>It is the seam a consumer wires to the optional
13+
* {@code com.babelqueue.otel.Tracing#wrapHandler(io.opentelemetry.api.trace.Tracer,
14+
* com.babelqueue.idempotency.Handler, java.util.function.Supplier)} headers
15+
* {@code Supplier}, so a carried W3C {@code traceparent} (ADR-0028) makes the consumer
16+
* span a true child of the producer span:
17+
*
18+
* <pre>{@code
19+
* SqsConsumer.builder(sqs, url)
20+
* .handler(urn, (env, message) -> Tracing
21+
* .wrapHandler(tracer, h, () -> SqsHeaders.of(message))
22+
* .handle(env))
23+
* .build();
24+
* }</pre>
25+
*
26+
* <p>The consumer already requests {@code messageAttributeNames("All")}, so every
27+
* attribute — both the contract {@code bq-*} projection and any out-of-band header — is
28+
* delivered. Reading the headers requires no OpenTelemetry dependency; the map is a plain
29+
* {@code Map<String, String>}.
30+
*/
31+
public final class SqsHeaders {
32+
33+
private SqsHeaders() {}
34+
35+
/**
36+
* Returns the message's {@code MessageAttributes} as a {@code Map<String, String>}
37+
* (reading each attribute's {@code stringValue}; blank values dropped). An empty map
38+
* when the message carries no attributes.
39+
*/
40+
public static Map<String, String> of(Message message) {
41+
if (message == null) {
42+
return Map.of();
43+
}
44+
Map<String, MessageAttributeValue> attrs = message.messageAttributes();
45+
return SqsAttributes.extract(attrs);
46+
}
47+
}

src/main/java/com/babelqueue/sqs/SqsPublisher.java

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import java.util.Map;
66
import java.util.Objects;
77
import software.amazon.awssdk.services.sqs.SqsClient;
8+
import software.amazon.awssdk.services.sqs.model.MessageAttributeValue;
89
import software.amazon.awssdk.services.sqs.model.SendMessageRequest;
910

1011
/**
@@ -50,18 +51,37 @@ public String publish(String urn, Map<String, Object> data) {
5051
/** Publish, continuing an existing {@code traceId} (or {@code null} to mint a fresh one). */
5152
public String publish(String urn, Map<String, Object> data, String traceId) {
5253
Envelope envelope = EnvelopeCodec.make(urn, data, SqsQueues.nameFromUrl(queueUrl), traceId);
54+
send(envelope, SqsAttributes.project(envelope));
55+
return envelope.meta().id();
56+
}
57+
58+
/**
59+
* Publish an already-built {@code envelope} together with out-of-band transport
60+
* {@code headers} (e.g. a W3C {@code traceparent}, ADR-0028). The headers ride on the
61+
* SQS {@code MessageAttributes} channel <b>beside</b> the contract {@code bq-*}
62+
* attributes ({@link SqsAttributes#projectWithHeaders}: contract wins a collision,
63+
* bounded by the 10-attribute cap), never inside the frozen envelope (GR-1). This is
64+
* the produce-side seam the optional {@code com.babelqueue.otel.HeaderSender} wires to;
65+
* with no/empty headers it is byte-identical to {@link #publish}. Returns
66+
* {@code meta.id}.
67+
*/
68+
public String publishWithHeaders(Envelope envelope, Map<String, String> headers) {
69+
send(envelope, SqsAttributes.projectWithHeaders(envelope, headers));
70+
return envelope.meta().id();
71+
}
72+
73+
private void send(Envelope envelope, Map<String, MessageAttributeValue> attributes) {
5374
SendMessageRequest.Builder request = SendMessageRequest.builder()
5475
.queueUrl(queueUrl)
5576
.messageBody(EnvelopeCodec.encode(envelope))
56-
.messageAttributes(SqsAttributes.project(envelope));
77+
.messageAttributes(attributes);
5778
if (fifo) {
5879
request.messageGroupId(messageGroupId != null ? messageGroupId : SqsQueues.nameFromUrl(queueUrl));
5980
if (!contentDedup) {
6081
request.messageDeduplicationId(envelope.meta().id());
6182
}
6283
}
6384
client.sendMessage(request.build());
64-
return envelope.meta().id();
6585
}
6686

6787
/** Fluent builder for {@link SqsPublisher}. */

0 commit comments

Comments
 (0)