Skip to content

Commit 37a7785

Browse files
committed
feat(otel): carry W3C traceparent via a Redis __bq_frame (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 ad9ff5f commit 37a7785

10 files changed

Lines changed: 843 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,31 @@ 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).** Redis lists have no
14+
native per-message metadata channel and the reliable-queue stores the bare body (the
15+
`LREM` ack handle *is* the stored value), so to carry an out-of-band header (e.g. a W3C
16+
`traceparent`) beside the frozen envelope the transport now uses a self-contained,
17+
transport-owned JSON frame distinct from the wire envelope:
18+
`{"__bq_frame":1,"headers":…,"body":<raw envelope>}` (`RedisFrame`, mirroring the Go/PHP
19+
`__bq_frame`). `RedisPublisher.publishWithHeaders(Envelope, Map<String,String>)` is the
20+
produce-side seam the optional core `com.babelqueue.otel.HeaderSender` wires to: with
21+
usable headers it `RPUSH`es the frame; with none it `RPUSH`es the bare envelope
22+
byte-for-byte (back-compat). `RedisConsumer` transparently unframes a reserved value —
23+
bare-value back-compat detection via the reserved `__bq_frame` sentinel (a frozen
24+
envelope never carries it), the `LREM` ack handle stays the stored value — and surfaces
25+
the headers to a new header-aware `RedisConsumer.HeaderHandler` (`(envelope, body,
26+
headers)`), the consume-side seam for `Tracing.wrapHandler(tracer, handler, Supplier)`.
27+
The existing `(envelope, body)` `BabelHandler` registration is unchanged. The wire
28+
envelope is never touched (GR-1); `trace_id` is preserved (GR-4); `schema_version` stays
29+
**1**. No new runtime dependency — the frame codec is self-contained and the header seam
30+
is a plain `Map<String,String>` (GR-7).
31+
32+
### Changed
33+
- Require `com.babelqueue:babelqueue-core 1.5.0` (the out-of-band header-carrier seam).
34+
1035
## [1.0.0] - 2026-06-14
1136

1237
### Added

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,34 @@ The envelope is unchanged (`schema_version` stays `1`); Redis is purely additive
7575
> consumer reading a queue produced by the Laravel driver must replicate Laravel's
7676
> reserve/ack semantics.
7777
78+
## Trace propagation (OpenTelemetry `traceparent`, ADR-0028)
79+
80+
Redis lists carry no native metadata channel, so to propagate a W3C `traceparent` (out of
81+
band, beside the frozen envelope — GR-1) the transport wraps the bare envelope in a small,
82+
transport-owned JSON frame distinct from the wire envelope:
83+
`{"__bq_frame":1,"headers":…,"body":<raw envelope>}` (the same `__bq_frame` Go and PHP
84+
use). The optional core `com.babelqueue.otel` module drives it:
85+
86+
```java
87+
// produce: HeaderSender -> RedisPublisher.publishWithHeaders (frames only when headers exist)
88+
RedisPublisher publisher = RedisPublisher.create(redis, "orders");
89+
Tracing.publish(tracer, "urn:babel:orders:created", Map.of("order_id", 1042), "orders",
90+
(envelope, headers) -> publisher.publishWithHeaders(envelope, headers));
91+
92+
// consume: a header-aware handler receives the surfaced headers for wrapHandler's Supplier
93+
RedisConsumer.builder(redis, "orders")
94+
.handler("urn:babel:orders:created", (env, body, headers) ->
95+
Tracing.wrapHandler(tracer, h, () -> headers).handle(env))
96+
.build();
97+
```
98+
99+
A header-less `publish(...)` stores the bare envelope **byte-for-byte**; a bare
100+
(un-framed) value still consumes — the consumer detects a frame by the reserved
101+
`__bq_frame` sentinel (a frozen envelope never carries it), and the `LREM` ack handle
102+
stays the stored value, so cross-version queues interoperate. The existing
103+
`(env, body)` handler keeps working. Requires `babelqueue-core` ≥ 1.5.0; no new runtime
104+
dependency (the frame codec is self-contained, the seam is a plain `Map<String,String>`).
105+
78106
## Build & test
79107

