Engineering

Server-side event tracking in Ruby and Rails

The Pug Ruby SDK brings event tracking to your backend. Here’s how a server-side track differs from a browser one, what the gem checks before an event leaves your process, and how it stays out of your request path under Puma and Sidekiq.

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:

  • String and Symbol become strings, capped at 1,024 bytes; a longer one is truncated with a warning.
  • true and false become booleans.
  • Integer becomes an integer, and one outside the int64 range is dropped.
  • Float, BigDecimal and Rational become doubles, and NaN or an infinity is dropped.
  • Time and DateTime become timestamps, and a Date becomes an ISO 8601 date string.
  • A Hash or Array is sent as a JSON string, and nil is 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 location for 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 immediate flag. When an event must go out now, call PUG.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.

FAQ

Common questions

How do I track events from a Rails app?

Add the gem with bundle add pug-sh, create one Pug::Client in an initializer with your private key, and call PUG.track(user_id, event_name, properties) wherever the event happens. The call returns at once: a background thread batches and sends the events, and at_exit { PUG.close } drains the buffer when the process shuts down.

Can track slow down a request or raise an exception?

No. track validates the event, buffers it in memory and returns, and a background thread does the sending. It never raises: an invalid event is logged and dropped, transient network failures are retried, and permanent ones go to your on_error callback. identify does wait for the server’s answer, up to the client timeout, but it never raises either.

Is the Pug Ruby SDK safe under Puma, Unicorn and Sidekiq?

Yes. One client can be shared by every thread, which covers Puma’s request threads and Sidekiq’s concurrent jobs. A client created before Puma or Unicorn forks starts empty in each worker, with its own sender thread and connections, while events buffered in the parent stay with the parent, so nothing is sent twice.

Why do my server-side events have no country or city?

Pug derives geography from CDN headers on the incoming request. For a server SDK that request comes from your backend, not the visitor, so the server skips geo enrichment on private-key events. Pass the visitor’s location yourself with the location: option, using an ISO 3166-1 alpha-2 country code such as DE.

Which Ruby versions does the Pug SDK support?

Ruby 3.3 or newer. The gem is published as pug-sh and loads as pug, its runtime dependencies are google-protobuf and logger, and it is MIT-licensed.

Send your first event from Ruby.

One gem, a private key, and one client. Open source, self-hostable on one Go binary, and free in the cloud up to 100k events a month.

Questions? Email hello@pug.sh