Skip to content

Commit 2c9e1b3

Browse files
committed
feat(otel): W3C traceparent transport-header propagation + AMQP/SQS wiring (ADR-0028)
Implements ADR-0025 Option 2: true cross-hop parent-child span linkage via the W3C traceparent header carried out-of-band on the transport (frozen envelope, GR-1 intact). The otel submodule injects/extracts traceparent over a broker- agnostic header seam (HeaderPublisher.PublishWithHeaders / ReceivedMessage. Headers). AMQP (amqp091.Table headers) and SQS (MessageAttributes) implement the seam — they now carry traceparent without clobbering existing bq-* headers and respect the SQS 10-attribute limit. No header present falls back to v0.1 (trace_id-derived trace). Core gains an additive, zero-dep HeadersFromContext. Redis stays deferred (no header side-channel). Other SDKs follow per-SDK MINOR.
1 parent fc22ee3 commit 2c9e1b3

17 files changed

Lines changed: 1094 additions & 23 deletions

amqp/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,17 @@ app.Publish(ctx, "urn:babel:orders:created", map[string]any{"order_id": 1042})
3232
app.Consume(ctx)
3333
```
3434

35+
## Out-of-band transport headers
36+
37+
This transport implements the optional `babelqueue.HeaderPublisher` capability:
38+
out-of-band headers ride in the AMQP message headers (`amqp091.Table`) **beside the
39+
frozen envelope, never in it** (GR-1), and are surfaced back to the consumer on
40+
`ReceivedMessage.Headers`. So the optional [`otel`](../otel) module's W3C
41+
`traceparent` (ADR-0028) — and the `bq-replay-bypass` marker (ADR-0027) —
42+
propagate over RabbitMQ for true cross-hop span parent-child linkage. The header is
43+
merged on top of the contract `x-*` headers without clobbering them (a contract
44+
header always wins a key collision).
45+
3546
The BabelQueue core stays zero-dependency; this module is the only place
3647
`amqp091-go` is pulled in.
3748

amqp/amqp.go

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@
66
// consumer can route on properties.type without parsing the body. Consuming uses
77
// basic.get + manual ack (at-least-once), matching the PHP RabbitMQ driver.
88
//
9+
// It also implements the optional [babelqueue.HeaderPublisher] capability: out-of-band
10+
// transport headers (e.g. a W3C traceparent for cross-hop span linkage, ADR-0028, or
11+
// the bq-replay-bypass marker, ADR-0027) ride in the AMQP message headers
12+
// (amqp091.Table) beside the frozen envelope — never in it (GR-1) — and are surfaced
13+
// back to the consumer on [babelqueue.ReceivedMessage.Headers].
14+
//
915
// tr := amqp.New("amqp://guest:guest@localhost:5672/")
1016
// app := babelqueue.NewApp(tr, babelqueue.WithDefaultQueue("orders"))
1117
//
@@ -14,6 +20,7 @@ package amqp
1420

1521
import (
1622
"context"
23+
"fmt"
1724
"sync"
1825
"time"
1926

@@ -74,6 +81,20 @@ func (t *Transport) declare(ch *amqp091.Channel, queue string) error {
7481
// Publish declares the durable queue and publishes body with persistent delivery
7582
// and the contract AMQP properties.
7683
func (t *Transport) Publish(ctx context.Context, queue, body string) error {
84+
return t.publish(ctx, queue, body, nil)
85+
}
86+
87+
// PublishWithHeaders publishes body together with out-of-band transport headers
88+
// ([babelqueue.HeaderPublisher]). The headers ride in the AMQP message headers
89+
// (amqp091.Table) beside the frozen envelope (GR-1) — e.g. a W3C traceparent for
90+
// cross-hop span linkage (ADR-0028). They are merged on top of the contract x-*
91+
// headers without clobbering them (a contract header always wins a key collision),
92+
// and empty/blank keys are skipped. A nil/empty map behaves exactly like [Transport.Publish].
93+
func (t *Transport) PublishWithHeaders(ctx context.Context, queue, body string, headers map[string]string) error {
94+
return t.publish(ctx, queue, body, headers)
95+
}
96+
97+
func (t *Transport) publish(ctx context.Context, queue, body string, headers map[string]string) error {
7798
t.mu.Lock()
7899
defer t.mu.Unlock()
79100
ch, err := t.ensure()
@@ -83,33 +104,84 @@ func (t *Transport) Publish(ctx context.Context, queue, body string) error {
83104
if err := t.declare(ch, queue); err != nil {
84105
return err
85106
}
86-
return ch.PublishWithContext(ctx, "", queue, false, false, t.publishing(body))
107+
return ch.PublishWithContext(ctx, "", queue, false, false, t.publishing(body, headers))
87108
}
88109

89-
func (t *Transport) publishing(body string) amqp091.Publishing {
110+
// publishing builds the AMQP message for body. extra carries optional out-of-band
111+
// transport headers that are merged into the message header table without overwriting
112+
// the contract x-* headers (the contract wins a key collision).
113+
func (t *Transport) publishing(body string, extra map[string]string) amqp091.Publishing {
90114
pub := amqp091.Publishing{
91115
ContentType: "application/json",
92116
ContentEncoding: "utf-8",
93117
DeliveryMode: amqp091.Persistent,
94118
AppId: "babelqueue",
95119
Body: []byte(body),
96120
}
121+
headers := amqp091.Table{}
122+
mergeHeaders(headers, extra)
97123
if env, err := babelqueue.Decode([]byte(body)); err == nil {
98124
pub.Type = env.Job
99125
pub.CorrelationId = env.TraceID
100126
pub.MessageId = env.Meta.ID
101-
headers := amqp091.Table{"x-attempts": env.Attempts}
127+
// Contract x-* headers are written last so they win any key collision with an
128+
// out-of-band header — the cross-language projection must never be clobbered.
129+
headers["x-attempts"] = env.Attempts
102130
if env.Meta.SchemaVersion != 0 {
103131
headers["x-schema-version"] = env.Meta.SchemaVersion
104132
}
105133
if env.Meta.Lang != "" {
106134
headers["x-source-lang"] = env.Meta.Lang
107135
}
136+
}
137+
if len(headers) > 0 {
108138
pub.Headers = headers
109139
}
110140
return pub
111141
}
112142

143+
// mergeHeaders writes the out-of-band string headers into table as AMQP values,
144+
// skipping empty keys and values. It never removes or overwrites a key already in
145+
// table, so callers can seed contract headers afterwards and keep them authoritative.
146+
func mergeHeaders(table amqp091.Table, headers map[string]string) {
147+
for k, v := range headers {
148+
if k == "" || v == "" {
149+
continue
150+
}
151+
if _, exists := table[k]; exists {
152+
continue
153+
}
154+
table[k] = v
155+
}
156+
}
157+
158+
// headersFromTable maps an inbound AMQP header table to a flat map[string]string,
159+
// stringifying values defensively (AMQP headers are typed: strings, ints, bytes…).
160+
// It returns nil when no headers are present so a header-less delivery stays
161+
// header-less on [babelqueue.ReceivedMessage.Headers].
162+
func headersFromTable(table amqp091.Table) map[string]string {
163+
if len(table) == 0 {
164+
return nil
165+
}
166+
out := make(map[string]string, len(table))
167+
for k, v := range table {
168+
switch val := v.(type) {
169+
case string:
170+
out[k] = val
171+
case []byte:
172+
out[k] = string(val)
173+
case nil:
174+
// skip nil-valued headers
175+
default:
176+
out[k] = fmt.Sprintf("%v", val)
177+
}
178+
}
179+
if len(out) == 0 {
180+
return nil
181+
}
182+
return out
183+
}
184+
113185
// Pop reserves the next message with basic.get (manual ack). When the queue is
114186
// empty it waits up to timeout (heartbeat-safe) and returns (nil, nil).
115187
func (t *Transport) Pop(ctx context.Context, queue string, timeout time.Duration) (*babelqueue.ReceivedMessage, error) {
@@ -139,7 +211,12 @@ func (t *Transport) Pop(ctx context.Context, queue string, timeout time.Duration
139211
}
140212
return nil, nil
141213
}
142-
return &babelqueue.ReceivedMessage{Body: string(delivery.Body), Queue: queue, Handle: delivery}, nil
214+
return &babelqueue.ReceivedMessage{
215+
Body: string(delivery.Body),
216+
Queue: queue,
217+
Handle: delivery,
218+
Headers: headersFromTable(delivery.Headers),
219+
}, nil
143220
}
144221

145222
// Ack acknowledges the reserved delivery.
@@ -164,4 +241,7 @@ func (t *Transport) Close() error {
164241
return nil
165242
}
166243

167-
var _ babelqueue.Transport = (*Transport)(nil)
244+
var (
245+
_ babelqueue.Transport = (*Transport)(nil)
246+
_ babelqueue.HeaderPublisher = (*Transport)(nil)
247+
)

amqp/amqp_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,48 @@ func TestAMQPRoundTrip(t *testing.T) {
5858
}
5959
}
6060

61+
// TestAMQPCarriesTraceparentHeader is an integration test: it publishes a message
62+
// with an out-of-band traceparent header (HeaderPublisher) and asserts the live
63+
// broker delivers it back on ReceivedMessage.Headers, beside the unchanged envelope.
64+
// It skips cleanly when no RabbitMQ broker is reachable.
65+
func TestAMQPCarriesTraceparentHeader(t *testing.T) {
66+
tr := bqamqp.New(brokerURL())
67+
defer tr.Close()
68+
69+
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
70+
defer cancel()
71+
72+
const queue = "bq-test-amqp-headers"
73+
const traceparent = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"
74+
75+
env, err := babelqueue.Make("urn:babel:test:traced", map[string]any{"n": 1}, babelqueue.WithQueue(queue))
76+
if err != nil {
77+
t.Fatal(err)
78+
}
79+
body, _ := env.Encode()
80+
81+
if err := tr.PublishWithHeaders(ctx, queue, string(body), map[string]string{"traceparent": traceparent}); err != nil {
82+
t.Skipf("rabbitmq not reachable: %v", err)
83+
}
84+
85+
msg, err := tr.Pop(ctx, queue, time.Second)
86+
if err != nil {
87+
t.Fatalf("Pop: %v", err)
88+
}
89+
if msg == nil {
90+
t.Fatal("expected a message")
91+
}
92+
defer func() { _ = tr.Ack(ctx, msg) }()
93+
94+
if msg.Headers["traceparent"] != traceparent {
95+
t.Errorf("traceparent header = %q, want %q", msg.Headers["traceparent"], traceparent)
96+
}
97+
// The envelope must be byte-identical — the header rides out of band (GR-1).
98+
if msg.Body != string(body) {
99+
t.Errorf("envelope body changed: header propagation must not touch the body")
100+
}
101+
}
102+
61103
// TestAMQPAppEndToEnd runs a publish + drain through the App over RabbitMQ.
62104
func TestAMQPAppEndToEnd(t *testing.T) {
63105
tr := bqamqp.New(brokerURL())

amqp/conformance_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ func TestRabbitMQConformance(t *testing.T) {
4545
t.Fatalf("fixture: %v", err)
4646
}
4747

48-
pub := (&Transport{}).publishing(string(body))
48+
pub := (&Transport{}).publishing(string(body), nil)
4949

5050
checks := map[string]struct{ got, want string }{
5151
"type": {pub.Type, proj.Properties["type"]},

amqp/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ module github.com/babelqueue/babelqueue-go/amqp
33
go 1.21
44

55
require (
6-
github.com/babelqueue/babelqueue-go v1.0.0
6+
github.com/babelqueue/babelqueue-go v1.3.0
77
github.com/rabbitmq/amqp091-go v1.10.0
88
)
99

amqp/headers_test.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package amqp
2+
3+
import (
4+
"testing"
5+
6+
babelqueue "github.com/babelqueue/babelqueue-go"
7+
amqp091 "github.com/rabbitmq/amqp091-go"
8+
)
9+
10+
// TestPublishingMergesHeadersWithoutClobbering proves an out-of-band header
11+
// (traceparent) is written into the AMQP header table beside the contract x-*
12+
// headers, and that a contract header always wins a key collision.
13+
func TestPublishingMergesHeadersWithoutClobbering(t *testing.T) {
14+
env, _ := babelqueue.Make("urn:babel:orders:created", map[string]any{"x": 1}, babelqueue.WithQueue("orders"))
15+
body, _ := env.Encode()
16+
17+
tr := &Transport{}
18+
pub := tr.publishing(string(body), map[string]string{
19+
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
20+
"tracestate": "vendor=value",
21+
"x-source-lang": "ATTACKER", // must NOT overwrite the contract header
22+
"": "blank-key-skipped",
23+
"bq-replay-bypass": "1",
24+
})
25+
26+
if got, _ := pub.Headers["traceparent"].(string); got != "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01" {
27+
t.Errorf("traceparent not carried: %q", got)
28+
}
29+
if got, _ := pub.Headers["tracestate"].(string); got != "vendor=value" {
30+
t.Errorf("tracestate not carried: %q", got)
31+
}
32+
if got, _ := pub.Headers["bq-replay-bypass"].(string); got != "1" {
33+
t.Errorf("bq-replay-bypass not carried: %q", got)
34+
}
35+
// The contract projection must survive the merge unchanged.
36+
if got, _ := pub.Headers["x-source-lang"].(string); got != env.Meta.Lang {
37+
t.Errorf("contract x-source-lang clobbered: got %q, want %q", got, env.Meta.Lang)
38+
}
39+
if v, _ := pub.Headers["x-schema-version"].(int); v != env.Meta.SchemaVersion {
40+
t.Errorf("x-schema-version = %v, want %d", pub.Headers["x-schema-version"], env.Meta.SchemaVersion)
41+
}
42+
if _, exists := pub.Headers[""]; exists {
43+
t.Error("blank header key must be skipped")
44+
}
45+
}
46+
47+
// TestPublishingNilHeadersIsByteForBytePlainPublish proves PublishWithHeaders with
48+
// no extra headers produces exactly the same message as a plain Publish would.
49+
func TestPublishingNilHeadersUnchanged(t *testing.T) {
50+
env, _ := babelqueue.Make("urn:babel:orders:created", map[string]any{"x": 1})
51+
body, _ := env.Encode()
52+
53+
tr := &Transport{}
54+
plain := tr.publishing(string(body), nil)
55+
56+
if plain.Type != env.Job || plain.CorrelationId != env.TraceID || plain.MessageId != env.Meta.ID {
57+
t.Error("contract properties changed under nil headers")
58+
}
59+
// Only the contract x-* headers should be present.
60+
for k := range plain.Headers {
61+
switch k {
62+
case "x-attempts", "x-schema-version", "x-source-lang":
63+
default:
64+
t.Errorf("unexpected header %q on a plain publish", k)
65+
}
66+
}
67+
}
68+
69+
func TestHeadersFromTable(t *testing.T) {
70+
if got := headersFromTable(nil); got != nil {
71+
t.Errorf("nil table must surface nil headers, got %v", got)
72+
}
73+
if got := headersFromTable(amqp091.Table{}); got != nil {
74+
t.Errorf("empty table must surface nil headers, got %v", got)
75+
}
76+
77+
got := headersFromTable(amqp091.Table{
78+
"traceparent": "00-trace-span-01",
79+
"x-attempts": 3, // ints stringify
80+
"bq-replay-bypass": []byte("1"), // byte slices stringify
81+
"x-source-lang": "go",
82+
"nil-value": nil, // skipped
83+
})
84+
if got["traceparent"] != "00-trace-span-01" {
85+
t.Errorf("traceparent = %q", got["traceparent"])
86+
}
87+
if got["x-attempts"] != "3" {
88+
t.Errorf("x-attempts (int) = %q, want \"3\"", got["x-attempts"])
89+
}
90+
if got["bq-replay-bypass"] != "1" {
91+
t.Errorf("bq-replay-bypass ([]byte) = %q, want \"1\"", got["bq-replay-bypass"])
92+
}
93+
if _, ok := got["nil-value"]; ok {
94+
t.Error("nil-valued header must be skipped")
95+
}
96+
}
97+
98+
// TestHeaderRoundTrip proves a traceparent injected on the produce side survives a
99+
// table → map[string]string extraction on the consume side, fully in-process.
100+
func TestHeaderRoundTrip(t *testing.T) {
101+
const tp = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"
102+
env, _ := babelqueue.Make("urn:babel:orders:created", map[string]any{"x": 1})
103+
body, _ := env.Encode()
104+
105+
pub := (&Transport{}).publishing(string(body), map[string]string{"traceparent": tp})
106+
// pub.Headers is what RabbitMQ would deliver back as delivery.Headers.
107+
out := headersFromTable(pub.Headers)
108+
if out["traceparent"] != tp {
109+
t.Fatalf("round-trip lost traceparent: got %q, want %q", out["traceparent"], tp)
110+
}
111+
}
112+
113+
var _ babelqueue.HeaderPublisher = (*Transport)(nil)

0 commit comments

Comments
 (0)