80108
```bash

pom.xml

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

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

1212
<name>BabelQueue Redis</name>
@@ -49,12 +49,25 @@
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
<lettuce.version>6.5.5.RELEASE</lettuce.version>
54+
<opentelemetry.version>1.45.0</opentelemetry.version>
5455
<junit.version>5.10.3</junit.version>
5556
<mockito.version>5.12.0</mockito.version>
5657
</properties>
5758

59+
<dependencyManagement>
60+
<dependencies>
61+
<dependency>
62+
<groupId>io.opentelemetry</groupId>
63+
<artifactId>opentelemetry-bom</artifactId>
64+
<version>${opentelemetry.version}</version>
65+
<type>pom</type>
66+
<scope>import</scope>
67+
</dependency>
68+
</dependencies>
69+
</dependencyManagement>
70+
5871
<dependencies>
5972
<!-- The framework-agnostic core: the one codec + contracts. Transitive. -->
6073
<dependency>
@@ -89,6 +102,23 @@
89102
<version>20240303</version>
90103
<scope>test</scope>
91104
</dependency>
105+
<!--
106+
The core's OTel module is optional (not transitive), so the end-to-end
107+
traceparent cross-hop test (Tracing.publish HeaderSender -> wrapHandler
108+
Supplier over the __bq_frame) pulls the SDK + in-memory exporter in test scope.
109+
Runtime use of this transport needs no OTel dependency at all — the header
110+
seam is a plain Map<String,String> and the frame codec is self-contained.
111+
-->
112+
<dependency>
113+
<groupId>io.opentelemetry</groupId>
114+
<artifactId>opentelemetry-sdk</artifactId>
115+
<scope>test</scope>
116+
</dependency>
117+
<dependency>
118+
<groupId>io.opentelemetry</groupId>
119+
<artifactId>opentelemetry-sdk-testing</artifactId>
120+
<scope>test</scope>
121+
</dependency>
92122
</dependencies>
93123

94124
<build>
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package com.babelqueue.redis;
2+
3+
import java.util.LinkedHashMap;
4+
import java.util.Map;
5+
6+
/**
7+
* A minimal, dependency-free reader for the Redis transport's own {@code __bq_frame}
8+
* header frame (ADR-0028) — and nothing more. The frame is a flat JSON object whose values
9+
* are exactly: an integer (the {@code __bq_frame} version), a string (the {@code body},
10+
* the raw wire envelope), and a nested string→string object (the {@code headers}). This
11+
* parser supports only that shape; it is never used on the wire envelope, which the core
12+
* codec owns. Any input it cannot parse — including a perfectly valid but differently
13+
* shaped JSON document — yields {@code null}, so {@link RedisFrame#unframe} falls back to
14+
* treating the value as a bare envelope.
15+
*
16+
* <p>Keeping the grammar this narrow keeps the transport zero-dependency (GR-7) without
17+
* carrying a general-purpose JSON parser's surface.
18+
*/
19+
final class JsonLite {
20+
21+
private final String s;
22+
private int pos;
23+
24+
private JsonLite(String s) {
25+
this.s = s;
26+
}
27+
28+
/**
29+
* Parses {@code raw} as a frame object: a JSON object mapping string keys to a string,
30+
* an integer, or a nested string→string object. Returns the map, or {@code null} on any
31+
* deviation from that shape.
32+
*/
33+
static Map<String, Object> parseFrame(String raw) {
34+
try {
35+
JsonLite p = new JsonLite(raw);
36+
Map<String, Object> obj = p.parseObject(true);
37+
return p.atEnd() ? obj : null;
38+
} catch (RuntimeException e) {
39+
return null;
40+
}
41+
}
42+
43+
/** Parses an object; {@code topLevel} values may be string/int/object, nested values string only. */
44+
private Map<String, Object> parseObject(boolean topLevel) {
45+
expect('{');
46+
Map<String, Object> obj = new LinkedHashMap<>();
47+
if (peek() == '}') {
48+
pos++;
49+
return obj;
50+
}
51+
while (true) {
52+
String key = parseString();
53+
expect(':');
54+
obj.put(key, topLevel ? parseTopValue() : parseString());
55+
char c = next();
56+
if (c == '}') {
57+
return obj;
58+
}
59+
if (c != ',') {
60+
throw new IllegalStateException("expected , or }");
61+
}
62+
}
63+
}
64+
65+
/** A top-level frame value is a string, a non-negative integer, or a nested string→string object. */
66+
private Object parseTopValue() {
67+
char c = peek();
68+
if (c == '"') {
69+
return parseString();
70+
}
71+
if (c == '{') {
72+
return parseObject(false);
73+
}
74+
return parseInt();
75+
}
76+
77+
private String parseString() {
78+
expect('"');
79+
StringBuilder sb = new StringBuilder();
80+
while (true) {
81+
char c = next();
82+
if (c == '"') {
83+
return sb.toString();
84+
}
85+
if (c == '\\') {
86+
char e = next();
87+
switch (e) {
88+
case '"' -> sb.append('"');
89+
case '\\' -> sb.append('\\');
90+
case '/' -> sb.append('/');
91+
case 'n' -> sb.append('\n');
92+
case 'r' -> sb.append('\r');
93+
case 't' -> sb.append('\t');
94+
case 'b' -> sb.append('\b');
95+
case 'f' -> sb.append('\f');
96+
case 'u' -> {
97+
sb.append((char) Integer.parseInt(s.substring(pos, pos + 4), 16));
98+
pos += 4;
99+
}
100+
default -> throw new IllegalStateException("bad escape");
101+
}
102+
} else {
103+
sb.append(c);
104+
}
105+
}
106+
}
107+
108+
private Long parseInt() {
109+
int start = pos;
110+
while (pos < s.length() && s.charAt(pos) >= '0' && s.charAt(pos) <= '9') {
111+
pos++;
112+
}
113+
if (pos == start) {
114+
throw new IllegalStateException("expected integer");
115+
}
116+
return Long.parseLong(s.substring(start, pos));
117+
}
118+
119+
private boolean atEnd() {
120+
return pos == s.length();
121+
}
122+
123+
private char peek() {
124+
if (pos >= s.length()) {
125+
throw new IllegalStateException("unexpected end of input");
126+
}
127+
return s.charAt(pos);
128+
}
129+
130+
private char next() {
131+
char c = peek();
132+
pos++;
133+
return c;
134+
}
135+
136+
private void expect(char c) {
137+
if (next() != c) {
138+
throw new IllegalStateException("expected " + c);
139+
}
140+
}
141+
}

0 commit comments

Comments
 (0)