Some of the events that matter most never happen in a browser. A payment clears in a Stripe webhook. A background job finishes an export. A customer revokes an API key. Your backend is the first place these are known, often the only place, and because the request that records them leaves from your server, no ad blocker or closed tab stands between the event and your analytics.
The Pug Ruby SDK is built for those events. Its first release, 0.0.1, went out on RubyGems on
October 12 as pug-sh. It follows the same model as the Node SDK: a private key, an explicit user on
every call, and reads of the same profiles and insights the dashboard shows. Here is how
event tracking works with it, and the decisions underneath that keep it out
of your way.
Install, and create one client
bundle add pug-sh
The gem needs Ruby 3.3 or newer. It’s published as pug-sh but loads as pug, which
Bundler.require takes care of in Rails:
require "pug"
pug = Pug::Client.new(api_key: ENV.fetch("PUG_API_KEY"))
The key is your project’s private key (prv_…). It can read data as well as write
it, so it belongs in an environment variable or Rails credentials, never in code shipped to a browser. The
constructor checks it up front: a missing key, a public pub_ key, or one carrying the trailing newline
a secrets file leaves behind all raise ArgumentError at boot. After that, tracking never raises.
Build the client once and share it, since one client is safe to use from every thread in the process.
In Rails
Create the client in config/initializers/pug.rb:
unless ENV["SECRET_KEY_BASE_DUMMY"]
PUG = Pug::Client.new(
api_key: Rails.application.credentials.pug_api_key,
logger: Rails.logger,
)
at_exit { PUG.close(timeout: 5) }
end
Every line of that is load-bearing. Rails’ generated Dockerfile runs assets:precompile with
SECRET_KEY_BASE_DUMMY=1 and no credentials, and because the client raises without a key, the guard
keeps that build step from failing. at_exit drains whatever is still buffered when the process exits.
Passing Rails.logger sends the gem’s warnings, each prefixed [Pug SDK], to your app’s log
instead of standard error. From then on it’s PUG.track anywhere in the app, which is what the
examples below use.
A server has no current user
A browser SDK runs for one visitor at a time, so it can remember who that is between calls. A Rails process
serves many people at once, so the Ruby SDK has no ambient user, no
reset and no rotate. Instead, the first argument of every call names who the event is
for:
PUG.track(current_user.id, "order_completed",
amount: 49.0, currency: "USD")
That distinct_id can be a String or an Integer, so a record’s id works as it is and goes
out as its decimal string. Use the same stable ID your client SDKs pass to identify(), your database’s
user ID rather than an email address that can change, and server events land on the same
profile as everything the browser or app recorded.
Custom events, and how Ruby values map
The second argument is the event name. Any name works as a custom event, with its properties in a Hash, braces optional. Ruby values map onto Pug’s property types like this:
StringandSymbolbecome strings, capped at 1,024 bytes; a longer one is truncated with a warning.trueandfalsebecome booleans.Integerbecomes an integer, and one outside the int64 range is dropped.Float,BigDecimalandRationalbecome doubles, andNaNor an infinity is dropped.TimeandDateTimebecome timestamps, and aDatebecomes an ISO 8601 date string.- A
HashorArrayis sent as a JSON string, andnilis left out.
PUG.track(user.id, "report_exported",
format: :csv, rows: 1200, size_mb: BigDecimal("1.5"))
Anything else, a Set for instance, drops that one property with a warning while the rest of the event
still sends. One catch comes from Ruby’s keyword syntax. Without braces, a key named timestamp,
session_id or location is read as an option rather than a property, so a property that
shares one of those names needs its Hash spelled out:
PUG.track(user.id, "signup", plan: "pro",
timestamp: user.created_at)
PUG.track(user.id, "signup",
{ plan: "pro", location: "landing" },
location: { country: "DE" }) Well-known events are checked before they’re queued
Pug defines 127 well-known events: standard names such as
purchase, subscription_started and feature_used, each with a typed schema.
The gem carries the 114 that can happen on a server. The 13 that only happen in a browser or an app, like
page_view, click and app_open, are plain custom events here. For the rest,
properties are checked against the schema inside your process, before the event is queued:
PUG.track(user.id, "purchase",
product_id: "sku_123", amount: 49, currency: "USD",
coupon: "FALL")
Three things happen to that call. Keys may be the schema’s field name (product_id) or its JSON name
(productId), and either way the property goes out as productId, the name every other Pug
SDK sends. Types come from the schema rather than the Ruby value, so amount: 49 is sent as the double
49.0 that purchase declares. And coupon isn’t in the schema, so it rides
along as a custom property.
A value the schema rejects drops the whole event, and the log line names the field and the rule it broke. This is
currency: "usd":
[Pug SDK] Event "purchase" dropped: properties validation failed for
"common.events.v1.PurchaseProperties": currency: value does not match
regex pattern `^[A-Z]{3}$` [string.pattern] quantity: "2" fails the same way (quantity: expected int32, got String), as do an
amount of zero and a subscription_started with no subscription_id. Dropping a
whole event sounds strict, but the alternative is a purchase with no usable currency sitting in a revenue chart for
weeks. A log line from the request that sent it is a cheaper place to find out.
Validation without protovalidate
Pug’s event schemas are protobuf messages annotated with buf.validate rules, which the Node SDK checks
with the protovalidate library. Ruby has no protovalidate, so the gem reads the rules straight off the message
descriptors and compiles the subset its schemas use into plain closures, once per message type. It doesn’t guess at
the rest: a rule it can’t evaluate is reported, and a test requires that report to be empty for every schema, so a
schema update that adopts a new rule fails CI instead of going quietly unenforced. The few CEL expressions the
schemas use get the same treatment. Each is implemented by hand and keyed by its exact text, so one that changes
upstream stops matching rather than being enforced with a stale meaning.
Regexes needed one more step. The currency rule is the pattern
^[A-Z]{3}$, written for RE2, where ^ and $ anchor to the ends of the
whole string. In a Ruby regex they anchor to the ends of any line, so "USD\nEUR" would pass in Ruby and
fail on the server. The gem rewrites those anchors to \A and \z before compiling, so a
check that passes in your process is one the server agrees with.
A track call never blocks, and never raises
Tracking is the one place an SDK must not hurt the app it lives in. track validates the event, appends
it to an in-memory buffer and returns nil. It doesn’t wait on the network, and it never raises: a
malformed event is logged and dropped, as above. A background thread named pug-batcher sends
buffered events in batches of up to 100, as soon as 100 are waiting or 5 seconds have passed, whichever comes
first. Both are configurable, as is the cap on events held in memory, 10,000 by default, past which the oldest
are dropped first.
When a send fails, the gem sorts the failure by its Connect code. A transient failure, such as an
unreachable server or a timeout, keeps the batch queued and retries max_wait after the failure, but
never sooner than a second, so even a max_wait of zero can’t hammer a server that is refusing
connections. A permanent one, a client error like invalid_argument or
unauthenticated that no retry can fix, hands the batch to your on_error hook instead, as
do a queue overflow and anything close couldn’t deliver:
pug = Pug::Client.new(
api_key: ENV.fetch("PUG_API_KEY"),
on_error: lambda do |error, events|
warn "Pug dropped #{events.size} event(s): #{error.message}"
end,
) on_error runs on the sender thread, or on whichever thread called close or overflowed the
queue, so keep it quick and thread-safe. In Rails, Rails.error.report(error) is a natural place to
send what it receives.
Two failures are stopped before anything is sent, because the server’s answer to them would cost far more than
one event. The server refuses a request body over 8 MiB with resource_exhausted, a code that reads as
transient, so an oversized batch would be retried forever. The gem cuts batches at 4 MiB and drops, at
track time, any single event too big to ever fit. And the server reserves the
cookieless- prefix for its own
cookieless identities, rejecting a whole batch that
contains one, so track refuses such a distinct_id on the spot rather than let it take
other users’ events down with it.
identify, unlike track, waits: it blocks until the server answers, up to the client’s
timeout, which is 10 seconds by default. It never raises either, and a failure is logged.
Built for Puma, Unicorn and Sidekiq
Sharing one client across threads covers Puma’s request threads and Sidekiq’s concurrent jobs. Forking is the
harder case. With app preloading, Puma in cluster mode and Unicorn load your app, initializers included, and then
fork workers, so every worker starts with a copy of the parent’s PUG. Threads don’t survive a fork.
Left alone, each child would inherit a buffer with no sender thread to drain it, holding copies of events the
parent will also send.
So the client remembers which process it last ran in, and the first call in a process with a different PID resets
it. The child starts with an empty buffer, its own pug-batcher thread and its own connections, while
events buffered before the fork stay with the parent, which still owns and sends them. Nothing is sent twice, and
there’s no on_worker_boot hook to remember. The gem’s test suite forks a real child to check both
halves: that the child starts empty and sends its own events, and that it dials its own connections and leaves the
parent’s alone when it closes.
Location is yours to pass
In the browser, Pug fills in geography from CDN headers on the incoming request. Those headers describe whoever opened the connection, and for a server SDK that’s your server, so on a private key the server skips geo enrichment entirely. A server event carries the location you give it, or none at all:
PUG.track(user.id, "signup", { plan: "pro" },
location: { country: "DE", city: "Berlin" })
If your app sits behind a CDN that resolves the visitor’s country, such as Cloudflare’s CF-IPCountry
header, that’s the value to pass. country must be an ISO 3166-1 alpha-2 code, with case and stray
spaces normalized, and latitude and longitude are only sent as a pair. A field that looks
like a mistake, such as an unknown key or a code like XX, is dropped with a warning and the rest of the
event still sends. Each field lands as the matching auto-property ($country, $city),
which makes location the only way to set one from Ruby: a custom property whose key starts with
$ fails validation and drops the event.
Identity across browser and server
The same external ID always resolves to the same profile, whichever SDK sends it, so
identity resolution across browser and server needs nothing extra: what
the browser or app records after its identify() and what your server sends under that ID meet on one
timeline. The Ruby
SDK has an identify too, for traits only your backend knows:
PUG.identify(user.id, plan: account.plan, seats: account.seats)
Traits are merged server-side and never removed. They travel as a protobuf Struct, whose numbers are
doubles, so an Integer past 2^53, which would arrive changed, is refused rather than silently rounded.
Reading analytics back
The private key that writes events can also read them. The client’s profiles, activity
and insights expose the same data the dashboard shows, so your backend can look up a user or chart a
metric itself:
profile = PUG.profiles.get_by_external_id(user.id.to_s)
trend = 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,
)
Reads behave the opposite way to tracking, on purpose. They run in your control flow, so they raise
Pug::Error with the Connect code as a Symbol, and nothing retries them behind your back. Your code
decides what a missing profile means:
begin
profile = PUG.profiles.get_by_external_id(user.id.to_s)
rescue Pug::Error => e
raise unless e.code == :not_found
end
Reads leave out traffic tagged as a bot at ingest unless you pass include_bots: true. Server events
are never tagged, so everything you send from Ruby counts.
What the Ruby SDK doesn’t do
A server SDK is a different tool from a browser one, and some of the gaps are deliberate:
- No autocapture. A server has no page views or clicks, so every event is one you send. The Web SDK covers those.
- No geo or user agent of its own. Both come from the connection, which is your backend. Pass
locationfor geography. - No consent gate and no redaction hooks. Honor the visitor’s consent choice before you call
track, and keep personal data out of properties. - No
immediateflag. When an event must go out now, callPUG.flush, which returns once that send attempt finishes.
The gem is MIT-licensed and developed in the open at pug-sh/sdk-ruby. The Ruby SDK docs cover every option and read, and how we deliver events reliably tells the same story from the browser’s side.