Skip to content

Commit e7c4af0

Browse files
anapsixclaude
andcommitted
fix(contrib/lovoo/goka): address review feedback on span/DSM lifecycle
Refine the goka integration after code review: - Drop the kafka.produce spans. goka.Context.Emit is asynchronous and returns no handle, so a produce span could never be finished on the emit's real completion nor tagged with an async delivery error. Emit and Loopback now propagate the consume span through the outbound headers instead, preserving trace continuity without a misleading span. Removes the now-unused producer service/span-name config. - Honor DeferCommit: DSM commit-offset tracking now follows the real (deferred) commit and records the offset only when the returned function is called with a nil error, instead of always on callback return. - Add WithLoopSuffix so Loopback DSM checkpoints match a goka loop suffix changed via goka.SetLoopSuffix. - Report DSM payload sizes for consumed and produced messages where observable (key + headers; value size only for raw []byte/string, since codec-encoded values are opaque at this seam). - Recover panics in the consume-span finish so Context.Fail and internal goka failures both tag the span before goka shuts the processor down. - Document the seam's limitations: WrapCallback is required for DSM commit tracking, async emit outcomes are not reflected on the consume span, and WrapContext also wraps VisitValues contexts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3cba544 commit e7c4af0

3 files changed

Lines changed: 297 additions & 58 deletions

File tree

contrib/lovoo/goka/goka.go

Lines changed: 171 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ package goka
2222

