Skip to content

Commit e29ff74

Browse files
authored
Enable Trace Propagation (#65)
When the configuration parameter `EnableTracePropagation` is enabled, the traces generated by river workers will be *linked* to the traces that enqueued them. This is done by use of metadata parameters, following the example in River's documentation for the middleware functionality (https://riverqueue.com/docs/middleware). This uses a *link* instead of directly making a child span which is semantically more correct. Fixes #41 (I believe, at least).
1 parent 0446a98 commit e29ff74

3 files changed

Lines changed: 152 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Add `EnableTracePropagation` configuration method, which enables linking river job spans to the span that enqueued them for tracing *why* a job was enqueued. [PR #65](https://github.com/riverqueue/rivercontrib/pull/65)
13+
1014
## [0.10.0] - 2026-06-06
1115

1216
### Added

otelriver/middleware.go

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,17 @@ package otelriver
33
import (
44
"cmp"
55
"context"
6+
"encoding/json"
67
"errors"
78
"slices"
89
"time"
910

11+
"github.com/tidwall/sjson"
1012
"go.opentelemetry.io/otel"
1113
"go.opentelemetry.io/otel/attribute"
1214
"go.opentelemetry.io/otel/codes"
1315
"go.opentelemetry.io/otel/metric"
16+
"go.opentelemetry.io/otel/propagation"
1417
"go.opentelemetry.io/otel/trace"
1518

1619
"github.com/riverqueue/river"
@@ -45,6 +48,11 @@ type MiddlewareConfig struct {
4548
// metric names, with attributes differentiating them.
4649
EnableSemanticMetrics bool
4750

51+
// EnableTracePropagation injects W3C trace context (traceparent/tracestate)
52+
// into job metadata on insert and extracts it on work, adding a span link
53+
// from the work span back to the span that enqueued the job.
54+
EnableTracePropagation bool
55+
4856
// EnableWorkSpanJobKindSuffix appends the job kind a suffix to work spans
4957
// so they look like `river.work/my_job` instead of `river.work`.
5058
EnableWorkSpanJobKindSuffix bool
@@ -208,6 +216,12 @@ func (m *Middleware) InsertMany(ctx context.Context, manyParams []*rivertype.Job
208216
}
209217
}()
210218

219+
if m.config.EnableTracePropagation {
220+
for i := range manyParams {
221+
manyParams[i].Metadata = injectTraceContext(ctx, manyParams[i].Metadata)
222+
}
223+
}
224+
211225
insertRes, err = doInner(ctx)
212226
panicked = false
213227
return insertRes, err
@@ -219,8 +233,18 @@ func (m *Middleware) Work(ctx context.Context, job *rivertype.JobRow, doInner fu
219233
spanName += "/" + job.Kind
220234
}
221235

236+
var startOpts []trace.SpanStartOption
237+
if m.config.EnableTracePropagation {
238+
//nolint:contextcheck
239+
if sc := extractSpanContext(job.Metadata); sc.IsValid() {
240+
// We use a *link* to the span that enqueued this value, because river jobs are async by nature, so they may happen
241+
// minutes, hours, or even days after they're enqueued, which can lead to really weird span contexts if a direct parent
242+
// relationship is used.
243+
startOpts = append(startOpts, trace.WithLinks(trace.Link{SpanContext: sc}))
244+
}
245+
}
222246
ctx, span := m.tracer.Start(ctx, spanName,
223-
trace.WithSpanKind(trace.SpanKindConsumer))
247+
append(startOpts, trace.WithSpanKind(trace.SpanKindConsumer))...)
224248
defer span.End()
225249

226250
attrs := []attribute.KeyValue{
@@ -336,6 +360,54 @@ func mustInt64Counter(meter metric.Meter, name string, options ...metric.Int64Co
336360
return metric
337361
}
338362

363+
// injectTraceContext injects the current span context from ctx into metadata
364+
// JSON under the W3C "traceparent" (and optionally "tracestate") key. If
365+
// injection fails for any reason the original metadata is returned unchanged.
366+
func injectTraceContext(ctx context.Context, metadata []byte) []byte {
367+
carrier := make(propagation.MapCarrier)
368+
propagation.TraceContext{}.Inject(ctx, carrier)
369+
if len(carrier) == 0 {
370+
return metadata
371+
}
372+
if len(metadata) == 0 {
373+
metadata = []byte("{}")
374+
}
375+
original := metadata
376+
for k, v := range carrier {
377+
var err error
378+
metadata, err = sjson.SetBytes(metadata, k, v)
379+
if err != nil {
380+
return original
381+
}
382+
}
383+
return metadata
384+
}
385+
386+
// extractSpanContext reads W3C trace context from metadata JSON and returns the
387+
// remote SpanContext it encodes. Returns a zero SpanContext (IsValid() == false)
388+
// if no traceparent is present or the metadata cannot be parsed.
389+
func extractSpanContext(metadata []byte) trace.SpanContext {
390+
if len(metadata) == 0 {
391+
return trace.SpanContext{}
392+
}
393+
var meta map[string]any
394+
if err := json.Unmarshal(metadata, &meta); err != nil {
395+
return trace.SpanContext{}
396+
}
397+
carrier := make(propagation.MapCarrier)
398+
for k, v := range meta {
399+
if s, ok := v.(string); ok {
400+
carrier[k] = s
401+
}
402+
}
403+
// We use context.Background here because the only purpose of this function is to return
404+
// a span context for *linking*. If one doesn't exist, we don't want to extract anything -
405+
// and we certainly don't want to extract the span from `ctx`, which would most often lead to us
406+
// linking to ourselves, which is pretty obviously incorrect!
407+
extracted := propagation.TraceContext{}.Extract(context.Background(), carrier)
408+
return trace.SpanFromContext(extracted).SpanContext()
409+
}
410+
339411
// Sets success status on the given span and within the set of attributes. The
340412
// index of the status attribute is required ahead of time as a minor
341413
// optimization.

otelriver/middleware_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package otelriver
22

33
import (
44
"context"
5+
"encoding/json"
56
"errors"
67
"fmt"
78
"testing"
@@ -10,6 +11,7 @@ import (
1011
"github.com/stretchr/testify/require"
1112
"go.opentelemetry.io/otel/attribute"
1213
"go.opentelemetry.io/otel/codes"
14+
"go.opentelemetry.io/otel/propagation"
1315
"go.opentelemetry.io/otel/sdk/metric"
1416
"go.opentelemetry.io/otel/sdk/metric/metricdata"
1517
"go.opentelemetry.io/otel/sdk/metric/metricdata/metricdatatest"
@@ -304,6 +306,34 @@ func TestMiddleware(t *testing.T) {
304306
getAttribute(t, spans[0].Attributes, "kinds").AsStringSlice())
305307
})
306308

309+
t.Run("InsertManyInjectsTraceparent", func(t *testing.T) {
310+
t.Parallel()
311+
312+
middleware, bundle := setupConfig(t, &MiddlewareConfig{EnableTracePropagation: true})
313+
314+
params := []*rivertype.JobInsertParams{{Kind: "no_op"}}
315+
doInner := func(ctx context.Context) ([]*rivertype.JobInsertResult, error) {
316+
return []*rivertype.JobInsertResult{{Job: &rivertype.JobRow{ID: 1}}}, nil
317+
}
318+
319+
_, err := middleware.InsertMany(ctx, params, doInner)
320+
require.NoError(t, err)
321+
322+
// The insert_many span's context should have been injected into the params metadata.
323+
require.NotNil(t, params[0].Metadata)
324+
var meta map[string]any
325+
require.NoError(t, json.Unmarshal(params[0].Metadata, &meta))
326+
traceparent, ok := meta["traceparent"].(string)
327+
require.True(t, ok, "expected traceparent key in job metadata")
328+
329+
// The traceparent must reference the insert_many span's trace and span IDs.
330+
spans := bundle.traceExporter.GetSpans()
331+
require.Len(t, spans, 1)
332+
insertSpan := spans[0]
333+
require.Contains(t, traceparent, insertSpan.SpanContext.TraceID().String())
334+
require.Contains(t, traceparent, insertSpan.SpanContext.SpanID().String())
335+
})
336+
307337
t.Run("InsertManyDurationUnitMS", func(t *testing.T) {
308338
t.Parallel()
309339

@@ -750,6 +780,51 @@ func TestMiddleware(t *testing.T) {
750780
}
751781
})
752782

783+
t.Run("WorkExtractsTraceparent", func(t *testing.T) {
784+
t.Parallel()
785+
786+
middleware, bundle := setupConfig(t, &MiddlewareConfig{EnableTracePropagation: true})
787+
788+
// Build a synthetic traceparent pointing to a remote parent span.
789+
parentTraceID := "4bf92f3577b34da6a3ce929d0e0e4736"
790+
parentSpanID := "00f067aa0ba902b7"
791+
carrier := propagation.MapCarrier{
792+
"traceparent": fmt.Sprintf("00-%s-%s-01", parentTraceID, parentSpanID),
793+
}
794+
metadata, err := json.Marshal(carrier)
795+
require.NoError(t, err)
796+
797+
err = middleware.Work(ctx, &rivertype.JobRow{
798+
Kind: "no_op",
799+
Metadata: metadata,
800+
}, func(ctx context.Context) error { return nil })
801+
require.NoError(t, err)
802+
803+
spans := bundle.traceExporter.GetSpans()
804+
require.Len(t, spans, 1)
805+
workSpan := spans[0]
806+
807+
// The work span must be linked to (not a child of) the insert span.
808+
require.False(t, workSpan.Parent.IsValid(), "work span should not be a child of the insert span")
809+
require.Len(t, workSpan.Links, 1)
810+
require.Equal(t, parentTraceID, workSpan.Links[0].SpanContext.TraceID().String())
811+
require.Equal(t, parentSpanID, workSpan.Links[0].SpanContext.SpanID().String())
812+
})
813+
814+
t.Run("WorkExtractsTraceparentMissingMetadata", func(t *testing.T) {
815+
t.Parallel()
816+
817+
middleware, bundle := setupConfig(t, &MiddlewareConfig{EnableTracePropagation: true})
818+
819+
// No traceparent in metadata — work span should be a root span.
820+
err := middleware.Work(ctx, &rivertype.JobRow{Kind: "no_op"}, func(ctx context.Context) error { return nil })
821+
require.NoError(t, err)
822+
823+
spans := bundle.traceExporter.GetSpans()
824+
require.Len(t, spans, 1)
825+
require.False(t, spans[0].Parent.IsValid(), "expected no parent span when metadata has no traceparent")
826+
})
827+
753828
t.Run("WorkEnableWorkSpanJobKindSuffix ", func(t *testing.T) {
754829
t.Parallel()
755830

0 commit comments

Comments
 (0)