Skip to content

Ruby SDK

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

Updated View as Markdown

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 with one exception: a server has no ambient user or session to reset, so you name the distinct_id on each call instead.

Install

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

View pug-sh on RubyGems

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:

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.

Prompt
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 - if you don’t have yours, issue a new one.

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

config/initializers/pug.rbruby
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 add typed properties and richer dashboards.

# 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:

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:

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.

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’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.

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: 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.

# 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.

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 yourself. Server events are also never tagged $bot, so reads never leave them out as bot traffic.

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.

# 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), or nil when the failure didn’t come from the RPC layer, and cause is the underlying exception:

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:

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 for field-by-field schemas. From a backend without an SDK, call those services directly with your private key (see Authentication).

Upgrading

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

Navigation

Type to search...

↑↓ navigate↵ selectEsc close