Class: AceMQ::AMQP::Telemetry::OpenTelemetry
- Inherits:
-
Object
- Object
- AceMQ::AMQP::Telemetry::OpenTelemetry
- Defined in:
- lib/acemq/amqp/telemetry/open_telemetry.rb
Overview
Emits OpenTelemetry spans for publishes and deliveries.
tracing = AceMQ::AMQP::Telemetry::OpenTelemetry.new
tracing.install(mq)
The point of tracing a message system is the join: the span covering a
handler must be a child of the span that published the message, even
though the two ran in different processes minutes apart. That is what
the traceparent header carries, and why it is written on the way out
and read on the way in.
It is an interceptor, not a telemetry observer
AceMQ::AMQP::Telemetry here counts things — three methods, no dependency, and a Registry for when the numbers are all that is wanted. Tracing is a different shape: a span wraps a publish or a handler, so it has to know when one starts and when it ends, and it has to be able to change the message on the way out. Interceptors is exactly that seam, and it is public, so everything below could have been written outside this gem.
Register on both sides, which #install does:
mq.intercept_publish(tracing)
mq.intercept_consume(tracing)
The names on the wire
traceparent and tracestate, which are the W3C names and are
deliberately not x-acemq- prefixed: other tooling already knows
them, and renaming them would make this library's traces invisible to
everything that did not know to look. The Java library writes the same
two, so a Ruby consumer joins a Java producer's trace without either
side being configured for the other.
Span names, kinds and attributes
Shared with the Java adapter, which follows the OpenTelemetry messaging conventions:
<destination> publish PRODUCER
<queue> process CONSUMER
<destination> request CLIENT
CLIENT for a request rather than PRODUCER because that span waits for an answer, and its duration means something different as a result — a reader who cannot tell the two apart cannot tell a slow broker from a slow responder.
Events rather than spans
A retry, a dead letter, an outbox failure and a finished pipeline run are events on the span that is already open. A zero-length span at the end of a trace adds a row and no information.
The gem
opentelemetry-api is required when one of these is built, not when
this file is read, and the gem declares no runtime dependencies at all.
A process that publishes messages and traces nothing installs nothing.
Defined Under Namespace
Classes: Scope
Constant Summary collapse
- INSTRUMENTATION_NAME =
What the tracer is registered under, in every AceMQ library.
"org.acemq.amqp"- TRACEPARENT =
The W3C names, which are what makes a trace legible to anything else.
"traceparent"- TRACESTATE =
"tracestate"- SYSTEM =
The attributes, shared with the Java adapter.
"messaging.system"- DESTINATION =
"messaging.destination.name"- OPERATION =
"messaging.operation"- MESSAGE_ID =
"messaging.message.id"- CONVERSATION_ID =
"messaging.message.conversation_id"- ROUTING_KEY =
"messaging.rabbitmq.destination.routing_key"- MESSAGE_TYPE =
"messaging.acemq.message_type"- ATTEMPT =
"messaging.acemq.attempt"- OUTCOME =
"messaging.acemq.outcome"- REASON =
"messaging.acemq.reason"- OUTBOX_LAG =
"messaging.acemq.outbox_lag_ms"- FAILED =
What a failure is called when nothing more specific was said. The same word the counters use, which is the whole point of having one.
"failed"- FAILING_OUTCOMES =
The outcomes that make a span an error, and the ones that do not.
retriedandrejectedare deliberately absent. A message that will be tried again has not failed yet, and a message the handler refused on purpose is the system working; marking either as an error is how a trace view fills with red and stops meaning anything.parkedis deliberately absent, for the same reasonrejectedis: a handler that parks a message meant to, and a decision somebody made is not a failure. Go and Python leave it out too, so a parked span reads the same in all three. The parking queue still has to be looked at -- that is whatacemq.consume.totaltaggedoutcome=parkedis for.refusedis absent for a different reason: it is not the word that makes a refused publish's span red, the exception is, recorded by Scope#failed with the cause in the description. Python's adapter does the same, and the set stays the same as Java's. %w[unroutable failed dead_lettered].freeze
- ORDER =
Runs before every other interceptor on the way in and after every other one on the way out, so a span covers whatever they do.
-1000
Instance Attribute Summary collapse
-
#tracer ⇒ Object
readonly
The tracer this emits through, for a caller that wants to open a span of its own inside one of these.
Class Method Summary collapse
-
.clean(attributes) ⇒ Object
private
Attributes OpenTelemetry will take: nothing nil, nothing empty.
-
.load_api! ⇒ Object
private
Reaches for the gem, and says which one when it is not there.
-
.outcome_of(ack) ⇒ String
What an ack alone is called on a span.
-
.timed_out?(failure) ⇒ Boolean
Whether a failure is a request that went unanswered.
Instance Method Summary collapse
-
#after_confirm(_context) ⇒ Object
The broker has the message.
-
#after_handle(context, ack) ⇒ Object
The handler has returned or raised, and the delivery has not been settled yet — which is the last moment the outcome and the span are both in hand.
-
#before_handle(context) ⇒ Object
Opens the handler span, joined to whoever published the message.
-
#before_publish(context) ⇒ Object
Opens the publish span and writes the trace into the message.
-
#consume_started(queue:, envelope:) ⇒ Scope
Opens the span covering a handler, joined to the publish that caused it.
-
#initialize(tracer_provider: nil, propagator: nil, transport: "rabbitmq") ⇒ OpenTelemetry
constructor
A new instance of OpenTelemetry.
-
#install(connection) ⇒ OpenTelemetry
Registers on both sides of a connection.
- #message_dead_lettered(queue:, envelope:, reason:) ⇒ Object
- #message_retried(queue:, envelope:, delay: nil) ⇒ Object
-
#on_error(context, failure) ⇒ Object
A publish that never reached the broker or reached no queue, or a handler that raised.
- #order ⇒ Object
- #outbox_publish_failed(exchange:, reason:) ⇒ Object
-
#outbox_published(lag:) ⇒ Object
Records how far behind the outbox relay is running.
- #pipeline_run_finished(pipeline:, step:, outcome:, age: nil) ⇒ Object
-
#propagation_headers ⇒ Hash{String=>String}
The current trace context, as headers.
-
#publish_started(exchange:, routing_key:, envelope:) ⇒ Scope
Opens the span covering a publish, and makes it current.
-
#request(destination, envelope: nil) ⇒ Object
#request_started with the closing done for you.
-
#request_started(destination:, envelope: nil) ⇒ Scope
Opens the span covering a request that waits for an answer.
Constructor Details
#initialize(tracer_provider: nil, propagator: nil, transport: "rabbitmq") ⇒ OpenTelemetry
Returns a new instance of OpenTelemetry.
136 137 138 139 140 141 142 143 144 145 146 147 |
# File 'lib/acemq/amqp/telemetry/open_telemetry.rb', line 136 def initialize(tracer_provider: nil, propagator: nil, transport: "rabbitmq") self.class.load_api! @tracer = (tracer_provider || ::OpenTelemetry.tracer_provider) .tracer(INSTRUMENTATION_NAME, AceMQ::AMQP::VERSION) @propagator = propagator || ::OpenTelemetry.propagation @transport = transport # Per instance and per fiber, so two adapters on one connection do not # pop each other's spans and a handler running on another thread has # its own. @publishing = :"acemq_otel_publish_#{object_id}" @consuming = :"acemq_otel_consume_#{object_id}" end |
Instance Attribute Details
#tracer ⇒ Object (readonly)
The tracer this emits through, for a caller that wants to open a span of its own inside one of these.
151 152 153 |
# File 'lib/acemq/amqp/telemetry/open_telemetry.rb', line 151 def tracer @tracer end |
Class Method Details
.clean(attributes) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Attributes OpenTelemetry will take: nothing nil, nothing empty.
An empty string is an attribute that says nothing and costs a column in every backend that stores one. The Java adapter writes them because its API will not take a null; Ruby's will not take a nil either, and leaving the key out entirely is the better answer.
443 444 445 |
# File 'lib/acemq/amqp/telemetry/open_telemetry.rb', line 443 def self.clean(attributes) attributes.reject { |_, value| value.nil? || value == "" } end |
.load_api! ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Reaches for the gem, and says which one when it is not there.
The gemspec declares no runtime dependencies on purpose, so this is the first moment anybody finds out. "cannot load such file -- opentelemetry-api" does not say which library wanted it or what to do about it.
456 457 458 459 460 461 462 463 464 |
# File 'lib/acemq/amqp/telemetry/open_telemetry.rb', line 456 def self.load_api! require "opentelemetry-api" rescue LoadError => e raise DependencyMissing, "the AceMQ OpenTelemetry adapter needs the opentelemetry-api gem, which is " \ "not installed. Add `gem \"opentelemetry-api\", \"~> 1.8\"` to your Gemfile " \ "— this gem declares no runtime dependencies, so a process that traces " \ "nothing installs nothing. (#{e.})" end |
.outcome_of(ack) ⇒ String
What an ack alone is called on a span.
The same words the Java adapter writes, plus parked for the action
Java's vocabulary does not have. dead_lettered rather than retried
for a retry marked fatal, because that is what the consumer will
actually do with it — honouring the mark rather than the request is
the entire point of having it.
This is the fallback. What an ack cannot know is whether a retry has any attempts left, so a Settlement on the context wins over it; see #after_handle.
425 426 427 428 429 430 431 432 433 |
# File 'lib/acemq/amqp/telemetry/open_telemetry.rb', line 425 def self.outcome_of(ack) return "acked" if ack.accept? return "rejected" if ack.reject? return "parked" if ack.park? return "in_progress" if ack.in_progress? return "dead_lettered" if ack.error.is_a?(FatalError) "retried" end |
.timed_out?(failure) ⇒ Boolean
Whether a failure is a request that went unanswered.
Asked with defined? rather than named in a rescue clause, because
Patterns::RequestTimedOut lives in the patterns file and a process
that traces without ever asking anybody a question need not have
loaded it — and a rescue naming a constant that is not there raises
a NameError in place of the exception the caller threw.
405 406 407 408 409 |
# File 'lib/acemq/amqp/telemetry/open_telemetry.rb', line 405 def self.timed_out?(failure) return false unless defined?(Patterns::RequestTimedOut) failure.is_a?(Patterns::RequestTimedOut) end |
Instance Method Details
#after_confirm(_context) ⇒ Object
The broker has the message.
335 336 337 |
# File 'lib/acemq/amqp/telemetry/open_telemetry.rb', line 335 def after_confirm(_context) finish(pop(@publishing), "confirmed") end |
#after_handle(context, ack) ⇒ Object
The handler has returned or raised, and the delivery has not been settled yet — which is the last moment the outcome and the span are both in hand.
The outcome comes off the context's Settlement when there is one,
because that is what the consumer is about to do; the ack is only what
the handler asked for. A retry on the last attempt is a dead letter,
and reading it off the ack is how a dead-lettered message ends up with
outcome=retried on its span and nothing in the trace to say it was
dropped. The ack is still the fallback, for anything driving this
interceptor without a consumer behind it.
356 357 358 359 360 361 362 363 364 365 366 |
# File 'lib/acemq/amqp/telemetry/open_telemetry.rb', line 356 def after_handle(context, ack) scope = pop(@consuming) return context if scope.nil? settlement = settlement_of(context) outcome = settlement&.outcome || self.class.outcome_of(ack) note(context, ack, settlement, outcome) scope.failed(ack.error) if ack.error.is_a?(Exception) finish(scope, outcome) context end |
#before_handle(context) ⇒ Object
Opens the handler span, joined to whoever published the message.
340 341 342 343 |
# File 'lib/acemq/amqp/telemetry/open_telemetry.rb', line 340 def before_handle(context) push(@consuming, consume_started(queue: context.queue, envelope: context.envelope)) context end |
#before_publish(context) ⇒ Object
Opens the publish span and writes the trace into the message.
The headers are set through the context rather than on the envelope directly, so what goes on the wire is what every other interceptor sees too — and so an interceptor registered after this one can read the trace it is part of.
326 327 328 329 330 331 332 |
# File 'lib/acemq/amqp/telemetry/open_telemetry.rb', line 326 def before_publish(context) scope = publish_started(exchange: context.exchange, routing_key: context.routing_key, envelope: context.envelope) push(@publishing, scope) propagation_headers.each { |name, value| context.set_header(name, value) } context end |
#consume_started(queue:, envelope:) ⇒ Scope
Opens the span covering a handler, joined to the publish that caused it.
The parent comes out of the message's own headers rather than out of whatever this thread happened to be doing, which is the whole point: the publish happened in another process, possibly minutes ago, and nothing in this one remembers it. Ambient context is the fallback and not the source, the same way round as the Java adapter.
195 196 197 198 199 200 201 202 |
# File 'lib/acemq/amqp/telemetry/open_telemetry.rb', line 195 def consume_started(queue:, envelope:) parent = @propagator.extract(carrier_from(envelope.headers), context: ::OpenTelemetry::Context.current) start("#{queue} process", :consumer, { SYSTEM => @transport, DESTINATION => queue.to_s, OPERATION => "process", ATTEMPT => envelope.attempt.to_i, **envelope_attributes(envelope) }, parent: parent) end |
#install(connection) ⇒ OpenTelemetry
Registers on both sides of a connection.
157 158 159 160 161 |
# File 'lib/acemq/amqp/telemetry/open_telemetry.rb', line 157 def install(connection) connection.intercept_publish(self) connection.intercept_consume(self) self end |
#message_dead_lettered(queue:, envelope:, reason:) ⇒ Object
312 313 314 315 316 |
# File 'lib/acemq/amqp/telemetry/open_telemetry.rb', line 312 def (queue:, envelope:, reason:) record("message.dead_lettered", { DESTINATION => queue.to_s, ATTEMPT => envelope.attempt.to_i, REASON => reason.to_s }) end |
#message_retried(queue:, envelope:, delay: nil) ⇒ Object
302 303 304 305 306 |
# File 'lib/acemq/amqp/telemetry/open_telemetry.rb', line 302 def (queue:, envelope:, delay: nil) attributes = { DESTINATION => queue.to_s, ATTEMPT => envelope.attempt.to_i } attributes["messaging.acemq.retry_delay_ms"] = (delay * 1000).to_i if delay record("message.retried", attributes) end |
#on_error(context, failure) ⇒ Object
A publish that never reached the broker or reached no queue, or a handler that raised.
On the publish side this is the only hook that runs, so the span is
closed here. On the consume side after_handle still runs afterwards
and closes it, so this records the exception and leaves it open.
A message the broker took and could not route is unroutable rather
than failed, and carries the broker's own words as the reason. Both
are errors — a publish that went nowhere is not a success — but they
send whoever is reading the trace to two different places, and Go
splits its publish span the same way for the same reason.
380 381 382 383 384 385 386 387 388 389 390 391 392 393 |
# File 'lib/acemq/amqp/telemetry/open_telemetry.rb', line 380 def on_error(context, failure) if context.is_a?(PublishContext) scope = pop(@publishing) outcome = Telemetry::Outcome.of_publish_failure(failure) if scope && outcome == Telemetry::Outcome::UNROUTABLE scope.span.set_attribute(REASON, failure..to_s) end scope&.failed(failure) finish(scope, outcome) else peek(@consuming)&.failed(failure) end context end |
#order ⇒ Object
163 |
# File 'lib/acemq/amqp/telemetry/open_telemetry.rb', line 163 def order = ORDER |
#outbox_publish_failed(exchange:, reason:) ⇒ Object
266 267 268 269 |
# File 'lib/acemq/amqp/telemetry/open_telemetry.rb', line 266 def outbox_publish_failed(exchange:, reason:) record("outbox.publish_failed", { DESTINATION => exchange.to_s, REASON => reason.to_s }) end |
#outbox_published(lag:) ⇒ Object
Records how far behind the outbox relay is running.
An attribute on the current span rather than an event, and the Java, Go and Python adapters write the same one: it measures the publish that is happening, not something that happened during it. Nothing when no span is open, for the same reason #outbox_publish_failed records nothing then.
280 281 282 283 284 285 286 |
# File 'lib/acemq/amqp/telemetry/open_telemetry.rb', line 280 def outbox_published(lag:) span = ::OpenTelemetry::Trace.current_span return unless span.recording? span.set_attribute(OUTBOX_LAG, (lag * 1000).to_i) nil end |
#pipeline_run_finished(pipeline:, step:, outcome:, age: nil) ⇒ Object
292 293 294 295 296 297 |
# File 'lib/acemq/amqp/telemetry/open_telemetry.rb', line 292 def pipeline_run_finished(pipeline:, step:, outcome:, age: nil) attributes = { "pipeline" => pipeline.to_s, "step" => step.to_s, "outcome" => outcome.to_s } attributes["messaging.acemq.run_age_ms"] = (age * 1000).to_i if age record("pipeline.run_finished", attributes) end |
#propagation_headers ⇒ Hash{String=>String}
The current trace context, as headers.
For anything publishing outside this library — an outbox relay writing rows, a scheduled job — that still wants its messages to join the trace it is running in.
256 257 258 259 260 |
# File 'lib/acemq/amqp/telemetry/open_telemetry.rb', line 256 def propagation_headers carrier = {} @propagator.inject(carrier, context: ::OpenTelemetry::Context.current) carrier end |
#publish_started(exchange:, routing_key:, envelope:) ⇒ Scope
Opens the span covering a publish, and makes it current.
173 174 175 176 177 178 179 180 181 |
# File 'lib/acemq/amqp/telemetry/open_telemetry.rb', line 173 def publish_started(exchange:, routing_key:, envelope:) # The exchange is the destination when there is one. Publishing # through the default exchange has no exchange to name, and the # routing key is the queue, which is what a reader wants to see. destination = exchange.to_s.empty? ? routing_key.to_s : exchange.to_s start("#{destination} publish", :producer, { SYSTEM => @transport, DESTINATION => destination, OPERATION => "publish", ROUTING_KEY => routing_key.to_s, **envelope_attributes(envelope) }) end |
#request(destination, envelope: nil) ⇒ Object
#request_started with the closing done for you.
225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 |
# File 'lib/acemq/amqp/telemetry/open_telemetry.rb', line 225 def request(destination, envelope: nil) scope = request_started(destination: destination, envelope: envelope) answer = yield scope.outcome("answered") answer rescue StandardError => e # +timed_out+ rather than +failed+ for a request nobody answered in # time, and deliberately not an error: that is the absence of a reply # rather than a failure of this process — usually a responder's queue # being long — and the Java and Go adapters both write it without # marking the span red, because a trace view that fills with red for # slow responders stops meaning anything. The exception still reaches # the caller, who is free to decide it was one. if self.class.timed_out?(e) scope&.outcome("timed_out") else scope&.outcome("failed") scope&.failed(e) end raise ensure scope&.close end |
#request_started(destination:, envelope: nil) ⇒ Scope
Opens the span covering a request that waits for an answer.
tracing.request("pricing.quote") { requester.call(order) }
CLIENT rather than PRODUCER, because a reader has to know that this span's duration includes somebody else's work.
214 215 216 217 218 |
# File 'lib/acemq/amqp/telemetry/open_telemetry.rb', line 214 def request_started(destination:, envelope: nil) start("#{destination} request", :client, { SYSTEM => @transport, DESTINATION => destination.to_s, OPERATION => "request", **envelope_attributes(envelope) }) end |