fs2-kafka-otel4s

Configuration

Pass KafkaTracer.Config.default to .traced(config) to start. This creates one tracer per stream evaluation. To reuse a tracer, pass it to .traced(kafkaTracer).

import fs2.kafka.otel4s.trace.{BatchSpanMode, KafkaTracer, ProcessSpanRelationship}

val tracerConfig: KafkaTracer.Config =
  KafkaTracer.Config
    .default
    .withServerAddress("localhost", Some(9092))

val lowerVolumeTracerConfig: KafkaTracer.Config =
  tracerConfig.withBatchSpanMode(BatchSpanMode.SharedSendSpan)

val processingOnlyConfig: KafkaTracer.Config =
  tracerConfig.withoutReceiveSpans.withoutCommitSpans
Option Use it to
withServerAddress(host, port) Add explicit broker endpoint attributes; bootstrap servers are configured separately in Kafka settings
withBatchSpanMode(SharedSendSpan) Reduce producer batch span volume; the default is PerRecordSpans
withoutReceiveSpans, withoutProcessSpans, withoutCommitSpans Suppress consumer span types while retaining processing and commits
withoutSendSpans Suppress producer send spans while retaining propagation and any batch create spans
withConstAttributes, addConstAttributes Add attributes to library spans
with*SpanSetup Customize a span's name, attributes, and finalization, or return None to suppress it

withProcessSpanSetup controls record spans. withBatchProcessSpanSetup controls chunk spans. withoutProcessSpans disables both.

Disabling spans still runs your effects. Use KafkaTracer.noop to disable both spans and header propagation.

Set client and group IDs in Kafka settings. Set tracing options in KafkaTracer.Config.

Process span relationships

Set SpanSetup.Process.relationship through either process setup hook:

Mode Behavior
Link (default) Start a root span and link to message context
ParentFromRecord Use message context as parent, retaining the message link and linking any distinct valid ambient context
val perTopicConfig = tracerConfig.withProcessSpanSetup { ctx =>
  KafkaTracer.Config.Defaults.processSpanSetup(ctx).map { setup =>
    if (ctx.topic == "orders") setup.withRelationship(ProcessSpanRelationship.ParentFromRecord)
    else setup
  }
}

Use withBatchProcessSpanSetup independently for batch processing, with KafkaTracer.Config.Defaults.batchProcessSpanSetup(ctx) as the starting point. Parenting applies to single-record processing and singleton batches. Missing or invalid context produces a root; multiple-record batches remain linked roots. Receive and commit behavior stays the same.

Custom message keys

Define KafkaMessageKey to include custom keys in messaging.kafka.message.key. Return None to omit a key.

When changing key types, use tracedWithSerializers to keep key attributes. Plain withSerializers keeps spans but stops deriving key attributes for the new type.

import cats.effect.IO
import fs2.kafka.Serializer
import fs2.kafka.otel4s.trace.{KafkaMessageKey, TracedKafkaProducer}

final class OrderId(val value: String)

implicit val orderIdKafkaMessageKey: KafkaMessageKey[OrderId] =
  KafkaMessageKey.instance(id => Some(id.value))

def remapSerializers(
    producer: TracedKafkaProducer[IO, String, String]
): TracedKafkaProducer[IO, OrderId, String] =
  producer.tracedWithSerializers(
    Serializer[IO, String].contramap[OrderId](_.value),
    Serializer[IO, String]
  )

Resource binding and tracer reuse

If you allocate clients through Resource, create a KafkaTracer once and bind your handles explicitly. The same tracer can also be passed to .traced(kafkaTracer) in stream syntax.

import cats.effect.Resource
import fs2.kafka.{ConsumerSettings, KafkaConsumer, KafkaProducer, ProducerSettings}
import fs2.kafka.otel4s.trace.TracedKafkaConsumer
import org.typelevel.otel4s.trace.TracerProvider

def createTracedProducer(
    producerSettings: ProducerSettings[IO, String, String]
)(implicit tracerProvider: TracerProvider[IO]): Resource[IO, TracedKafkaProducer[IO, String, String]] =
  for {
    kafkaTracer <- KafkaTracer.resource[IO](tracerConfig)
    producer <- KafkaProducer.resource[IO, String, String](producerSettings)
  } yield kafkaTracer.producer(producer)

def createTracedConsumer(
    consumerSettings: ConsumerSettings[IO, String, String]
)(implicit tracerProvider: TracerProvider[IO]): Resource[IO, TracedKafkaConsumer[IO, String, String]] =
  for {
    kafkaTracer <- KafkaTracer.resource[IO](tracerConfig)
    consumer <- KafkaConsumer.resource[IO, String, String](consumerSettings)
  } yield kafkaTracer.consumer(consumer)

Reuse traced handles for as long as their Kafka clients exist.

Metadata provider Lifetime
Provider.fromConsumer / Provider.fromProducer Retains the client and caches its identity on first lookup, including failed lookups
Provider.constant Stores supplied metadata without retaining a client

For commits, use metadata from the consumer that produced the offsets. Use ConsumerMetadata(None, None) when its identity is unavailable.

The commit batching method requires Temporal for timed flushes and Parallel for separate committers.