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.