sixty (Ruby)
Zero-configuration performance drift detection for Ruby and Rails services.
# Gemfile
gem 'sixty'
export SIXTY_API_KEY=sixty_sk_…
export SIXTY_SERVICE=checkout-api
export SIXTY_RELEASE=$(git rev-parse --short HEAD)
That is the install. In a Rails application the railtie adds the middleware,
subscribes to sql.active_record and wraps controller actions on boot — there
is no initializer to write and nothing to call.
Outside Rails:
require 'sixty'
Sixty.init
use Sixty::Instrument::Rack # config.ru
What it reports
Not latency alone. Shape — the numbers that barely move on a warm development database and take production down a week later:
| signal | the question it answers |
|---|---|
| rows per call | did this query start returning 30,000 rows instead of 30? |
| queries per call | did this method start issuing 20 queries instead of 1? |
| round trips per call | did this read start fetching in three hundred batches? |
| self time | is my code slower, or is something I call slower? |
| query plan | did this statement stop using its index? |
| bytes | did the payload go from 4KB to 2MB? |
Each is recorded per operation, per release. The collector compares one release
against the release before it — which is why SIXTY_RELEASE matters more than
any other setting here. Without it there is no "before".
Which database you use
Postgres, MySQL and MongoDB, and nothing to configure for any of them.
| client | how it is picked up | rows means | plans |
|---|---|---|---|
| ActiveRecord (any adapter) | automatic | row_count from the adapter |
Postgres only |
pg |
automatic, outside Rails | ntuples, or cmd_tuples for a write |
no |
mysql2 / trilogy |
automatic, outside Rails | result count, or affected_rows |
no |
mongo (and Mongoid) |
automatic | documents returned, or affected | no |
ActiveRecord excludes the raw drivers, deliberately. sql.active_record and
a patched PG::Connection see the same query — the adapter runs it through the
driver — so installing both would count every query in a Rails app twice. A
doubled db_calls is not a visible error; it is a plausible number that makes
every fanout finding wrong by a factor of two. The subscriber wins wherever
ActiveRecord is in the process, and the driver patches cover everything else:
Sinatra, Sequel, ROM, workers, scripts. SIXTY_INSTRUMENT=active_record,pg
overrides that if your app really does issue queries down both paths.
Prepared statements are covered on both pg and mysql2: the text is
remembered where it is prepared and looked up where it is executed, because
statement.execute(id) carries no SQL and an application that uses them would
otherwise report no database calls at all.
Installation is retried on every flush, because a driver is a constant that may
not exist yet: Sixty.init before require 'mysql2' would otherwise report no
queries for the life of the process, silently.
What is not covered
Stated plainly, because a blind spot nobody wrote down is the failure this project exists to prevent:
pg's asynchronous API (send_query+get_result). The synchronous family —exec,exec_params,exec_prepared,async_exec— is patched; pairing a send with a later get is stateful and is not attempted.- Fiber schedulers (Falcon, the
asyncgem). Context is fiber-local, so a span opened in one fiber is not visible in a fiber spawned from it: counts and durations stay correct, parent/child edges are lost. Thread-per-request servers — Puma, Unicorn, Sidekiq — are unaffected. - Query plans outside ActiveRecord, which needs a connection pool to borrow from. See below.
Sixty.instrument(Klass, :method)wraps the methods you name and does not follow ones defined later.
MySQL is read by different rules, not the same ones
"alice@example.com" is a quoted identifier in Postgres — schema, kept, and
keeping it is what makes select "userId" from t readable in the feed — and in
MySQL's default sql_mode the same bytes are a string literal, which is
exactly the PII this agent exists never to transmit. Backticks are the mirror
image. So the dialect is asked of the adapter rather than guessed from the text,
and it decides which lexer runs.
MongoDB has no statement, so identity is the shape
There is nothing to strip literals from: a filter is a tree where the values sit beside the keys. So the identity is built from keys only, and a value has no path into it at all:
find orders {filter{user_id},limit,sort{created_at}}
aggregate orders [$match{status}][$lookup{from}][$unwind]
insert orders {documents{email,total,user_id}}
{_id: {$in: [...]}} with three ids and with three thousand is one operation —
the same fold in (?, ?, ?) → in (?) performs for SQL. A pipeline is the
opposite case: every stage counts, and in order, because collapsing it would
hide the $lookup that turned one query into an N+1.
A cursor is one operation, however many batches it took. A find returning
thirty thousand documents at the default batch size is the initial command plus
roughly three hundred getMore round trips, each a network wait. Recording each
of those as its own operation would tell you your method makes three hundred
database calls — the signature of an N+1 your code does not contain — and send
you looking for a loop when the fix is batch_size. So the span stays open
until the cursor is drained: db_calls says one call, rows says thirty
thousand, and round_trips says three hundred, which is its own finding with
its own fix.
Batch size is deliberately not part of the identity, for the same reason:
setting it is the change the round-trips signal exists to report, and an
identity that moved with it would leave the detector with nothing to compare.
limit is part of the identity, because a limit changes what you asked for.
The instrumentation is the driver's own command-monitoring API rather than a
monkey patch, which is only possible because the Ruby driver is synchronous:
events are published on the calling thread, so a query still knows which method
issued it. (@sixty-sh/node cannot do this — in Node those events fire after the
caller's async context is gone, so it patches the collection instead.)
Why MySQL and MongoDB get no query plans
An index that stopped being used is the highest-value finding this agent could
produce, and it is still refused for both. Postgres has EXPLAIN (GENERIC_PLAN),
which plans a statement without binding a parameter — there is no step at
which a value could enter it. MySQL and MongoDB can only explain a query that
still has its values in it, so capturing a plan there would mean retaining
somebody's data in order to compose a command out of it. The same refusal, for
the same reason, in all three agents.
Postgres plans are also refused for a statement that arrived carrying literals — what an app with prepared statements disabled produces — because this agent explains on the flush thread rather than in front of a user, and queueing such a statement would mean holding those values until the next flush.
Your own code
Controllers and queries are instrumented automatically. Service objects, query objects and jobs — the layer where an N+1 is actually born — are one line:
class OrdersQuery
include Sixty::Instrumented
def for_user(id) = Order.where(user_id: id).limit(30).to_a
def enrich(orders) = ...
end
Every public instance method becomes an operation, with its queries and rows attributed to it. Private methods are left alone, accessors are skipped, and arguments, keyword arguments, blocks, return values, raised exceptions and method visibility pass through unchanged — there is a test for each of those, because an instrumentation layer that changes program behaviour is unshippable.
For a class you do not own:
Sixty.instrument(Stripe::Charge, :create)
And for anything else:
Sixty.trace('nightly-reconciliation') { ... }
Sixty.annotate(:rows, results.length)
What it costs
Measured, not asserted:
| overhead | |
|---|---|
a traced method (Sixty::Instrumented) |
~3.5µs per call |
| a query, recorded and rolled up | ~4µs |
pg query, end to end against a real server |
+12–16µs |
mysql2 / trilogy query |
+13–14µs |
mongo command |
+27–30µs |
The per-call number is asserted by test/safety_test.rb, which fails if it
becomes milliseconds. The driver numbers are paired interleaved A/B samples
against live servers — every measurement is (one query without the agent, one
with, back to back), reported as the median difference, with an A/A control run
to prove the harness has no bias of its own. Against a local query that takes
300–500µs, that is a few percent; against a Rails request it was not
measurable — 731 rps without the agent and 797 with it, i.e. inside the noise.
Two of those numbers started five times worse, which is the reason the
benchmark exists at all: naming an operation (select:orders) rebuilt regular
expressions on every query, and estimating a result's size rendered five rows to
strings. Both are now computed once per statement and read from libpq
respectively.
Nothing else happens on the request path. Spans go into an in-process rollup
capped at 2,000 operations; a background thread posts a window every 15 seconds.
A stack trace is captured once per query, ever, because a call site is a
property of the statement rather than of the call. EXPLAIN runs on the flush
thread on a connection of its own, never in front of a user, and never
EXPLAIN ANALYZE.
What happens when the collector is down
Nothing, to your application:
- the request path never opens a socket to the collector
- a failed flush is swallowed, logged at most once a minute, and backs off exponentially to a five-minute ceiling
- the undeliverable window is dropped, so the agent's memory is bounded by your application's shape rather than by somebody else's uptime
- shutdown waits at most two seconds for a final flush
- an exception anywhere inside the agent is caught before it reaches your code —
the Rack middleware is written so that the only unguarded line in it is
@app.call(env)
test/safety_test.rb asserts all of the above, including that a deliberately
broken agent still returns 200.
What never leaves the process
Raw SQL does not, and neither does a Mongo filter's contents. Statements are reduced to their literal-free shape before
anything is recorded (select * from users where email = ?), which is also what
keeps where id = 1 and where id = 99 from becoming two operations. Query
plans come from EXPLAIN (GENERIC_PLAN), which plans a parameterised statement
without ever binding a parameter, so there is no step at which a value could
enter one. Errors carry their class and message, never their arguments or
backtrace.
Every one of those claims is asserted against a live server rather than a
fixture: test/integration runs the same checks against Postgres, MySQL and
MongoDB, including "no value from a real query reaches the payload".
Configuration
Every setting is an environment variable, and DRIFT_* still answers everywhere
SIXTY_* does.
| variable | default |
|---|---|
SIXTY_API_KEY |
— (without it the agent stays inactive and says so) |
SIXTY_ENDPOINT |
http://localhost:4319 |
SIXTY_SERVICE |
the Rails application's name |
SIXTY_ENV |
Rails.env |
SIXTY_RELEASE |
a git SHA from the platform's own variables, if it sets one |
SIXTY_FLUSH_MS |
15000 |
SIXTY_SAMPLE_RATE |
0.05 (retained exemplar traces, not measurement) |
SIXTY_SLOW_TRACE_MS |
1000 |
SIXTY_CAPTURE_PLANS |
on; 0 disables EXPLAIN |
SIXTY_DEBUG |
1 prints what the agent decided at boot |
In code, through the railtie:
config.sixty.sample_rate = 0.2
config.sixty.ignore_paths = [%r{\A/internal/}]
Compatibility
Ruby 2.7+. Rails 6.1+ for the automatic install; Rack alone is enough for the
manual one. Postgres via pg, MySQL via mysql2 or trilogy, MongoDB via the
mongo driver (which is what Mongoid uses). No runtime dependencies — this gem is loaded into other people's
production processes, and every dependency it took would be a version conflict
it could cause in an application that has nothing to do with observability.
Tests
bundle install
rake test
Integration tests skip when a database is not running; docker compose in the
repository root starts Postgres, and MySQL and MongoDB need one container each:
docker run -d -p 3307:3306 -e MYSQL_ROOT_PASSWORD=drift -e MYSQL_DATABASE=sixty_test \
-e MYSQL_USER=drift -e MYSQL_PASSWORD=drift mysql:8
docker run -d -p 27018:27017 mongo:7
CI starts all three as services and fails if any test reports a skip, so "skipped" can never quietly become "never run".
The sketch and SQL suites assert against fixtures generated by the JavaScript
agent (node test/fixtures/generate.mjs) rather than against themselves. A
codec bug that is consistent between a Ruby writer and a Ruby reader passes
every roundtrip test there is; the collector decodes these bytes with the
JavaScript implementation, so that is what the expectations come from.
A running example lives in apps/demo-rails.
Releasing
Bump Sixty::VERSION in lib/sixty/version.rb and merge to main. CI publishes
the version to RubyGems if it is not already there, so a merge that does not
change the version is a no-op rather than a failed build.
Authentication is trusted publishing rather than an API key: the gemspec
sets rubygems_mfa_required, which is incompatible with an unattended gem push — deliberately, for a package that loads into other people's production
processes. GitHub mints a short-lived OIDC token for this repository and this
workflow instead, so there is no secret to leak or rotate. It is configured once
per gem at rubygems.org/gems/sixty/trusted_publishers, and until it is, the
publish job fails on the credentials step.