2323
import (
2424
"context"
25+
"fmt"
2526
"time"
2627

2728
"github.com/lovoo/goka"
@@ -65,6 +66,11 @@ func NewTracer(opts ...Option) *Tracer {
6566
//
6667
// The APM consume span is started by WrapCallback, not here, because
6768
// WithContextWrapper has no hook for finishing a span once the callback returns.
69+
//
70+
// Note: goka also invokes the context wrapper for VisitValues visit callbacks,
71+
// whose Topic() is the visit name rather than a Kafka topic. With DSM enabled,
72+
// such visits produce an inbound checkpoint keyed on the visit name; ignore those
73+
// nodes in the Data Streams Monitoring graph.
6874
func (tr *Tracer) WrapContext(ctx goka.Context) goka.Context {
6975
tc := &tracedContext{gctx: ctx, tr: tr}
7076
if tr.cfg.dataStreamsEnabled {
@@ -75,11 +81,22 @@ func (tr *Tracer) WrapContext(ctx goka.Context) goka.Context {
7581

7682
// WrapCallback wraps a goka.ProcessCallback so that each input message is
7783
// processed inside a "kafka.consume" span. The span is finished when the callback
78-
// returns; if the callback calls Context.Fail (which panics to unwind), the
79-
// deferred finish still runs and tags the span with the error.
84+
// returns; any panic that unwinds the callback (Context.Fail, an internal goka
85+
// failure, or a bug in the handler) is recovered so the span is tagged with the
86+
// error and then re-raised so goka still shuts the processor down.
8087
//
8188
// Wrap every input callback whose messages you want traced, and register
8289
// WrapContext via goka.WithContextWrapper so emitted messages continue the trace.
90+
//
91+
// DSM commit-offset tracking is also performed here on successful processing (or,
92+
// if the handler calls ctx.DeferCommit, when that deferred commit succeeds), so
93+
// an Input whose callback is not wrapped reports no committed offset to Data
94+
// Streams Monitoring. Wrap every input callback for which you want DSM lag.
95+
//
96+
// Because goka.Context.Emit is asynchronous, the span is finished when the
97+
// callback returns and does not reflect the outcome of emits that resolve later;
98+
// an emit that fails asynchronously (and shuts the partition down) is not tagged
99+
// on the span.
83100
func (tr *Tracer) WrapCallback(cb goka.ProcessCallback) goka.ProcessCallback {
84101
return func(ctx goka.Context, msg any) {
85102
tc, ok := ctx.(*tracedContext)
@@ -104,12 +121,14 @@ func (tr *Tracer) WrapCallback(cb goka.ProcessCallback) goka.ProcessCallback {
104121
// The span is taken from ctx if one is active there. When neither tracing nor DSM
105122
// is enabled the returned headers are empty.
106123
func (tr *Tracer) EmitHeaders(ctx context.Context, topic string) goka.Headers {
107-
headers := goka.Headers{}
108-
if span, ok := tracer.SpanFromContext(ctx); ok {
109-
tracer.Inject(span.Context(), gokaHeadersCarrier(headers))
124+
if ctx == nil {
125+
ctx = context.Background()
110126
}
111-
tr.injectProduceCheckpoint(ctx, topic, headers)
112-
return headers
127+
var span *tracer.Span
128+
if s, ok := tracer.SpanFromContext(ctx); ok {
129+
span = s
130+
}
131+
return tr.outboundHeaders(span, ctx, topic, 0)
113132
}
114133

115134
func (tr *Tracer) setConsumeCheckpoint(gctx goka.Context) context.Context {
@@ -119,33 +138,49 @@ func (tr *Tracer) setConsumeCheckpoint(gctx goka.Context) context.Context {
119138
if group != "" {
120139
edges = append(edges, "group:"+group)
121140
}
141+
// goka's Context exposes neither the raw message value nor its encoded size
142+
// at this hook (Value() returns the group-table state, not the input body),
143+
// so the payload size counts only the key and header bytes we can observe.
144+
params := options.CheckpointParams{
145+
PayloadSize: int64(len(gctx.Key())) + headersSize(gctx.Headers()),
146+
}
122147
ctx, ok := tracer.SetDataStreamsCheckpointWithParams(
123148
datastreams.ExtractFromBase64Carrier(gctx.Context(), gokaHeadersCarrier(gctx.Headers())),
124-
options.CheckpointParams{},
149+
params,
125150
edges...,
126151
)
127152
if !ok {
128153
return nil
129154
}
130-
if group != "" {
131-
tracer.TrackKafkaCommitOffset(group, topic, gctx.Partition(), gctx.Offset())
132-
}
133155
return ctx
134156
}
135157

158+
// trackCommit records the consumed offset for DSM lag tracking. It runs only
159+
// after the callback returns without failing, mirroring goka, which commits the
160+
// offset after successful processing; a message that fails is reprocessed and
161+
// must not be counted as committed.
162+
func (tc *tracedContext) trackCommit() {
163+
if !tc.tr.cfg.dataStreamsEnabled {
164+
return
165+
}
166+
group := string(tc.gctx.Group())
167+
if group == "" {
168+
return
169+
}
170+
tracer.TrackKafkaCommitOffset(group, string(tc.gctx.Topic()), tc.gctx.Partition(), tc.gctx.Offset())
171+
}
172+
136173
// injectProduceCheckpoint sets a DSM outbound checkpoint on base and injects the
137-
// resulting pathway into headers. base should carry the inbound pathway so the
138-
// produce checkpoint chains onto it.
139-
func (tr *Tracer) injectProduceCheckpoint(base context.Context, topic string, headers goka.Headers) {
174+
// resulting pathway into headers. base must be non-nil and should carry the
175+
// inbound pathway so the produce checkpoint chains onto it; callers own that
176+
// fallback (produce uses the consume context, EmitHeaders the supplied context).
177+
func (tr *Tracer) injectProduceCheckpoint(base context.Context, topic string, headers goka.Headers, payloadSize int64) {
140178
if !tr.cfg.dataStreamsEnabled {
141179
return
142180
}
143-
if base == nil {
144-
base = context.Background()
145-
}
146181
ctx, ok := tracer.SetDataStreamsCheckpointWithParams(
147182
base,
148-
options.CheckpointParams{},
183+
options.CheckpointParams{PayloadSize: payloadSize},
149184
"direction:out", "topic:"+topic, "type:kafka",
150185
)
151186
if !ok {
@@ -154,16 +189,40 @@ func (tr *Tracer) injectProduceCheckpoint(base context.Context, topic string, he
154189
datastreams.InjectToBase64Carrier(ctx, gokaHeadersCarrier(headers))
155190
}
156191

192+
// headersSize returns the total byte size of the key and value pairs in h.
193+
func headersSize(h goka.Headers) int64 {
194+
var n int64
195+
for k, v := range h {
196+
n += int64(len(k) + len(v))
197+
}
198+
return n
199+
}
200+
201+
// valueSize returns the byte size of an emitted value when it is a raw []byte or
202+
// string. goka encodes other values with a codec we cannot see here, so their
203+
// size is reported as 0.
204+
func valueSize(v any) int64 {
205+
switch t := v.(type) {
206+
case []byte:
207+
return int64(len(t))
208+
case string:
209+
return int64(len(t))
210+
default:
211+
return 0
212+
}
213+
}
214+
157215
// tracedContext wraps a goka.Context, overriding Emit/Loopback/Context/Fail to
158216
// carry Datadog trace and DSM propagation. All other methods delegate to gctx.
159217
type tracedContext struct {
160218
gctx goka.Context
161219
tr *Tracer
162220

163-
dsmCtx context.Context // inbound DSM pathway; base for outbound checkpoints
164-
span *tracer.Span // APM consume span, set by startConsumeSpan
165-
tracedCtx context.Context // span context returned by Context()
166-
spanErr error // recorded by Fail for the deferred span finish
221+
dsmCtx context.Context // inbound DSM pathway; base for outbound checkpoints
222+
span *tracer.Span // APM consume span, set by startConsumeSpan
223+
tracedCtx context.Context // span context returned by Context()
224+
spanErr error // recorded by Fail for the deferred span finish
225+
commitDeferred bool // set by DeferCommit; offset tracking moves to its callback
167226
}
168227

169228
func (tc *tracedContext) startConsumeSpan() func() {
@@ -198,47 +257,94 @@ func (tc *tracedContext) startConsumeSpan() func() {
198257
tc.tracedCtx = spanCtx
199258

200259
return func() {
260+
// A failure unwinds the callback by panicking: goka.Context.Fail (via our
261+
// override, which sets spanErr) or an internal goka failure / plain panic
262+
// that never reaches spanErr. Recover so the span records the error either
263+
// way, then re-panic so goka still shuts the processor down.
264+
if r := recover(); r != nil {
265+
err := tc.spanErr
266+
if err == nil {
267+
if e, ok := r.(error); ok {
268+
err = e
269+
} else {
270+
err = fmt.Errorf("goka: message processing panicked: %v", r)
271+
}
272+
}
273+
span.Finish(tracer.WithError(err))
274+
panic(r)
275+
}
201276
if tc.spanErr != nil {
202277
span.Finish(tracer.WithError(tc.spanErr))
203278
return
204279
}
280+
// When the handler deferred the commit, offset tracking is handled by the
281+
// DeferCommit callback instead, so it isn't double-counted here.
282+
if !tc.commitDeferred {
283+
tc.trackCommit()
284+
}
205285
span.Finish()
206286
}
207287
}
208288

209-
// outboundHeaders builds the headers to attach to a message emitted to topic,
210-
// injecting the active APM span and a DSM outbound checkpoint chained onto the
211-
// inbound pathway.
212-
func (tc *tracedContext) outboundHeaders(topic string) goka.Headers {
289+
// outboundHeaders builds the headers to attach to a message emitted to topic:
290+
// span (the active consume span) is injected for APM trace propagation so the
291+
// downstream consumer continues the trace, and a DSM outbound checkpoint is
292+
// chained onto base. It is the single header builder shared by processor emits
293+
// and the standalone EmitHeaders so the two cannot drift apart.
294+
//
295+
// No "kafka.produce" span is created: goka.Context.Emit is asynchronous and
296+
// returns no handle, so a produce span could never be finished on the emit's
297+
// actual completion or tagged with an async delivery error. Trace continuity is
298+
// preserved by propagating the consume span through the headers instead.
299+
func (tr *Tracer) outboundHeaders(span *tracer.Span, base context.Context, topic string, payloadSize int64) goka.Headers {
213300
headers := goka.Headers{}
214-
if tc.span != nil {
215-
tracer.Inject(tc.span.Context(), gokaHeadersCarrier(headers))
301+
if span != nil {
302+
tracer.Inject(span.Context(), gokaHeadersCarrier(headers))
216303
}
304+
tr.injectProduceCheckpoint(base, topic, headers, payloadSize)
305+
return headers
306+
}
307+
308+
// produce builds the outbound headers for an emit to topic (propagating the
309+
// consume span and a DSM outbound checkpoint) and prepends them as a
310+
// ContextOption before delegating. It centralises header injection and DSM
311+
// checkpointing so Emit and Loopback share one path. Caller-supplied
312+
// ContextOptions win on header-key collisions (goka merges per-emit headers over
313+
// the ones we prepend).
314+
func (tc *tracedContext) produce(topic, key string, value any, opts []goka.ContextOption, emit func(opts []goka.ContextOption)) {
217315
base := tc.dsmCtx
218316
if base == nil {
219317
base = tc.gctx.Context()
220318
}
221-
tc.tr.injectProduceCheckpoint(base, topic, headers)
222-
return headers
319+
size := int64(len(key)) + valueSize(value)
320+
if headers := tc.tr.outboundHeaders(tc.span, base, topic, size); len(headers) > 0 {
321+
opts = append([]goka.ContextOption{goka.WithCtxEmitHeaders(headers)}, opts...)
322+
}
323+
emit(opts)
223324
}
224325

225-
// Emit injects trace/DSM headers, then delegates to the wrapped context. Caller
226-
// supplied ContextOptions win on header-key collisions (goka merges per-emit
227-
// headers over these).
326+
// Emit injects trace/DSM headers, then delegates to the wrapped context.
228327
func (tc *tracedContext) Emit(topic goka.Stream, key string, value any, opts ...goka.ContextOption) {
229-
if headers := tc.outboundHeaders(string(topic)); len(headers) > 0 {
230-
opts = append([]goka.ContextOption{goka.WithCtxEmitHeaders(headers)}, opts...)
231-
}
232-
tc.gctx.Emit(topic, key, value, opts...)
328+
tc.produce(string(topic), key, value, opts, func(opts []goka.ContextOption) {
329+
tc.gctx.Emit(topic, key, value, opts...)
330+
})
233331
}
234332

235333
// Loopback injects trace/DSM headers for the group's loop stream, then delegates.
334+
//
335+
// The loop topic is derived from cfg.loopSuffix (default "-loop"); if the
336+
// application changed goka's suffix via goka.SetLoopSuffix, pass the matching
337+
// WithLoopSuffix or the DSM loop edge and span tags will name the wrong topic.
236338
func (tc *tracedContext) Loopback(key string, value any, opts ...goka.ContextOption) {
237-
loopTopic := string(tc.gctx.Group()) + "-loop"
238-
if headers := tc.outboundHeaders(loopTopic); len(headers) > 0 {
239-
opts = append([]goka.ContextOption{goka.WithCtxEmitHeaders(headers)}, opts...)
240-
}
241-
tc.gctx.Loopback(key, value, opts...)
339+
tc.produce(tc.loopTopic(), key, value, opts, func(opts []goka.ContextOption) {
340+
tc.gctx.Loopback(key, value, opts...)
341+
})
342+
}
343+
344+
// loopTopic returns the name of the group's loop stream, used to tag the
345+
// Loopback DSM checkpoint. It mirrors goka's own "<group><suffix>" naming.
346+
func (tc *tracedContext) loopTopic() string {
347+
return string(tc.gctx.Group()) + tc.tr.cfg.loopSuffix
242348
}
243349

244350
// Fail records the error for the deferred span finish, then delegates (which
@@ -257,6 +363,29 @@ func (tc *tracedContext) Context() context.Context {
257363
return tc.gctx.Context()
258364
}
259365

366+
// DeferCommit wraps goka's DeferCommit so DSM commit-offset tracking follows the
367+
// real (deferred) commit instead of the callback return: the offset is recorded
368+
// only when the returned function is called with a nil error. The message
369+
// coordinates are captured now, as the context must not be used once the callback
370+
// has returned.
371+
func (tc *tracedContext) DeferCommit() func(error) {
372+
commit := tc.gctx.DeferCommit()
373+
tc.commitDeferred = true
374+
375+
track := tc.tr.cfg.dataStreamsEnabled
376+
group := string(tc.gctx.Group())
377+
topic := string(tc.gctx.Topic())
378+
partition := tc.gctx.Partition()
379+
offset := tc.gctx.Offset()
380+
381+
return func(err error) {
382+
if err == nil && track && group != "" {
383+
tracer.TrackKafkaCommitOffset(group, topic, partition, offset)
384+
}
385+
commit(err)
386+
}
387+
}
388+
260389
// The remaining methods delegate unchanged to the wrapped goka.Context.
261390

262391
func (tc *tracedContext) Topic() goka.Stream { return tc.gctx.Topic() }
@@ -269,7 +398,6 @@ func (tc *tracedContext) Headers() goka.Headers { return tc.gc
269398
func (tc *tracedContext) Timestamp() time.Time { return tc.gctx.Timestamp() }
270399
func (tc *tracedContext) Join(topic goka.Table) any { return tc.gctx.Join(topic) }
271400
func (tc *tracedContext) Lookup(topic goka.Table, key string) any { return tc.gctx.Lookup(topic, key) }
272-
func (tc *tracedContext) DeferCommit() func(error) { return tc.gctx.DeferCommit() }
273401
func (tc *tracedContext) SetValue(value any, opts ...goka.ContextOption) {
274402
tc.gctx.SetValue(value, opts...)
275403
}

0 commit comments

Comments
 (0)