ruby / sentry

Sentry tracks errors and exceptions across production services. The official sentry-ruby gem is a large multi-feature SDK: error capture is the smallest piece of it. The bulk of the gem's weight goes to APM tracing (transactions and spans), profiling, breadcrumbs, session tracking, release health, and dozens of integrations.

When I'm only using Sentry for error tracking (capturing exceptions and messages, attaching user and request context), a thin HTTP client covers the use case in a few hundred lines.

API

The Sentry surface I rely on:

Sentry.init(dsn: ENV["SENTRY_DSN"])
Sentry.capture_message("did something weird", extra: {...})
Sentry.capture_exception(err, extra: {...}, fingerprint: [...])
Sentry.set_user(username: "...")
Sentry.set_extras(...)

set_user and set_extras mutate per-request context that gets merged into every event captured later in the request. A Rack middleware clears the scope at request boundaries so values from one request don't leak into the next.

Module

require "http"
require "json"
require "securerandom"
require "socket"
require "uri"

module Sentry
  PROTOCOL_VERSION = "7"
  USER_AGENT = "app-sentry/1.0"
  MAX_MESSAGE_BYTES = 8192
  QUEUE_LIMIT = 1000
  WORKER_MUTEX = Mutex.new

  Config = Struct.new(:dsn, :environment, :release, :server_name, :project_root)

  class << self
    def init(dsn:, environment: ENV.fetch("APP_ENV"), release: ENV["SENTRY_RELEASE"])
      dsn = dsn.to_s.strip
      if dsn == ""
        @config = nil
        return
      end

      @config = Config.new(
        DSN.parse(dsn),
        environment.to_s,
        release,
        Socket.gethostname,
        Dir.pwd
      )
    end

    def capture_message(message, extra: {}, tags: {}, fingerprint: nil, level: "info")
      if @config.nil?
        return
      end

      event = build_event(level: level, extra: extra, tags: tags, fingerprint: fingerprint)
      event[:message] = {formatted: message.to_s.byteslice(0..MAX_MESSAGE_BYTES)}
      dispatch(event)
    end

    def capture_exception(exception, extra: {}, tags: {}, fingerprint: nil, level: "error")
      if @config.nil?
        return
      end

      event = build_event(level: level, extra: extra, tags: tags, fingerprint: fingerprint)
      event[:exception] = {
        values: [
          {
            type: exception.class.to_s,
            value: exception.message.to_s.byteslice(0..MAX_MESSAGE_BYTES),
            stacktrace: {frames: build_frames(exception.backtrace || [])}
          }
        ]
      }
      dispatch(event)
    end

    def set_user(**attrs)
      Scope.current.merge_user(attrs)
    end

    def set_extras(**attrs)
      Scope.current.merge_extras(attrs)
    end

    def clear_scope
      Scope.clear
    end
  end
end

capture_* is a no-op when init hasn't been called with a non-empty DSN. Tests stay quiet without stubbing.

The rest of this article fills in the helpers used above. Sentry::DSN and Sentry::Scope are nested inside module Sentry; the private dispatch, ensure_worker, send_event, build_event, build_frames, and build_body methods live in the class << self block alongside the public API. The SentryScope Rack middleware is a separate top-level class.

DSN

A Sentry DSN encodes the project ID, public key, and host:

https://{public_key}@{host}/{project_id}

Parse it once at boot. The parts build the envelope endpoint and signed auth header:

module Sentry
  class DSN
    attr_reader :scheme, :host, :port, :public_key, :project_id, :path

    def self.parse(dsn_string)
      uri = URI.parse(dsn_string.to_s)
      uri_path = uri.path.split("/")
      project_id = uri_path.pop.to_s

      new(
        scheme: uri.scheme,
        host: uri.host,
        port: uri.port,
        public_key: uri.user.to_s,
        project_id: project_id,
        path: uri_path.join("/")
      )
    end

    def envelope_endpoint
      "#{scheme}://#{host}#{path}/api/#{project_id}/envelope/"
    end

    def auth_header(client:, now: Time.now.utc.to_i)
      [
        "Sentry sentry_version=#{PROTOCOL_VERSION}",
        "sentry_timestamp=#{now}",
        "sentry_key=#{public_key}",
        "sentry_client=#{client}"
      ].join(", ")
    end
  end
end

Scope

The Sentry gem stores per-request user/extras in fiber-local storage. A thread-local equivalent gives the same effect:

module Sentry
  class Scope
    KEY = :sentry_scope

    def self.current
      Thread.current[KEY] ||= new
    end

    def self.clear
      Thread.current[KEY] = nil
    end

    attr_reader :user, :extras

    def initialize
      @user = {}
      @extras = {}
    end

    def merge_user(attrs)
      @user.merge!(attrs)
    end

    def merge_extras(attrs)
      @extras.merge!(attrs)
    end
  end
end

Middleware

SentryScope is a Rack middleware, not part of module Sentry. It clears the scope at request boundaries so user/extras set inside one request don't leak into the next:

class SentryScope
  def initialize(app)
    @app = app
  end

  def call(env)
    Sentry.clear_scope
    @app.call(env)
  ensure
    Sentry.clear_scope
  end
end

Plug it into the Rack stack just above the error handler so user and extras populated by the request are still on scope when an unhandled exception is captured:

use RequestLogger
use SentryScope
use ErrorHandler

Async dispatch

Sentry capture runs on the error path. The request thread shouldn't block on a slow or down Sentry. Events go onto a bounded queue; a background thread POSTs them with retries. These methods live in the class << self block from the Module section:

