@@ -22,6 +22,7 @@ package goka
2222
2323import (
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.
6874func (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.
83100func (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.
106123func (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
115134func (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.
159217type 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
169228func (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.
228327func (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.
236338func (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
262391func (tc * tracedContext ) Topic () goka.Stream { return tc .gctx .Topic () }
@@ -269,7 +398,6 @@ func (tc *tracedContext) Headers() goka.Headers { return tc.gc
269398func (tc * tracedContext ) Timestamp () time.Time { return tc .gctx .Timestamp () }
270399func (tc * tracedContext ) Join (topic goka.Table ) any { return tc .gctx .Join (topic ) }
271400func (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 () }
273401func (tc * tracedContext ) SetValue (value any , opts ... goka.ContextOption ) {
274402 tc .gctx .SetValue (value , opts ... )
275403}
0 commit comments