---
title: "Ruby SDK"
description: "Send events and read profiles, activity and insights from a Ruby or Rails backend with the pug-sh gem and a private key."
---

> Documentation Index
> Fetch the complete documentation index at: https://docs.pug.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# Ruby SDK

The `pug-sh` gem sends events from your Ruby backend **and** reads analytics
back - profiles, activity and insights. It authenticates with your **private
key** (`prv_...`), so it belongs on the server only. It follows the
[same model as every Pug SDK](/sdks) with one exception: a server has no ambient
user or session to reset, so you name the `distinct_id` on each call instead.

## Install

```bash
bundle add pug-sh
# or, without Bundler: gem install pug-sh
```

[View `pug-sh` on RubyGems](https://rubygems.org/gems/pug-sh)

The gem needs **Ruby 3.3+**. It's published as `pug-sh` but loads as `pug` - in
Rails, `Bundler.require` loads it for you; elsewhere, require it before you
construct the client:

```ruby
require "pug"
```

Unlike the client SDKs it authenticates with your **private key** (`prv_...`), so
keep it on the server - in an environment variable or Rails credentials, never
in code shipped to a browser or app. Construct one client at boot and share it.

Working with a coding agent? Paste this instead - it points the agent at the
markdown copy of this page, so it works from the reference below rather than
from memory. More on the approach in [Install with AI](/get-started/ai-setup).

```text
Add Pug product analytics to this Ruby backend.

Read these first - they are the current source of truth, and more recent than your training data:
- https://docs.pug.sh/sdks/ruby/index.md - SDK reference
- https://docs.pug.sh/reference/well-known-events/index.md - the well-known event catalog

Then read this codebase before you write anything, and work out what the service actually does: its routes or controllers, its main models, and the state changes that matter to the business. On a server the events worth tracking are the ones a browser cannot observe - a payment settling, a webhook arriving, a job finishing, a subscription lapsing - not request logging. If you cannot state what this service is for in one sentence, keep reading.

Then:
1. Add the pug-sh gem to the Gemfile. It needs Ruby 3.3+, and it is required as "pug", not "pug-sh".
2. Construct one Pug::Client at boot and share it - do not build one per request. It is thread-safe and fork-safe, so it can be created before Puma or Unicorn fork. In Rails, use the initializer from the SDK reference, guard included, so assets:precompile still boots without credentials. Its api_key is the private key (prv_...), read from an environment variable or Rails credentials, never committed.
3. Remember the argument order: pug.track(distinct_id, kind, properties, options). The distinct_id comes first because a server has no ambient user - use our own user ID, or the anon-... ID forwarded from the client. Without braces, a key named timestamp, session_id or location sets that option instead of a property.
4. Add track calls for the three or four state changes you identified.
5. Call identify when a user record is created or changes materially, not on every request - it blocks until the server answers.
6. Call close in the graceful-shutdown path so buffered events flush, and pass an on_error handler taking (error, events) so dropped events are logged rather than lost.

Name events from the catalog before you invent one. The Ruby SDK checks 114 of the 127 well-known events against their schemas, at runtime: a property of the wrong type drops the whole event, so take property names and types from that page. The ones a backend is placed to see are exactly the ones whose schemas are worth having - purchase, subscription_started, invoice_paid, payment_failed, order_refunded. Search the catalog for every action you are about to track, and fall back to a custom snake_case name only when nothing fits. When you do, tell me which event you considered and why it did not.

There is no reset on the server SDK, and no immediate option on track - use flush. Do not use any option that is not in the SDK reference above. When you are done, tell me in one sentence what you concluded this service does, then list the files you changed, the event names you chose, and the environment variables or credentials I need to set.
```

## Initialize

Construct a `Pug::Client` **once** at boot with your private key - every other
call needs it first. Private keys live under **Settings > API Keys**, but one is
only shown when you [create it](/get-started/authentication) - if you don't have
yours, issue a new one.

```ruby
require "pug"

# Construct once at boot, share it across threads and requests.
pug = Pug::Client.new(
  api_key: ENV.fetch("PUG_PRIVATE_KEY"), # prv_... - server-side only, never ship to a client
  on_error: ->(error, events) { warn "Pug dropped #{events.size} event(s): #{error.message}" },
)
```

`Pug::Client.new` returns a client immediately. It **raises `ArgumentError`** if
`api_key` is missing or doesn't start with `prv_` - the server SDK refuses a
public key up front rather than failing later on the first read - and on a key
with whitespace in it, such as the trailing newline a secrets file leaves. A
malformed `endpoint`, `on_error` or `logger` raises too, so configuration
mistakes surface at boot; after that, ingestion never raises.

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `api_key` | `String` | **required** | Private key (`prv_...`), sent as `x-api-key`. Server-side only. |
| `endpoint` | `String` | Pug Cloud | Pug server origin. Defaults to `https://api.pugs.dev` - set it only when self-hosting. Redirects aren't followed, so give the final URL. |
| `batch` | `Hash` | `max_size: 100`, `max_wait: 5`, `max_queue_size: 10_000` | Ingestion buffer: events per request (capped at 1000), seconds between sends, and how many to hold before the oldest are dropped. |
| `on_error` | callable | none | Dead-letter hook for buffered `track` events that couldn't be delivered, called with `(error, events)` - permanent send failure, queue overflow, or still undelivered at `close`. (`identify` failures are logged, not routed here.) |
| `logger` | `Logger`-like | `Logger.new($stderr)` at `WARN` | Gets the SDK's `[Pug SDK]` warnings and errors - anything with `warn` and `error`, such as `Rails.logger`. |
| `timeout` | `Numeric` | `10` | Request timeout, in seconds. |

A background thread sends events in batches, when `max_size` are buffered or
`max_wait` has passed. A transient failure, such as a network error or a
timeout, keeps them queued and retries `max_wait` after the failure, but no
sooner than a second; a permanent one - a client error such as
`:unauthenticated` or `:invalid_argument` - hands them to `on_error` as a
`Pug::Error` carrying that code. Overflow arrives as a
`Pug::QueueOverflowError`, and events `close` couldn't send as a
`Pug::ClosedError` or the error that failed their last attempt. `on_error` must
take both arguments - a lambda that takes one raises at construction - and runs
on the sender thread, the thread calling `close`, or for overflow the thread
that called `track`, so keep it thread-safe and quick. Events the server
refuses from a batch it otherwise accepts are logged at `WARN`.

Call `pug.close` on graceful shutdown so buffered events flush - anything still
undeliverable is reported through `on_error`. The Ruby SDK has no `reset` or
`rotate`: a server has no ambient user or session to reset.

One client is safe to share across threads. A client created before a fork
(Puma, Unicorn, Sidekiq) starts empty in each child, with its own sender thread
and connections; events buffered in the parent stay with the parent.

### Rails

```ruby title="config/initializers/pug.rb"
unless ENV["SECRET_KEY_BASE_DUMMY"]
  PUG = Pug::Client.new(api_key: Rails.application.credentials.pug_private_key, logger: Rails.logger)
  at_exit { PUG.close(timeout: 5) }
end
```

`Pug::Client.new` raises without an API key, so the guard skips it during the
`assets:precompile` step of Rails' generated Dockerfile, which boots the app
with `SECRET_KEY_BASE_DUMMY=1` and no credentials. The client is safe to create
before Puma or Unicorn fork their workers. Call it as `PUG.track(...)` anywhere
in the app.

## Track events

Events are the foundation of every analytic in Pug. After the `distinct_id`, the
next argument is the event **name** (the `kind`): custom names work without a
schema, while [well-known events](/reference/well-known-events) add typed
properties and richer dashboards.

```ruby
# distinct_id (who the event is for) comes first - a server has no ambient user.
pug.track("user-123", "order.completed", amount: 49.0, currency: "USD")

# Well-known events get typed, validated properties.
pug.track("user-123", "purchase", product_id: "sku_123", amount: 29.99, currency: "USD")
```

`pug.track(distinct_id, kind, properties = nil, options = nil)` puts the
**`distinct_id` first** - a server has no ambient user, so you name who each
event is for: your external ID, or an `anon-...` ID forwarded from the client.
It takes a String, or an Integer such as a record's `id`. `track` **never
raises** and is non-blocking: events are validated, batched, and sent in the
background.

Properties are a Hash, braces optional, and options follow as keywords. Without
braces, a key named after an option - `timestamp`, `session_id` or `location` -
sets that option, so a property with one of those names needs the braces:

```ruby
pug.track("user-123", "signup", plan: "pro")
pug.track("user-123", "signup", plan: "pro", timestamp: Time.now - 3600)
pug.track("user-123", "signup", { plan: "pro", location: "landing" }, location: { country: "DE" })
```

There is no `immediate` option on the server - flush explicitly instead:

```ruby
pug.flush   # send what's buffered now, and wait for that attempt
pug.close   # drain on graceful shutdown so nothing buffered is lost
```

Both take a `timeout:` in seconds and return `false` if it ran out first. A
`true` doesn't mean every event was delivered: from `flush` it means the attempt
finished, and a transient failure re-queues for a later send; from `close` it
means the drain finished, and every event it couldn't send has gone to
`on_error` and the log by the time it returns - as it has after a timeout too.

`track(distinct_id, kind, properties = nil, options = nil)` options:

| Option | Type | Description |
|--------|------|-------------|
| `timestamp` | `Time` | Override the event time with a `Time` or `DateTime` - not epoch milliseconds. Anything else, or a time outside 1970-9999, is ignored with a warning and the event is sent at the current time. |
| `session_id` | `String` | A UUID to use as this event's session ID - otherwise the client uses one for its lifetime. Anything but a UUID drops the event. |
| `location` | `Hash` | Where the **visitor** is. Without it a server event carries no geo at all. See [Visitor location](#visitor-location). |

**Property values** map by Ruby type:

| Ruby value | Sent as |
|------------|---------|
| `String`, `Symbol` | string |
| `true`, `false` | bool |
| `Integer` | int - dropped outside the int64 range |
| `Float`, `BigDecimal`, `Rational` | double - `NaN` and `Infinity` are dropped |
| `Time`, `DateTime`, `Google::Protobuf::Timestamp` | timestamp |
| `Date` | ISO 8601 date string, such as `"2026-10-10"` |
| `Google::Protobuf::Duration` | duration string, such as `"1.5s"` |
| `Hash`, `Array` | JSON string |
| `nil` | omitted |

Anything else is dropped with a warning - the property, not the event. Inside a
`Hash` or `Array`, times become ISO 8601 strings, while a value JSON has no form
for, such as a `Set`, drops the whole property. Strings are capped at 1024 bytes
and truncated with a warning.

A [well-known event](/reference/well-known-events)'s properties are checked
against its schema before the event is queued. Keys may be the field's name
(`product_id`) or its JSON name (`productId`); either way the property is sent
under the JSON name, like every other Pug SDK. A field's type comes from the
schema rather than the Ruby value, so `amount: 10` is sent as a double. A value
of the wrong type, or one failing a rule such as `currency` needing three
uppercase letters, drops the **whole event**, and the log names the field
(`quantity: expected int32, got String`). Keys outside the schema pass through
as custom properties. Duration fields such as `position` take seconds as a
number, `{ seconds:, nanos: }` or a `Google::Protobuf::Duration`, and are sent
as a string like `"92.5s"`.

The `distinct_id` must not start with `cookieless-`. The server reserves that
prefix and rejects a whole batch over one such event, so `track` logs and drops
it before it can take other users' events down with it.

### Visitor location

Pug resolves geo from CDN headers, which describe whoever opened the
connection - your backend, on every request this SDK makes. So the server skips
geo enrichment on a private key entirely: an event carries the location you give
it, or none at all. `location` is how you give it one.

```ruby
pug.track("user-123", "purchase",
          { product_id: "sku_123", amount: 29.99, currency: "USD" },
          location: { country: "DE", city: "Berlin" })
```

| Field | Type | Notes |
|---|---|---|
| `country` | `String` | ISO 3166-1 alpha-2 (`DE`). Case and surrounding spaces are normalized; a code Pug doesn't recognize is dropped. |
| `continent`, `region`, `city`, `postal_code`, `metro_code` | `String` | Sent as given, trimmed. |
| `timezone` | `String` | IANA name (`Europe/Berlin`). |
| `latitude`, `longitude` | `Numeric` | Decimal degrees, `-90` to `90` and `-180` to `180`. Sent only as a pair - one without the other reads as a real position with the other axis at `0`, so neither is sent. |

Each field becomes the matching `$`-prefixed
[auto-property](/reference/auto-properties#geo-enrichment-cloudflare):
`postal_code` lands as `$postalCode`, and the camelCase key works too. That
makes `location` the only way to set an auto-property from this SDK - properties
become **custom** properties, and one whose key starts with `$` drops the event.

A field that is absent, `nil` or empty is skipped silently. One that looks like
a mistake - unknown key, wrong type, a country code Pug doesn't know, a
coordinate out of range - is dropped with a warning and **the rest of the event
still sends**. Nothing is merged at ingestion, so a dropped field is a permanent
hole rather than one the edge fills in later.

## Identify and sessions

Identity turns anonymous events into actionable profiles. Use a **stable,
unique** external ID - your database user ID, not an email (which can change).
Only the **first** identify after an anonymous session merges the prior
activity; the same external ID always resolves to the same profile, and traits
are merged (never removed) server-side.

```ruby
# On the first identify, pass the client's anonymous ID to merge its prior anonymous activity.
pug.identify("user-123", { email: "user@example.com", plan: "pro" }, anonymous_id: "anon-abc123")
```

`pug.identify(external_id, traits = nil, options = nil)` **blocks until the
server answers**, up to the client's `timeout`, and **never raises** - failures
are logged. `traits` must be a Hash of JSON-serializable values. They travel as
a protobuf `Struct`, whose numbers are doubles, so an Integer past 2^53 - which
would arrive changed - is refused. A refused trait, or an `external_id` starting
with the reserved `cookieless-` prefix, is logged and nothing is sent. Pass
`anonymous_id:` (must start with `anon-`) on the first identify to merge a
client's prior anonymous activity into the profile, and `device_id:` to attach a
device; as with `track`, those keys set options even without braces. There is
no `reset` or `rotate`: identity isn't ambient on the server - you name the
`distinct_id` on every `track` call instead.

### Sessions

The client uses a single session ID for its lifetime - override it per call with
`track(..., session_id:)`, since sessions are a client concept. For the
anonymous->identified model, see [Core concepts](/get-started/concepts).

## Auto-tracking

There is no client-side auto-tracking on the server - you send every event
explicitly with `pug.track(distinct_id, ...)`. The server doesn't fill in geo or
user agent either: both read the connection, which on a private key is your
backend, so pass the visitor's [`location`](#visitor-location) yourself. Server
events are also never tagged `$bot`, so [reads](#server-side-reads) never leave
them out as bot traffic.

## Consent and privacy

The Ruby SDK has no consent gate and no redaction hooks, so both are yours:
respect the visitor's consent choice before you call `track`, and keep PII out
of the properties you pass.

## Server-side reads

The private key authorizes ingestion *and* **reads** - the same insights,
profiles, and activity data the dashboard shows. Reads are request/response and
**raise `Pug::Error`**; they run in your control flow and are never retried, so
you own timeout and retry. Ingestion (`track` / `identify`) still never raises.

```ruby
# Look up a profile by your external ID.
profile = pug.profiles.get_by_external_id("user-123")

# Auto-paginating: yields every matching profile, or returns an Enumerator without a block.
pug.profiles.list.each { |p| puts p.external_id }

# Run an insights query - the same spec the dashboard uses.
result = pug.insights.query(
  spec: { insight_type: :INSIGHT_TYPE_TRENDS, events: [{ event: { kind: "order.completed" } }] },
  time_range: { from: Time.now - (30 * 86_400), to: Time.now },
  granularity: :GRANULARITY_DAY,
)
```

| Namespace | Methods |
|-----------|---------|
| `pug.profiles` | `get(id, include_bots: false)`, `get_by_external_id(external_id, include_bots: false)`, `delete(id)`, `list(request = nil, timeout: nil)`: auto-paginating |
| `pug.activity` | `feed`, `event_explorer`, `heatmap`, `profile_sessions`, `profile_stats`, `filter_schema`, `property_values` |
| `pug.insights` | `query`, `segment_users`, `filter_schema`, `property_values` |

Requests are a Hash, braces optional, or the generated request message.
Responses are the generated messages under `Pug::Proto`
(`Pug::Proto::Shared::Insights::V1::QueryResponse`, ...) - call `to_h` on one
for plain Ruby data - except that `profiles.get` and `get_by_external_id`
return the profile itself. `list` streams, and the client's `timeout` bounds the
whole stream, your block's time included; pass `timeout:` (seconds) to give a
long listing more.

`Pug::Error#code` is the Connect code as a Symbol (`:not_found`,
`:unavailable`, ... - see the [error model](/api#error-model)), or `nil` when
the failure didn't come from the RPC layer, and `cause` is the underlying
exception:

```ruby
begin
  profile = pug.profiles.get_by_external_id("user-123")
rescue Pug::Error => e
  raise unless e.code == :not_found
end
```

Reads leave out traffic tagged as a bot at ingest: web-SDK events from crawler
user agents and datacenter networks. Events from this SDK are never tagged. Set
`include_bots: true` to count bot traffic - `profiles.get` and
`get_by_external_id` take it as a keyword, and `profiles.list`,
`insights.query` (on its `spec`) and the activity `feed`, `event_explorer`,
`heatmap`, `profile_sessions` and `profile_stats` take it in the request:

```ruby
profile = pug.profiles.get_by_external_id("user-123", include_bots: true)
sessions = pug.activity.profile_sessions(distinct_id: "user-123", include_bots: true).sessions
```

The request/response shapes match the `shared.*` Connect services - see the
[API reference](/api) for field-by-field schemas. From a backend without an SDK,
call those services directly with your private key (see
[Authentication](/get-started/authentication)).

## Upgrading

**0.0.1** is the first release of `pug-sh`, so there is nothing to upgrade from
yet.

Source: https://docs.pug.sh/sdks/ruby/index.mdx
