Internal guide for working on sdk-ruby. End-user docs live in the README.
- Ruby 3.3 or newer, with Bundler:
bundle installsets up everything below except buf and protoc. - Minitest + Rake: tests (
bundle exec rake test). - RuboCop: lint and format (see
.rubocop.yml). - debride: dead code (see Dead code).
- buf 1.73 and protoc 36.1: re-vendor and regenerate the protobuf code. Only needed when updating protos.
Protobuf types are vendored, not pulled from the Buf Schema Registry: the generated code is
committed under lib/pug/gen/ and shipped in the gem, so gem install needs nothing but
rubygems.org. The .proto sources are mirrored under proto/ from a pinned BSR commit, with
the same allowlist as sdk-node and sdk-go and normally the same commit. See the Makefile:
| Command | What it does |
|---|---|
make sync-protos |
buf export buf.build/pugsh/pug:$(PROTO_COMMIT) (read-only) → proto/ mirror, allowlisted to the SDK packages (incl. the server-only shared/**). Needs BSR network. |
make protos |
buf generate → lib/pug/gen/, then rewrites its requires (below). Offline. |
make proto-latest |
Print the newest commit on the module's main branch (to bump PROTO_COMMIT). |
To take a newer schema: run make proto-latest, set PROTO_COMMIT in the Makefile, then
make sync-protos && make protos, run the tests, and review + commit the proto/ and
lib/pug/gen/ diff. Builds and CI never touch the BSR: they load the committed snapshot, and CI
fails if a fresh make protos differs from it.
Things specific to Ruby codegen:
- The generator is protoc's built-in one (
protoc_builtin: rubyinbuf.gen.yaml), so the output depends on the protoc version;make protosrefuses any protoc butPROTOC_VERSION. - buf's managed mode sets
ruby_packageper proto package, so everything lands underPug::Proto(Pug::Proto::Sdk::Events::V1::Event) instead of top-levelCommon,Sdk,SharedandBufmodules. Descriptor names keep the upstream packages (sdk.events.v1.Event). - protoc requires imports by proto path (
require 'common/v1/property_value_pb'), which only resolves with the output directory on$LOAD_PATH.scripts/relativize_requires.rbrewrites them torequire_relative, so the gem never touches$LOAD_PATH. buf/validate/validate.protois generated (sdk-go skips it): the validator decodes itsFieldRulesfrom the descriptors.
As in sdk-go, a program that loads two copies of the pug protos (this gem alongside code
generated from the monorepo, say) fails at load: protobuf refuses a second registration of the
same .proto file.
Like sdk-go, there is no generated registry: lib/pug/well_known.rb builds the catalog at load
from the (common.events.v1.kind) option of the generated Pug::Proto::Common::Events::V1
messages. Custom options are extensions, read off a descriptor's options through
google-protobuf's extension API (Pug::Proto.extension("common.events.v1.kind").get(options)).
- Filter policy (same as sdk-node): an event is well-known here if its
(common.events.v1.platforms)option containsPLATFORM_SERVERor is unset. test/fixtures/well_known_events.goldenlists the catalog (kind→ message) and is identical to sdk-go's. A proto sync that adds, drops or re-targets an event fails the tests until the change is reviewed and accepted withUPDATE_GOLDEN=1 bundle exec rake test.
There is no protovalidate for Ruby, so lib/pug/validator.rb implements the part the SDK's
schemas use, driven by the buf.validate rules decoded from the descriptors: required, oneof
required, the numeric and duration const/lt/lte/gt/gte/in/not_in family,
finite, the string length, pattern, affix, list and UUID rules, and recursion into messages,
lists and map values. Messages are formatted like protovalidate's (path: message [rule_id]).
Timestamp rules aren't evaluated, since no schema uses them yet: unsupported_rules reports them.
- Patterns are RE2;
^and$are rewritten to\Aand\z, since Ruby's anchor every line. - CEL has no Ruby interpreter. The handful of expressions the schemas use are implemented in
Validator::CEL, keyed by their exact text, so an expression changed upstream stops matching rather than being enforced with stale semantics. Validator.unsupported_ruleslists every rule it can't evaluate. The tests require it to be empty forEvent,IdentifyRequestand every catalog event, so a proto sync that adopts a new rule fails CI instead of going silently unenforced. Read requests aren't validated client-side, as in sdk-node: the server does.
There is no Connect library for Ruby either. lib/pug/connect.rb speaks the
Connect protocol over Net::HTTP for the two call
shapes the SDK uses (unary, and server streaming for profiles.list) with binary protobuf, like
sdk-node. It mirrors connect-es where it matters: Connect-Timeout-Ms, the HTTP-status-to-code
table, JSON error bodies, end-of-stream errors, and the protocol-error codes. Network failures map
to unavailable and timeouts to deadline_exceeded, so the batcher retries them. Net::HTTP
doesn't follow redirects, as the clients under connect-es and connect-go do, so a 3xx maps to
unimplemented, like a 404, and is never retried; an error response without a Connect body keeps
its Location or the start of its body in the message, since a proxy may have sent it.
Connections are kept alive in a small pool (one per concurrent caller, as Net::HTTP isn't
thread-safe). After a fork the child drops the inherited connections without closing them, even
in close: closing a TLS connection writes to it, and the parent still owns it.
make deadcode (part of make check) runs debride over lib and test, leaving out
lib/pug/gen, and fails if it reports any method that nothing calls. debride matches calls by
method name, statically: a call from a test counts, any call to a method of the same name counts,
and a name built at runtime (public_send(:"has_#{name}?")) is invisible. Minitest calls test_*,
setup and teardown by reflection, so .debride_whitelist lists them; add a name, or a
/regexp/, there for anything else only called that way. To delete what it reports instead,
bundle exec debride_rm takes the same arguments and edits the files in place.
| Command | What it does |
|---|---|
bundle exec rake test |
Run the tests |
bundle exec rubocop |
Lint (-a to autocorrect) |
make deadcode |
Fail on methods nothing calls (Dead code) |
make build |
gem build pug-sh.gemspec |
make check |
lint, deadcode, test, build |
make ci |
protos, check-codegen, then check (what CI runs) |
Pug::VERSION in lib/pug/version.rb is the gem's version and is sent with every event as
$sdkVersion. Bump it together with the release tag (vX.Y.Z): pushing the tag runs
.github/workflows/release.yml, which checks the tag matches Pug::VERSION, runs make ci and
pushes the gem to RubyGems through trusted publishing.
| File | Responsibility |
|---|---|
client.rb |
Pug::Client: options, ingestion (track/identify), lifecycle; the BatchCreate sink and the identify sink, which falls back to one Identify per item when BatchIdentify answers unimplemented. Pug::Undelivered. |
event_builder.rb |
Builds and validates Event protos; maps Ruby values and typed schemas to PropertyValue; location. |
batcher.rb |
Batching transport, one per queue (events, identifies): one sender thread, flush triggers, retry, dead-letter. |
reads.rb |
Profiles/Activity/Insights read facades and error normalization. |
rpc.rb |
The procedure table (path, request and response class per RPC). |
connect.rb |
Connect protocol client and connection pool. |
validator.rb |
protovalidate subset. |
well_known.rb |
Well-known event catalog from the proto options. |
proto.rb |
Loads the generated code; looks up custom options' extensions. |
countries.rb |
ISO 3166-1 alpha-2 set, mirroring the server's internal/geo. |
errors.rb, log.rb |
Pug::Error and its subclasses; the [Pug SDK]-prefixed logger wrapper. |
The contract matches sdk-node. sdk-go's deliberate differences carry over:
- One sender thread per queue. Flushes run sequentially instead of overlapping, so event order holds across flushes. Identifies have their own queue and sender, so a burst of them can't hold up event batches.
- Retry backoff. A send stops at the first transiently failing chunk and re-queues it ahead of
the unsent ones, then waits
max_waitfrom the failure before retrying; size triggers during that wait don't retry early. Duringclose, the failed chunk is dead-lettered and the unsent rest reported as undelivered. Retrying never gives up; the queue's overflow bounds it. - Byte-aware chunks. Requests are also cut at 4 MiB, and an event that alone exceeds it is
dropped at
track: the server refuses bodies over 8 MiB with a transient code, so an oversized batch would otherwise retry forever. An identify over 900 KiB is dropped atidentify: one identify can't be split across NATS messages, and NATS refuses a message over its default 1 MiB however small the request. max_queue_sizeholds after a retry, too: re-queued events that overflow it drop the oldest.- Types over heuristics. A
Floatis always a double and anIntegeralways an int; the whole-number-is-int rule exists in sdk-node only because JavaScript has one number type. - Queued
identify.identifyreturns at once, liketrack, and identifies are sent in batches withBatchIdentify; a server that answersunimplemented(one older thanBatchIdentify) gets oneIdentifycall per item for the rest of the client's life. One of those calls failing raisesBatcher::PartialSendErrorwith how many went before it, so the retry or dead letter covers only the rest.on_errorreceives undeliverable identifies throughUndelivered#identifies. - Bad session IDs. A
session_idthat isn't a hyphenated UUID String is ignored with a warning, and the event keeps the client's session instead of being dropped. - Non-finite traits. A trait holding NaN or Infinity is skipped with a warning, and the rest of the identify still goes out.
And some are Ruby's own:
- A retry waits at least a second. sdk-go maps a
MaxWaitof 0 to the default; heremax_wait: 0still sends each event at once, but a retry waitsMIN_RETRY_WAIT, so a server refusing connections isn't retried in a tight loop. - A close that times out reports the chunk in flight. sdk-go cancels that send;
Net::HTTPcan't be cancelled from another thread, so it goes on in the background, to the chunk's last item on the per-itemIdentifyfallback, andclosereports the chunk before returning, as possibly delivered. The batcher takes one chunk at a time off the buffer for this, so everything else is still there to report. Sincetimeoutbounds each request rather than a chunk, a slow server doesn't cut a fallback chunk short as it does in sdk-go. - Waiting from
on_error. On a sender thread or a closing one,flushandclosedon't wait at all: with two queues, what they would wait for, such as the other queue's sender, may be inon_errorwaiting on them.Client#closestarts both batchers' closes under one lock before finishing either, so one caller owns both, their drains overlap, and aclosefromon_errorduring it finds both underway and returns. - Braceless keywords. Ruby passes
track(id, kind, timestamp: t)andtrack(id, kind, plan: "pro")alike, as one Hash, so a key named after an option sets that option. Properties passed as an argument of their own, in braces or a variable, are never read for options; likewiseidentify's traits. - Encodings. Property strings and keys are transcoded to UTF-8, with invalid bytes replaced by U+FFFD, since google-protobuf refuses invalid UTF-8. Ids aren't repaired (a mangled id is a different person), so an id that isn't valid UTF-8 drops the call.
- Duration fields take seconds. A number for a Duration field is seconds, Ruby's unit for time
spans and what google-protobuf itself accepts; sdk-node refuses a bare number because
JavaScript's convention is milliseconds.
{ seconds:, nanos: }andGoogle::Protobuf::Durationwork too. - Keys. A snake_case property or trait key goes out in camelCase (
order_total→orderTotal), by protobuf's JSON-name rule, so Ruby's spelling and JavaScript's usual one land on one key; sdk-node, sdk-go and sdk-python send keys as given. Only the top level is renamed, since a nested Hash is as likely data keyed by ids, and of two keys that come out alike the last wins, as inHash#merge. Well-known properties may be keyed by either name (product_id/productId), and location keys likewise (postal_code/postalCode). - Blocking calls.
flushandclosewait, with an optionaltimeout:and a boolean result, the Ruby counterparts of awaiting sdk-node's promises.track'stimestampoption is aTime, not epoch milliseconds. - Forks. A client created before a fork starts empty in the child, with its own sender threads and connections; a sender thread exits when idle and restarts on the next item, so an unused client holds no thread.
- Timeouts.
timeoutbounds each phase of a request (connect, write, read) and is sent asConnect-Timeout-Ms, so the server enforces the overall deadline; a streaminglistis bounded as a whole, the caller's block included, and takes atimeout:of its own. - Nested values.
JSON.generatewrites an object it doesn't know as itsto_s, so aHashorArrayproperty, or identify's traits, go throughEventBuilder.json_readyfirst: times become ISO 8601, other real numbers floats, and anything else is refused. profiles.deletereturns theDeleteResponse(erasure request id and status), as in sdk-go.
- Tests run through the real validator over the real generated descriptors, so they exercise the actual validation contract.
- The batcher is driven through a fake sink; client tests stub the RPC layer, and
test/connect_test.rbruns the SDK against an in-process HTTP server (TestSupport::FakeServer), covering the wire format, the API key header, streaminglist, error mapping, timeouts and theBatchIdentifyfallback on a server that answers it 404. - The never-raises contract is asserted through
TestSupport::LogCapture, standing in for sdk-node's console spies. test_accounts_for_every_eventandtest_accounts_for_every_item_through_partial_sendscheck under real concurrency that every item is delivered or dead-lettered exactly once; the fork test needs a platform withfork.
Before opening a PR: make check.