Summary
Bus.subscribe_all installs a plain ActiveSupport::Notifications subscriber, and ActiveSupport::Notifications re-raises subscriber exceptions. So a failing observer — logging, forwarding, metrics — propagates into Bus.emit and therefore into the emitting service.
Observed in servus 0.6.0.
Detail
# lib/servus/events/bus.rb:112-117
def subscribe_all(&block)
ActiveSupport::Notifications.subscribe(/^servus\.events\./) do |name, started, finished, id, payload|
event_name = name.delete_prefix('servus.events.').to_sym
block.call(event_name, payload, started_at: started, finished_at: finished, id: id)
end
end
ActiveSupport::Notifications::Fanout#finish collects subscriber exceptions via iterate_guarding_exceptions and then re-raises them: raise exceptions.first for a single failure, or InstrumentationSubscriberError for several. With exactly one subscriber installed it takes the single-exception branch and does not guard at all.
The result is that a subscribe_all observer is not an observer — it is load-bearing. Bus.enable_logging! uses this hook, and it is the natural hook for any "forward every event" integration (analytics, an event mesh, an audit sink). A blip in any of those fails the service that emitted the event.
There is a second, quieter consequence. Instrumenter#instrument runs handle.finish in an ensure, and when a block raises it stamps payload[:exception] and payload[:exception_object] onto the shared payload hash first. So when an invocation raises, subscribe_all observers still fire, and they receive the payload with those exception keys folded in. A forwarding observer will therefore ship a failed dispatch as an ordinary event carrying extra keys, rather than as a failure — silently corrupting whatever downstream record it feeds, unless it explicitly checks for :exception.
Suggested direction
- Guard the
subscribe_all block so an observer cannot break the emit — rescue inside the wrapper and report. Observers of a fan-out hook should be strictly non-participating.
- Document the
payload[:exception] behaviour on subscribe_all, since observers must currently opt out of failed dispatches by hand. Alternatively pass the exception explicitly as a keyword (e.g. error:) so the contract is visible in the block signature rather than hidden in the payload hash.
(1) is a small, safe change. (2) is the one that prevents quiet data corruption in forwarding integrations.
Happy to open a PR.
Summary
Bus.subscribe_allinstalls a plainActiveSupport::Notificationssubscriber, andActiveSupport::Notificationsre-raises subscriber exceptions. So a failing observer — logging, forwarding, metrics — propagates intoBus.emitand therefore into the emitting service.Observed in
servus 0.6.0.Detail
ActiveSupport::Notifications::Fanout#finishcollects subscriber exceptions viaiterate_guarding_exceptionsand then re-raises them:raise exceptions.firstfor a single failure, orInstrumentationSubscriberErrorfor several. With exactly one subscriber installed it takes the single-exception branch and does not guard at all.The result is that a
subscribe_allobserver is not an observer — it is load-bearing.Bus.enable_logging!uses this hook, and it is the natural hook for any "forward every event" integration (analytics, an event mesh, an audit sink). A blip in any of those fails the service that emitted the event.There is a second, quieter consequence.
Instrumenter#instrumentrunshandle.finishin anensure, and when a block raises it stampspayload[:exception]andpayload[:exception_object]onto the shared payload hash first. So when an invocation raises,subscribe_allobservers still fire, and they receive the payload with those exception keys folded in. A forwarding observer will therefore ship a failed dispatch as an ordinary event carrying extra keys, rather than as a failure — silently corrupting whatever downstream record it feeds, unless it explicitly checks for:exception.Suggested direction
subscribe_allblock so an observer cannot break the emit — rescue inside the wrapper and report. Observers of a fan-out hook should be strictly non-participating.payload[:exception]behaviour onsubscribe_all, since observers must currently opt out of failed dispatches by hand. Alternatively pass the exception explicitly as a keyword (e.g.error:) so the contract is visible in the block signature rather than hidden in the payload hash.(1) is a small, safe change. (2) is the one that prevents quiet data corruption in forwarding integrations.
Happy to open a PR.