Skip to content

Latest commit

 

History

History
230 lines (196 loc) · 16.4 KB

development.md

File metadata and controls

230 lines (196 loc) · 16.4 KB

Development

Internal guide for working on sdk-ruby. End-user docs live in the README.

Toolchain

  • Ruby 3.3 or newer, with Bundler: bundle install sets 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 (vendored)

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: ruby in buf.gen.yaml), so the output depends on the protoc version; make protos refuses any protoc but PROTOC_VERSION.
  • buf's managed mode sets ruby_package per proto package, so everything lands under Pug::Proto (Pug::Proto::Sdk::Events::V1::Event) instead of top-level Common, Sdk, Shared and Buf modules. 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.rb rewrites them to require_relative, so the gem never touches $LOAD_PATH.
  • buf/validate/validate.proto is generated (sdk-go skips it): the validator decodes its FieldRules from 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.

Well-known events

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 contains PLATFORM_SERVER or is unset.
  • test/fixtures/well_known_events.golden lists 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 with UPDATE_GOLDEN=1 bundle exec rake test.

Validation

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 \A and \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_rules lists every rule it can't evaluate. The tests require it to be empty for Event, IdentifyRequest and 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.

Connect

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.

Dead code

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.

Scripts

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.

Source layout

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.

Behaviour notes (vs sdk-node)

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_wait from the failure before retrying; size triggers during that wait don't retry early. During close, 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 at identify: 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_size holds after a retry, too: re-queued events that overflow it drop the oldest.
  • Types over heuristics. A Float is always a double and an Integer always an int; the whole-number-is-int rule exists in sdk-node only because JavaScript has one number type.
  • Queued identify. identify returns at once, like track, and identifies are sent in batches with BatchIdentify; a server that answers unimplemented (one older than BatchIdentify) gets one Identify call per item for the rest of the client's life. One of those calls failing raises Batcher::PartialSendError with how many went before it, so the retry or dead letter covers only the rest. on_error receives undeliverable identifies through Undelivered#identifies.
  • Bad session IDs. A session_id that 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 MaxWait of 0 to the default; here max_wait: 0 still sends each event at once, but a retry waits MIN_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::HTTP can't be cancelled from another thread, so it goes on in the background, to the chunk's last item on the per-item Identify fallback, and close reports 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. Since timeout bounds 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, flush and close don't wait at all: with two queues, what they would wait for, such as the other queue's sender, may be in on_error waiting on them. Client#close starts both batchers' closes under one lock before finishing either, so one caller owns both, their drains overlap, and a close from on_error during it finds both underway and returns.
  • Braceless keywords. Ruby passes track(id, kind, timestamp: t) and track(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; likewise identify'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: } and Google::Protobuf::Duration work 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 in Hash#merge. Well-known properties may be keyed by either name (product_id/productId), and location keys likewise (postal_code/postalCode).
  • Blocking calls. flush and close wait, with an optional timeout: and a boolean result, the Ruby counterparts of awaiting sdk-node's promises. track's timestamp option is a Time, 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. timeout bounds each phase of a request (connect, write, read) and is sent as Connect-Timeout-Ms, so the server enforces the overall deadline; a streaming list is bounded as a whole, the caller's block included, and takes a timeout: of its own.
  • Nested values. JSON.generate writes an object it doesn't know as its to_s, so a Hash or Array property, or identify's traits, go through EventBuilder.json_ready first: times become ISO 8601, other real numbers floats, and anything else is refused.
  • profiles.delete returns the DeleteResponse (erasure request id and status), as in sdk-go.

Testing notes

  • 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.rb runs the SDK against an in-process HTTP server (TestSupport::FakeServer), covering the wire format, the API key header, streaming list, error mapping, timeouts and the BatchIdentify fallback 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_event and test_accounts_for_every_item_through_partial_sends check under real concurrency that every item is delivered or dead-lettered exactly once; the fork test needs a platform with fork.

Before opening a PR: make check.