OpenTelemetry tracing
The fs2-rabbit-otel4s module adds otel4s spans and trace-context propagation to
publishers and consumers without requiring the OpenTelemetry Java agent.
libraryDependencies += "dev.profunktor" %% "fs2-rabbit-otel4s" % Version
Create a RabbitTracer from the application’s otel4s TracerProvider, then use
its traced client for publishers and consumers. The tracer should normally be
created once alongside the RabbitClient:
import cats.effect.Async
import cats.syntax.all.*
import dev.profunktor.fs2rabbit.interpreter.RabbitClient
import dev.profunktor.fs2rabbit.model.*
import dev.profunktor.fs2rabbit.otel4s.*
import fs2.Stream
import org.typelevel.otel4s.trace.TracerProvider
def program[F[_]: Async: TracerProvider](client: RabbitClient[F])(implicit
channel: AMQPChannel
): F[Unit] =
RabbitTracer
.resource[F](
RabbitTracer.Config.default
.withServerAddress("rabbitmq.example.com", Some(5672))
.withClientId("orders-service")
)
.use { rabbitTracer =>
val traced = rabbitTracer.client(client)
for {
publish <- traced.createPublisher[String](ExchangeName("orders"), RoutingKey("created"))
_ <- publish("order-123")
} yield ()
}
The encoder and decoder instances are the same ones used by the regular
RabbitClient API. All publisher variants and the auto-ack and manual-ack consumer
constructors have traced equivalents.
Publishing
Create publishers from the TracedRabbitClient in the same way as from a regular
client. Each invocation of the returned function creates a publish span and
injects a creation context into the AMQP headers when the message does not
already carry one:
def publish[F[_]: Async](client: TracedRabbitClient[F])(implicit
channel: AMQPChannel
): F[Unit] =
for {
publish <- client.createPublisher[String](
ExchangeName("orders"),
RoutingKey("created")
)
_ <- publish("order-123")
} yield ()
The routing publisher, basic publisher, publishing flags, and returned-message
listener variants are also available on TracedRabbitClient.
Consuming
recordsWithProcess places a process span around the effect returned for each
message:
def consume[F[_]: Async](client: TracedRabbitClient[F])(implicit
channel: AMQPChannel
): F[Unit] =
for {
consumer <- client.createAutoAckConsumer[String](QueueName("order-workers"))
_ <- consumer
.recordsWithProcess { envelope =>
Async[F].delay(println(envelope.payload))
}
.compile
.drain
} yield ()
For manual acknowledgement, use createAckerConsumer or
createAckerConsumerWithMultipleFlag. Acknowledgement and rejection use the
regular fs2-rabbit functions returned by those constructors and do not create
additional spans.
Use records and consumer.process(envelope)(effect) when the processing
boundary needs to be placed manually:
def processManually[F[_]: Async, A](
consumer: TracedRabbitConsumer[F, A],
handle: AmqpEnvelope[A] => F[Unit]
): Stream[F, Unit] =
consumer.records.evalMap(envelope => Async[F].defer(consumer.process(envelope)(handle(envelope))))
Syntax
Importing dev.profunktor.fs2rabbit.otel4s.syntax.* enables two convenience
extensions. They do not change tracing behavior; they are shorter forms of the
regular API:
import cats.effect.Async
import dev.profunktor.fs2rabbit.otel4s.syntax.*
def tracedClient[F[_]](client: RabbitClient[F], rabbitTracer: RabbitTracer[F]): TracedRabbitClient[F] =
client.traced(rabbitTracer)
processTraced is useful with a manually chosen processing boundary. It requires
the corresponding TracedRabbitConsumer to be in implicit scope:
def processWithSyntax[F[_]: Async, A](
consumer: TracedRabbitConsumer[F, A],
handle: AmqpEnvelope[A] => F[Unit]
): Stream[F, Unit] = {
implicit val tracedConsumer: TracedRabbitConsumer[F, A] = consumer
consumer.records.evalMap(envelope => Async[F].defer(envelope.processTraced(handle(envelope))))
}
recordsWithProcess is usually simpler when the whole evalMap operation should
be traced.
Span model
| Operation | Span name | Kind | Parent and links |
|---|---|---|---|
| Publish a message without creation context | publish <destination> |
PRODUCER |
The ambient span is the parent. A new creation context is injected into the AMQP headers. |
| Publish a message with creation context | publish <destination> |
CLIENT |
The ambient span is the parent. The message creation context is preserved and linked. |
| Process a delivered message | process <destination> |
CONSUMER |
Root by default; optionally parented to the currently active span. The message creation context is linked when present. |
A message published without an existing creation context gets a PRODUCER span,
and that span’s context is injected into the message headers. If the message
already contains valid trace context, the headers are preserved and publishing
gets a CLIENT span linked to that context.
Processing creates one CONSUMER span per delivered message and links it to
the creation context in the message headers. Root parenting is the default; set
withProcessSpanParentingPolicy(RabbitTracer.ProcessSpanParentingPolicy.Ambient)
to use the currently active span as parent. RabbitMQ pushes messages to consumers,
so the module emits process spans rather than receive spans.
The destination is a colon-separated combination of exchange, routing key, and,
for processing, queue name, with empty components removed. The default exchange
and an empty routing key are represented as amq.default.
Generated queue destinations use a low-cardinality destination template in the
default span name. RabbitMQ names beginning with amq.gen- use amq.gen-*,
names beginning with spring.gen- use spring.gen-*, and UUID-shaped names use
{queue_id}. RabbitMQ Direct Reply-To addresses beginning with
amq.rabbitmq.reply-to. use amq.rabbitmq.reply-to.*. For example,
orders:created:amq.gen-4p09x produces the process span name
process orders:created:amq.gen-*. A publish routed directly to
amq.gen-4p09x through the default exchange uses publish amq.gen-*; a Direct
Reply-To address such as amq.rabbitmq.reply-to.ab12 uses
publish amq.rabbitmq.reply-to.*. The span retains the actual value in
messaging.destination.name and records the normalized value in
messaging.destination.template.
If a configured classifier returns no template for a known generated queue, the
span name omits the destination rather than using its anonymous name, following
the OpenTelemetry span naming rules. The actual destination remains on the span
as messaging.destination.name, and messaging.destination.anonymous=true
remains set.
Publishing to a named exchange does not infer a generated queue from the routing key alone; the routing key remains part of the destination name and span name.
Semantic attributes
Every span has messaging.system=rabbitmq, messaging.operation.name,
messaging.operation.type, and messaging.destination.name. When applicable it
also has:
messaging.rabbitmq.destination.routing_keymessaging.message.idandmessaging.message.conversation_idmessaging.rabbitmq.message.delivery_tagon process spansmessaging.destination.anonymous=truefor queue names recognized by the built-in generated queue patterns on process spans and direct default-exchange publishes; a custom template match alone does not set this attributemessaging.destination.templatefor generated queue destinations, also used in default process and publish span names- configured
messaging.client.id,server.address, andserver.port
Links repeat the destination, routing key, message id, and destination template when available. Process links also include the delivery tag. Body and envelope size attributes are omitted because the public fs2-rabbit APIs do not expose a reliable encoded envelope size at every instrumented boundary.
Customization
The defaults follow the current OpenTelemetry RabbitMQ messaging semantic
conventions. Span names, extra attributes, and finalization behavior can be
customized with RabbitTracer.Config.withPublishSpanSetup and
withProcessSpanSetup. Use RabbitTracer.noop when tracing is disabled.
Queue name templates are selected by QueueNameTemplateClassifier. The default
classifier composes the built-in RabbitMQ generated queue, Spring generated
queue, Direct Reply-To, and UUID classifiers in order. Use matching to add an
application pattern; classifiers return the first template they recognize:
def config: RabbitTracer.Config = {
val queueTemplates =
QueueNameTemplateClassifier
.matching {
case queue if queue.value.startsWith("reply-") => "reply-{id}"
}
.orElse(QueueNameTemplateClassifier.default)
RabbitTracer.Config.default
.withQueueNameTemplateClassifier(queueTemplates)
}
withQueueNameTemplateClassifier replaces the configured classifier. Compose
with QueueNameTemplateClassifier.default to retain the built-in patterns.
Use the individual rabbitMqGeneratedQueue, springGeneratedQueue,
rabbitMqDirectReplyTo, and uuidQueue classifiers to choose a different set
of defaults.
Do not enable overlapping RabbitMQ Java-agent instrumentation for the same client; doing so can create duplicate messaging spans.