Module: Axn::Async::ExceptionReporting

Defined in:
lib/axn/async/exception_reporting.rb

Overview

Shared utilities for async exception reporting across adapters. Used by both Sidekiq (death handler) and ActiveJob (after_discard) to build context and trigger on_exception consistently.

Defined Under Namespace

Classes: DiscardedJobAction, DiscardedJobResult

Class Method Summary collapse

Class Method Details

.trigger_on_exception(exception:, action_class:, retry_context:, job_args:, extra_context: {}, log_prefix: "async") ⇒ Object

Triggers on_exception for an async job that has been discarded/exhausted.

Parameters:

  • exception (Exception)

    the exception that caused the discard

  • action_class (Class)

    the Axn action class

  • retry_context (RetryContext)

    the retry context

  • job_args (Hash)

    the job arguments (will be filtered)

  • extra_context (Hash) (defaults to: {})

    additional context to merge (e.g., discarded: true, _job_metadata)

  • log_prefix (String) (defaults to: "async")

    prefix for error logging (e.g., "Sidekiq death handler")



21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
# File 'lib/axn/async/exception_reporting.rb', line 21

def trigger_on_exception(exception:, action_class:, retry_context:, job_args:, extra_context: {}, log_prefix: "async")
  Axn::Extensions.best_effort("in #{log_prefix}") do
    # NOTE: deliberately NOT guarded by `_fails_on?`. This is the discard/death-handler path,
    # which only fires after a job exhausts retries or is discarded. A `fails_on` exception
    # settles as `outcome.failure?` and is never re-raised by the adapter (see the
    # `raise … if result.outcome.exception?` gate in the Sidekiq/ActiveJob `perform`), so it
    # never reaches here. Anything that does reach here either was a genuine `exception`
    # outcome (so `_fails_on?` is necessarily false) or bypassed the executor entirely (job
    # deserialization / proxy errors) — and a broad declaration like `fails_on StandardError`
    # must NOT suppress the only global report for those.

    # Filter sensitive values using the action class's internal _context_slice
    filtered_context = action_class._context_slice(data: job_args, direction: :inbound)

    # Build final context with async info (avoid mutating extra_context)
    async_extra = extra_context[:async] || {}
    context = filtered_context.merge(
      async: retry_context.to_h.merge(async_extra),
    ).merge(extra_context.except(:async))

    # Attach declared observability facets (PRO-2853) so an exhausted/discarded-job report
    # carries the same context[:tags]/context[:dimensions] as the synchronous executor path.
    # There's no settled action instance here (the run died in a prior attempt), so facets are
    # resolved best-effort against an instance reconstructed from the (deserialized) job_args:
    # input-derived facets (company_id, record ids) resolve; output-derived ones find no exposes
    # and are skipped per-facet — the same partial-resolution contract as the failure path.
    #
    # Reserve the facet keys first: unlike the sync report (where inputs nest under :inputs and
    # RESERVED_EXECUTION_CONTEXT_KEYS guards the top level), this path merges the job-arg slice
    # in at the top level, so an action with an input literally named `tags`/`dimensions` would
    # otherwise expose that user value under the framework key whenever no facet overwrites it.
    # Strip, then assign only the resolved facets (when any) — framework owns these keys.
    %i[tags dimensions].each { |key| context.delete(key) }
    facets = resolve_facets(action_class:, job_args:)
    context[:tags] = facets[:tags] if facets[:tags].any?
    context[:dimensions] = facets[:dimensions] if facets[:dimensions].any?

    # Create proxy action for the on_exception interface
    proxy_action = DiscardedJobAction.new(action_class, exception)

    # Trigger on_exception
    Axn.config.on_exception(exception, action: proxy_action, context:)
  end
end