---
title: "Ruby SDK for Rails, Sinatra and any Rack app · camada"
description: "Install camada in Rails through the Railtie, or in Sinatra, Hanami and any Rack app: the options it takes, what this position sees, how it fails open."
url: https://www.camada.app/docs/ruby
last_updated: 2026-10-26
---

Docs

# Ruby: Rails, Sinatra, any Rack app

The `camada` gem enforces your snapshot inside a Ruby process: your ordered custom rules, then allow, block and challenge, decided in-process, with events shipped off the request path. One gem with a Rack middleware that any Rack app mounts and a Railtie that mounts it for Rails — the `sentry-ruby` model.

## Install

shell

```sh
bundle add camada
```

Ruby 3.1 or newer. No runtime dependencies (stdlib only); it never requires `rack` itself. On [RubyGems](https://rubygems.org/gems/camada) as `camada`.

## Mount it

Rack

```ruby
# config.ru — Sinatra, Hanami, or any Rack app
require "camada"
use Camada::Rack      # first, so camada answers before routing
run App
```

Rails (Gemfile)

```ruby
# Rails — nothing to add: the Railtie inserts Camada::Rack at the top of the middleware stack
gem "camada"
```

The middleware shares one lazy engine, built from the environment on the first request:

shell

```sh
export CAMADA_KEY=<ingest_token>.<snap_token>
export CAMADA_INGEST_URL=http://localhost:8787   # dev only; defaults to production ingest
```

Set these as environment variables: export them in the shell that starts your app, or add them in your host's settings. Rack and Rails read no .env file (Rails needs the dotenv-rails gem for that).

That build starts the snapshot poll on a thread and never blocks, so the request that triggered it is answered cold: it passes (fail open), and so does anything that arrives before the first poll lands. To enforce from request 1, warm the engine at boot by waiting for the boot poll:

warm-up

```ruby
# config/initializers/camada.rb, or config.ru before `run`
engine = Camada.default          # builds the engine; the boot poll is already running on its thread
if engine.snap                   # nil when CAMADA_KEY is unset or CAMADA_DISABLED=1
  probe = Camada::Snapshot::MatchInput.new(ip: "0.0.0.0")
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
  while engine.snap.verdict(probe).reason == "cold" && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
    sleep 0.01                   # bounded: an unreachable analyst leaves it cold, and the app still fails open
  end
end
```

An app that reads its own configuration can build the engine itself and hand it in:

explicit config

```ruby
# my_key, my_ingest: read from your own configuration
engine = Camada::Engine.new(env: { "CAMADA_KEY" => my_key, "CAMADA_INGEST_URL" => my_ingest }, challenge_path: "/api/__camada/challenge")
use Camada::Rack, engine
```

Quickstart: [Rails and Rack](https://www.camada.app/docs/quickstart/rails).

## The options you will use

| Option | Default | Meaning |
| --- | --- | --- |
| `env` | `ENV` | Where CAMADA_KEY and friends are read from. Pass a Hash to configure in code. |
| `transport` | `Net::HTTP` | Anything responding to #call(HttpRequest) that reaches the analyst; tests hand in an in-process fake. |
| `refresh_s` | `server-steered` | Poll cadence in seconds. Unset, the tenant config's poll_seconds steers it; set, it is pinned. |
| `challenge` | `true` | Serve the proof-of-work page for challenge verdicts (CAMADA_CHALLENGE=0 switches it off too). |
| `challenge_path` | `/__camada/challenge` | Where that page posts its solution. |
| `snapshot_version` | `5` | 4 drops your ordered custom rules; 3 the allow and challenge sections too. |
| `script_path` | `/_cam/b.js` | Where the first-party beacon script is served. |
| `fp_path` | `/_cam/fp` | Where that script posts the beacon. Keep it in the same directory as script_path: the script derives one from the other. |

Environment: `CAMADA_KEY`, `CAMADA_INGEST_URL`, `CAMADA_SNAPSHOT_URL`, `CAMADA_TRUSTED_PROXY`, `CAMADA_SERVERLESS=1` (a per-request staleness check, no poll thread), `CAMADA_CHALLENGE=0`, and the kill switch `CAMADA_DISABLED=1`, checked per request (set at boot, no threads start at all). The [docs index](https://www.camada.app/docs) lists what each one means.

## What it does per request

1. Keeps the snapshot fresh off the request path: a poll thread at the cadence your tenant config sets (gzip on the wire, 304 when nothing changed), asking for snapshot v5, the container that carries your ordered custom rules. `CAMADA_SERVERLESS=1` checks staleness per request instead.
2. Resolves the client from the socket peer (`REMOTE_ADDR`), combined with `X-Forwarded-For` only under your trusted-proxy setting. Never `request.ip`: `Rack::Request#ip` trusts a forwarded header from anyone, which is exactly the spoof camada refuses.
3. Runs your ordered custom rules, then the allow, block and challenge lists. A blocked request gets **403** with `x-block-reason` (and `x-block-rule` when a rule decided) before your app runs. The event still ships, marked as an SDK block so it is not confused with your own 403s.
4. A challenged browser gets the proof-of-work page; an API call reaches your app, and its response carries `x-camada-challenge: 1` for your frontend to act on. A request whose IP cannot be resolved is let through. See [challenge](https://www.camada.app/docs/challenge).
5. Serves the beacon endpoints, then runs your app with `x-rid` and the `_sfp` session cookie on its response, and when the server closes the response body ships one batched, redacted event with its real status, latency and the matched route pattern when the framework names it (`sinatra.route`, Rails' `route_uri_pattern`). An exception in your app ships as `st: 500` and propagates unchanged.

## Add the beacon

Bots that never run JavaScript are the cheapest to catch. Put the tag in the `<head>` of the pages you render and the middleware does the rest:

beacon

```ruby
# Sinatra: `env` is the Rack env; Rails: request.env
get "/" do
  "<html><head>#{Camada.script_tag(env)}</head><body>…</body></html>"
end
```

`Camada.script_tag(env)` (Rails: `request.env`) returns a `<script src="/_cam/b.js?r=…" async>` tag carrying this request's event id, so the beacon is joined to the page view it came from. The middleware serves the script at `GET /_cam/b.js` (cacheable for an hour) and relays `POST /_cam/fp` onto the event batch, stamped with the client address camada resolved — never the one the body claims. Both endpoints sit behind the verdict, so a blocked client gets 403 there too. Turn the beacon off in the project's settings and the tag becomes empty and both endpoints fall through to your app.

## Report what your app knows

The wire shows a `POST /login`; only your handler knows whether it failed. Tell camada from the handler:

outcomes

```ruby
post "/login" do
  ok = sign_in(params)   # your own check
  Camada.track(env, "login_failed", user: params[:email]) unless ok   # hashed in-process; never raises
  ok ? redirect("/") : halt(401, { error: "invalid" }.to_json)
end
```

`Camada.track(env, event, user: …)` ships the outcome joined to this request's event and session. The user identifier is hashed in-process with your ingest token before it is queued — the raw value never leaves the process. It never raises and is a no-op on a request the middleware did not run for. The event name is free-form; the analyst's rules read this vocabulary:

| Event | When |
| --- | --- |
| `login_failed / login_succeeded` | a credential check settled |
| `signup` | an account was created |
| `password_reset` | a reset was requested |
| `mfa_failed` | a second factor was rejected |
| `payment_failed / payment_succeeded` | a charge settled |
| `coupon_failed` | a promo code was rejected |

A route you want to gate yourself can serve the challenge on demand; the answer is a Rack triple:

gating a route

```ruby
get "/export" do
  if (answer = Camada.serve_challenge(env))
    halt(*answer)   # the proof-of-work page (or 403 JSON) until this browser holds a valid _cch
  end
  big_file   # your response
end
```

## What this position sees

The same position as `@camada/node`, in a Ruby process: the final status and latency, the session, the beacon's browser signals and your app's outcomes. The Rack env carries no wire header order, so the analyst reads no header-order signal from this tap — camada knows what this tap can see from the position itself and never scores the absence of header order as evidence against a request. No ASN, country or TLS fingerprint reaches a Ruby process, so those conditions fail open in-app and the analyst resolves ASN and country itself. `matches` patterns are JS regexes read by Ruby's Onigmo: named groups are native, `^` and `$` become `\A` and `\z`, and a spelling Ruby still rejects never matches here (and never raises) while it does at the edge. What a rule can key on is on [rules and list entries](https://www.camada.app/docs/rules).

## Deploying it

- Every worker process polls its own snapshot (about 5 MB resident, read a word at a time) and flushes its own batches; the tenant's `poll_seconds` keeps the cadence honest across a fleet.
- Threads do not survive a fork. Ruby has no fork hook, so the SDK compares `Process.pid` on every call and starts over in a forked worker: new locks, an empty queue, its poll and flush threads restarted. Puma cluster mode with `preload_app!`, Unicorn and Passenger need nothing added.
- Pending events drain at interpreter exit within half a second (`at_exit`). No signal handlers are installed — an app owns its own shutdown — so a worker killed by SIGKILL, or by SIGTERM without a handler, may drop its last batch.
- Serverless hosts: `CAMADA_SERVERLESS=1`. A cold invocation fails open and catches up on the next one.
- The middleware writes lower-case header names; it appends `set-cookie` as an Array under Rack 3 and joins cookies with `"\n"` under Rack 2.

## It fails open

Without a key the middleware is **inert**: one log line, no requests, no enforcement, so an unprovisioned environment behaves exactly as if camada were not installed. Beyond that, a dead ingest (logged at most once a minute, one line, no backtrace), a corrupt snapshot or a bug in the gem costs you the telemetry, never the request — camada can never turn one of your responses into a 5xx. The whole stance is on the [security](https://www.camada.app/security) page.

## Sitemap

[Every page on this site](https://www.camada.app/sitemap.md)