private def dispatch(event)
  if ENV.fetch("APP_ENV") == "test"
    send_event(event)
    return
  end

  queue = ensure_worker
  begin
    queue.push(event, true)
  rescue ThreadError
    # queue full; capture is best-effort, drop the event
  end
end

private def ensure_worker
  WORKER_MUTEX.synchronize do
    if @worker_pid != Process.pid || @worker.nil? || !@worker.alive?
      @worker_pid = Process.pid
      @queue = SizedQueue.new(QUEUE_LIMIT)
      @worker = Thread.new { worker_loop }
    end
    @queue
  end
end

private def worker_loop
  loop do
    event = @queue.pop
    send_event(event)
  end
rescue
  retry # never let the worker die on an unexpected error
end

The Process.pid check makes startup fork-safe: each Puma forked worker creates its own thread and queue rather than inheriting a dead reference from the master. The SizedQueue bound (1000 events) drops new events when full instead of backing up callers, the same failure mode as the gem's queue.

In APP_ENV=test, dispatch is synchronous so webmock can observe each request inside the test that triggered it.

Sending events

Sentry's wire format is the envelope: three newline-separated lines for envelope header, item header, and event JSON. POST it with the HTTP gem, retrying transient failures with the same delay sequence as the other clients in the series:

private def send_event(event)
  body = build_body(event)
  Err::Retry::DELAYS.each do |delay|
    resp = nil
    begin
      resp = HTTP
        .timeout(connect: 5, write: 5, read: 5)
        .headers(
          "Content-Type" => "application/x-sentry-envelope",
          "User-Agent" => USER_AGENT,
          "X-Sentry-Auth" => @config.dsn.auth_header(client: USER_AGENT)
        )
        .post(@config.dsn.envelope_endpoint, body: body)
    rescue *Err::Retry::TRANSIENT_EXCEPTIONS
      Err::Retry.backoff(delay)
      next
    end

    if Err::Retry.transient?(resp)
      Err::Retry.backoff(delay)
      next
    end

    break
  end
end

private def build_body(event)
  [
    JSON.generate({event_id: event[:event_id], sent_at: Time.now.utc.iso8601}),
    JSON.generate({type: "event", content_type: "application/json"}),
    JSON.generate(event)
  ].join("\n")
end

Retries happen inside the worker, not the caller. After all delays are exhausted, the event is dropped. Capture is best-effort, and a failed Sentry must never break the caller.

Building events

private def build_event(level:, extra:, tags:, fingerprint:)
  scope = Scope.current
  event = {
    event_id: SecureRandom.uuid.delete("-"),
    timestamp: Time.now.utc.iso8601,
    platform: "ruby",
    level: level.to_s,
    environment: @config.environment,
    server_name: @config.server_name,
    sdk: {name: "app.sentry", version: "1.0.0"}
  }

  if @config.release
    event[:release] = @config.release
  end
  if scope.user != {}
    event[:user] = scope.user
  end
  merged_extras = scope.extras.merge(extra)
  if merged_extras != {}
    event[:extra] = merged_extras
  end
  if tags != {}
    event[:tags] = tags
  end
  if fingerprint
    event[:fingerprint] = Array(fingerprint)
  end

  event
end

Stack frames

Ruby's err.backtrace is an array of strings like /path/file.rb:42:in 'method_name'. Sentry expects newest-call-last, so reverse the array. Mark frames inside the project as in_app so the UI surfaces them and folds gem frames away by default:

private def build_frames(backtrace)
  project_root = @config.project_root
  backtrace.reverse.filter_map do |line|
    m = /\A(.+?):(\d+)(?::in [`'](.+?)')?\z/.match(line)
    if m.nil?
      next
    end

    abs_path = m[1]
    lineno = m[2].to_i
    function = m[3]

    in_app = abs_path.start_with?(project_root) && !abs_path.include?("/gems/")
    filename =
      if abs_path.start_with?(project_root)
        abs_path[(project_root.length + 1)..]
      else
        abs_path
      end

    frame = {
      abs_path: abs_path,
      filename: filename,
      lineno: lineno,
      in_app: in_app
    }
    if function
      frame[:function] = function
    end
    frame
  end
end

Trade-offs

What this client gives up compared to the gem: APM tracing. No transactions, no spans, no Performance dashboard. Acceptable when only the error feed is in use.

The gem's auto-instrumentation for Rails / Sidekiq / Faraday / etc. doesn't apply here. Without those frameworks, every capture_* is already an explicit call. See ruby / web-framework for the request-cycle error handler that captures unhandled exceptions, and ruby / job-queues for the worker loop that captures failures around each job.

Tests

Tests run on a custom framework. Stub the HTTP boundary with webmock:

def test_posts_envelope
  Sentry.init(dsn: "https://abc@o123.ingest.sentry.io/456")
  captured_body = nil
  stub_request(:post, "https://o123.ingest.sentry.io/api/456/envelope/")
    .to_return { |req|
      captured_body = req.body
      {status: 200, body: ""}
    }

  Sentry.capture_message("hello", extra: {a: 1})

  lines = captured_body.split("\n")
  envelope = JSON.parse(lines[0])
  ok { envelope["event_id"].length == 32 }
  event = JSON.parse(lines[2])
  ok { event["message"]["formatted"] == "hello" }
  ok { event["extra"]["a"] == 1 }
end

Tests that don't initialize Sentry exercise the no-op path. No HTTP traffic, no stub required.

← All articles