dispatch-rails

Feed Dispatch, the AI-native ticketing system, from any Rails app. Two ways in:

  • API-only / Errors — headless server-side exception tracking for apps with no UI (API services, background workers). No widget; just automatic capture, structured error responses, and a programmatic report helper.
  • Widget — a floating 💬 feedback button for apps that have a UI, where a human reports a bug, requests a feature, or suggests a change, and the report ships with browser context.

Both modes authenticate with the same project API token and share the same error pipeline.

Install

# Gemfile
gem "dispatch-rails", "~> 0.6"

API-only / Errors mode

For an app with no UI, set mode: :errors_only. Server-side capture is on by default once configured — a Rack middleware catches unhandled request exceptions (with full request + user context) and a Rails.error subscriber catches background/job errors. The widget and browser-error tags become no-ops.

# config/initializers/dispatch.rb
Dispatch::Rails.configure do |c|
  c.mode     = :errors_only
  c.api_key  = Rails.application.credentials.dig(:dispatch, :api_key)
  c.endpoint = "https://dispatchit.app/api/v1/tickets"  # host is also used to derive the error + report URLs
  c.release  = ENV["GIT_SHA"]
  c.enabled_environments = %w[production staging]

  # Resolve the affected user from your API auth (see "Identity" below).
  c.user    = ->(ctx) { ctx.request.env["warden"]&.user&.then { { email: _1.email, external_id: _1.id } } }
  c.context = ->(ctx) { { request_id: ctx.request.request_id }.compact }

  # Structured error responses (opt-in).
  c.structured_error_responses = true
  c.annotate_error_body        = false  # headers only by default; true also merges into JSON error bodies
end

Identity (the contract that matters for API apps)

The user and context lambdas receive the controller instance (not a session). That's the seam for API auth — read whatever identifies the caller:

c.user = ->(ctx) {
  u = ctx.try(:current_user) ||
      ctx.request.env["warden"]&.user ||
      ((k = ctx.request.headers["X-API-Key"]) && User.find_by(api_key: k))
  u && { email: u.email, external_id: u.id }
}
c.context = ->(ctx) { { player_id: ctx.request.headers["X-Player-ID"] }.compact }

context returns extra tags merged into every event (your explicit context[:tags] on a manual capture still win). For background-job errors there is no controller, so both lambdas receive nil — guard accordingly.

What's captured

Each event is a Sentry-shaped payload: the exception chain with source context for in-app frames, transaction (controller#action), tags.request_id (the Rails request id), the request URL/method/headers, the resolved user, and your context tags. Request params are off by default; opt in with c.send_default_params = true (uses Rails' filtered_parameters, so your config.filter_parameters redactions apply — add a before_send for stricter scrubbing).

Process lifecycle (boot crashes, rake failures, shutdown flush)

Errors that never reach the Rack stack are still captured:

  • Crash at exit — an unhandled exception that kills the process (a boot crash in an initializer, a dying rails runner script) is reported from an at_exit hook, tagged source: at_exit. Normal exits and SIGTERM-driven graceful shutdowns are ignored.
  • Rake task failures — rake rescues the real exception itself, so the gem patches Rake::Application#display_error_message and reports the failure tagged source: rake with the failing command.
  • Shutdown flush — events are sent from a background queue; on process exit the in-progress traffic-heartbeat window is shipped and the queue is drained (up to shutdown_timeout seconds) so restarts and deploys don't drop reports — or the final window of confound-guard counts — captured moments earlier.
c.capture_at_exit  = true  # default; set false to skip the crash-at-exit report
c.shutdown_timeout = 3     # seconds to wait for the send queue to drain at exit; 0 skips

Traffic heartbeats (confound signal)

On by default in enabled environments, the SDK ships lightweight per-transaction request/error counts — one small aggregate POST per minute, regardless of request volume. Dispatch uses these so fix verification can tell "the error stopped because we fixed it" from "…because nobody hits that path anymore": a fix is only marked verified if the affected controller#action is still serving successful traffic.

c.capture_traffic        = true   # default; set false to disable
c.traffic_sample_rate    = 1.0    # independent of error_sample_rate
c.heartbeat_flush_seconds = 60    # aggregation window

No request bodies, params, or user data are sent — only counts keyed by controller#action.

Structured error responses

With c.structured_error_responses = true, any 4xx/5xx response carries:

  • X-Dispatch-Request-Id: <request id> — the correlation key
  • X-Dispatch-Report-Url: <link> — informational

This is the API-only analogue of the widget: it hands the caller an id they can quote. With c.annotate_error_body = true, the same fields (dispatch_request_id, dispatch_report_url) are merged into JSON error bodies too (RFC 7807-friendly — they sit beside type/title/detail). The middleware never changes status codes and passes through anything it can't safely parse.

Curated reports (programmatic / agent)

A consumer (or an AI agent inside your app) turns a failure into a tracked report:

Dispatch::Rails.report(
  description: "Nightly import aborted: upstream returned 502",
  severity: "high",
  correlation_id: request.request_id,   # links to the captured error
  metadata: { job: "ImportJob" }
)
# => { "id" => 123, "status" => "inbox", "url" => "https://acme.dispatchit.app/tickets/123" }

Or over HTTP, quoting the id from X-Dispatch-Request-Id:

curl -X POST https://dispatchit.app/api/v1/tickets \
  -H "Authorization: Bearer dsp_live_…" -H "Content-Type: application/json" \
  -d '{"ticket":{"description":"Checkout 500s on empty cart","severity":"high",
        "source":"api","metadata":{"correlation_id":"abc123"}}}'

Dispatch links the resulting ticket to the captured error's group. (An MCP server / CLI wrapping this is a planned agent-workflow extension.)

Manual capture

rescue => e
  Dispatch::Rails.capture_exception(e, context: { tags: { area: "import" } })
end

Not on Rails?

Any language with a Sentry SDK can point at Dispatch via a DSN — see docs/sentry-dsn.md.


Widget mode

For apps with a UI. Configure, then render the widget in your layout:

# config/initializers/dispatch.rb
Dispatch::Rails.configure do |c|
  c.api_key  = Rails.application.credentials.dig(:dispatch, :api_key)
  c.endpoint = "https://dispatchit.app/api/v1/tickets"
  c.user     = ->(ctx) { ctx.current_user&.then { { email: _1.email, external_id: _1.id } } }
  c. = ->(ctx) { { release: ENV["GIT_SHA"], env: Rails.env } }
end
<%# In your layout, e.g. on staging %>
<% if Rails.env.staging? %>
  <%= dispatch_widget_tag %>
<% end %>

Per-page severity / labels

<%= dispatch_widget_tag severity: :critical, labels: %w[checkout payments] %>

These attach to every report from that page (ticket.severity, metadata.labels).

What the widget captures

  • location.href, navigator.userAgent, viewport size, document.referrer
  • User path — the last 5 things the user clicked, as metadata.user_path (c.capture_clicks = true, the default)
  • Optional: last 20 console.error entries (c.capture_console = true)
  • Whatever your user and metadata lambdas return, plus per-tag severity/labels

It POSTs to your endpoint with Authorization: Bearer <api_key>, an auto-generated Idempotency-Key, and { ticket: { description, source: "widget", severity, reporter, metadata } }.

Browser exception tracking

Add the tracker to your layout <head> to capture uncaught JS errors and unhandled promise rejections (same project key, same user lambda for the affected-user email loop):

<%= dispatch_error_tracker_tag %>

Multiple projects per app

Each Dispatch project has its own API key. To send reports from different surfaces to different projects, create multiple initializers or wrap Dispatch::Rails.configuration.api_key with a per-request override.