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-shThe 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.
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
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) }
endPug::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 lostBoth 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.
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.
# 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
endReads 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).sessionsThe 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.