Skip to content

subscribe_all observer exceptions propagate into Bus.emit and into the emitting service #52

Description

@fractaledmind

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

  1. 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.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions