Ruby SDK for Quonfig — Feature Flags, Live Config, and Dynamic Log Levels.
Note: This SDK is stable (v1) and follows Semantic Versioning: breaking changes to the public API land only in a major (
x.0.0) release.
Add the gem to your Gemfile:
gem 'quonfig'Or install directly:
gem install quonfigrequire 'quonfig'
client = Quonfig::Client.new(sdk_key: ENV['QUONFIG_BACKEND_SDK_KEY'])
# Feature flags
if client.enabled?('new-dashboard')
# show new dashboard
end
# Typed config values
limit = client.get_int('rate-limit')
name = client.get_string('app.display-name')
regions = client.get_string_list('allowed-regions')
# Context-aware evaluation — pass a context hash as the last argument
value = client.get_string('homepage-hero', user: { key: 'user-123', country: 'US' })Contexts are hashes grouped by scope (user, team, device, etc.). You can
attach a context in three ways:
client.get_bool('beta-feature', user: { key: 'user-123', plan: 'pro' })Everything evaluated inside the block sees the supplied context. The block's
return value is returned from with_context.
result = client.with_context(user: { key: 'user-123', plan: 'pro' }) do |bound|
{
hero: bound.get_string('homepage-hero'),
limit: bound.get_int('rate-limit'),
beta?: bound.enabled?('beta-feature')
}
endCalled without a block, with_context returns an immutable BoundClient that
carries the context on every call. Useful when you want to pass a
context-bound handle down the stack.
bound = client.with_context(user: { key: 'user-123', plan: 'pro' })
bound.get_string('homepage-hero')
bound.enabled?('beta-feature')
bound.get_int('rate-limit')
in_contextis a deprecated alias ofwith_contextkept for backward compatibility through 1.0.0. New code should usewith_context.
For tests, CI, or air-gapped environments, point the client at a local workspace directory instead of the Quonfig API. In datadir mode the SDK loads JSON config files from disk — config delivery does no network I/O at all: no config fetch, no SSE stream, no polling.
client = Quonfig::Client.new(
datadir: '/path/to/workspace',
environment: 'production'
)
client.get_bool('feature-x')You can also set QUONFIG_DIR in the environment and omit the datadir:
option; when QUONFIG_DIR is set the SDK switches to datadir mode
automatically. environment is required in datadir mode — it can be provided
via the option or via QUONFIG_ENVIRONMENT.
export QUONFIG_DIR=/path/to/workspace
export QUONFIG_ENVIRONMENT=productionclient = Quonfig::Client.new # reads QUONFIG_DIR + QUONFIG_ENVIRONMENTUsage telemetry is gated on SDK-key presence, not on mode. A datadir client
with an sdk_key: configured still reports evaluation summaries and context
telemetry to the telemetry service, exactly as a delivery-mode client does —
that combination is what makes flag usage visible in the Quonfig UI for services
that read config from a checked-out workspace.
A datadir client with no SDK key has no workspace to attribute telemetry to, so it collects and sends nothing: fully offline, zero network I/O.
To run with a key but without telemetry, use the standard opt-outs:
client = Quonfig::Client.new(
datadir: '/path/to/workspace',
environment: 'production',
sdk_key: ENV['QUONFIG_BACKEND_SDK_KEY'],
collect_evaluation_summaries: false,
context_upload_mode: :none
)Changed in 1.3.0: before 1.3.0 a datadir sent nothing even with a valid SDK key. See the CHANGELOG.
In datadir mode the SDK loads the workspace once at construction time and then
serves config purely from memory. Opt in to data_dir_auto_reload: true to
have the SDK watch the directory and re-read the envelope whenever files
change — an editor save, a git pull, or a build step that rewrites the
workspace.
client = Quonfig::Client.new(
datadir: '/path/to/workspace',
environment: 'development',
data_dir_auto_reload: true # off by default — must be opted in
)
client.on_update do
puts 'Quonfig configs reloaded from disk'
end
# Edit a file under /path/to/workspace and on_update fires within ~200ms.
# On shutdown, stop stops the watcher and cancels any pending debounce.
client.stop- Local development with the datadir checked out from git.
- Self-hosted servers that
git pullthe datadir on a schedule. - CI jobs that mutate the datadir between assertions.
- Read-only / immutable filesystems (some containers, scratch images, AWS Lambda). Watch registration may fail; the SDK degrades gracefully (logs the error and continues serving the envelope it loaded at init time) but you're paying for nothing.
- Build-time-embedded workflows where the datadir is bundled into the artifact and never changes at runtime. Watching wastes a thread and a native-backend handle.
- Production paths where reload timing matters — e.g. you'd rather pin the envelope you shipped with and roll forward through a redeploy than have it shift under traffic.
Default is false; datadir mode is silent until you opt in.
- Parse-then-swap. If the new envelope fails to parse (truncated write,
mid-
git pullstate, invalid JSON), the SDK logs the error and keeps serving the previous envelope.on_updateis not fired on parse failure — only on a successful swap. - Debounced. Bursts of filesystem events (atomic-rename editor saves,
git pulltouching dozens of files) coalesce into a single re-read. Default window: 200ms — long enough to absorb the 3–5 events a typical editor emits in <50ms, short enough that interactive edits feel immediate. Tune viadata_dir_auto_reload_debounce_msif you need a different window. - Graceful degrade. If watch registration fails (read-only fs, immutable container, missing native backend), the SDK logs and continues without watching — it does not raise from the constructor.
- Symlinks. The watcher resolves
datadirto its real path at start time. Editing the file the symlink points at is detected; atomic flips that retarget the link itself are not. - Shutdown.
client.stopstops the watcher and cancels any pending debounce. There is no separate handle to manage — the watcher lifecycle is tied to the client.
The auto-reload watcher uses a background thread, which — like any Ruby
thread — does not survive fork(2). You do not need to wire this up
manually on Ruby 3.1+. After a fork, the child re-loads the workspace from
disk and registers a fresh watcher on its first use of the client (see Rails
integration below); a child that never uses the client
starts no watcher at all. The parent's watcher is left alone and keeps
working. This covers Puma clustered mode, Unicorn, Resque, Spring, and manual
fork { ... } calls — including a fork inside a Sidekiq job.
On Ruby 3.0 (no Process._fork), follow the manual on_worker_boot pattern
in the Rails integration section — Quonfig.fork
rebuilds the full client, including the datadir watcher, in the child.
Quonfig::Client.new(
datadir: '/path/to/workspace',
data_dir_auto_reload: true,
data_dir_auto_reload_debounce_ms: 1000 # wait a full second after the last event
)The default (200 ms) is tuned for interactive editing. Raise it if you have a noisy producer (continuously regenerating files) and you'd rather see one reload per second than per save. Lower it only if you've measured that 200 ms is meaningfully too slow for your use case.
See the open-source / local how-to for the cross-SDK story (sdk-node, sdk-go, sdk-ruby, sdk-python, sdk-java).
| Variable | Purpose |
|---|---|
QUONFIG_BACKEND_SDK_KEY |
SDK key used to authenticate against the Quonfig API. Used when sdk_key: is omitted. |
QUONFIG_DIR |
Path to a workspace directory. When set, the SDK runs in datadir/offline mode. |
QUONFIG_ENVIRONMENT |
Environment name (production, staging, development) evaluated in datadir mode. |
QUONFIG_DOMAIN |
Base domain used to derive api, sse, and telemetry URLs. Defaults to quonfig.com. Set to quonfig-staging.com to point at staging. Explicit api_urls: / telemetry_url: kwargs override this. |
Quonfig::Client.new(
sdk_key: '...', # required unless QUONFIG_BACKEND_SDK_KEY is set
api_urls: ['https://primary.quonfig.com', 'https://secondary.quonfig.com'],
telemetry_url: 'https://telemetry.quonfig.com',
enable_sse: true,
fallback_poll_enabled: true,
fallback_poll_interval_ms: 60_000,
init_timeout_ms: 10_000,
on_no_default: :error,
global_context: {},
datadir: '/path/to/workspace',
environment: 'production',
data_dir_auto_reload: false,
data_dir_auto_reload_debounce_ms: 200
)| Option | Type | Default | Description |
|---|---|---|---|
sdk_key |
String |
ENV['QUONFIG_BACKEND_SDK_KEY'] |
SDK key for API authentication. |
api_urls |
Array<String> |
["https://primary.${QUONFIG_DOMAIN}", "https://secondary.${QUONFIG_DOMAIN}"] |
Ordered list of API base URLs to try. SSE stream URLs are derived by prepending stream. to each hostname. Defaults derive from QUONFIG_DOMAIN (default quonfig.com). |
telemetry_url |
String |
https://telemetry.${QUONFIG_DOMAIN} |
Base URL for the telemetry service. Default derives from QUONFIG_DOMAIN. |
enable_sse |
Boolean |
true |
Receive real-time updates over Server-Sent Events. |
fallback_poll_enabled |
Boolean |
true |
Engage HTTP polling as a fallback when SSE is unavailable for >= 2x fallback_poll_interval_ms. Deprecated alias: enable_polling. |
fallback_poll_interval_ms |
Integer (ms) |
60_000 |
Interval between fallback HTTP polls, in milliseconds. Deprecated alias: poll_interval (seconds, multiplied by 1000 internally). |
init_timeout_ms |
Integer (ms) |
10_000 |
Maximum time to wait for the initial config load, in milliseconds. Deprecated alias: initialization_timeout_sec (seconds, multiplied by 1000 internally). |
on_no_default |
Symbol |
:error |
Behavior when a key has no value and no default: :error, :warn, or :ignore. |
global_context |
Hash |
{} |
Context applied to every evaluation. |
datadir |
String |
ENV['QUONFIG_DIR'] |
Path to a local workspace. When set, the SDK runs offline from disk. |
environment |
String |
ENV['QUONFIG_ENVIRONMENT'] |
Environment to evaluate in datadir mode. Required when datadir is set. |
data_dir_auto_reload |
Boolean |
false |
Datadir mode only. When true, the SDK watches the datadir and re-reads the envelope when files change. See Datadir mode: auto-reload on file changes. |
data_dir_auto_reload_debounce_ms |
Integer (ms) |
200 |
Debounce window for the auto-reload watcher — events arriving inside the window are coalesced into a single re-read. Ignored when data_dir_auto_reload is false. |
logger |
Logger-like object | nil |
Optional host-app logger (e.g. Rails.logger). Must respond to debug/info/warn/error. When set, all SDK warnings/errors flow through this logger instead of the default stderr / SemanticLogger backend. |
collect_evaluation_summaries |
Boolean |
true |
Send per-flag evaluation counts. See Telemetry. |
collect_max_evaluation_summaries |
Integer |
10_000 |
Distinct flags/configs counted per telemetry window; a key already seen keeps counting at the cap. |
context_upload_mode |
Symbol |
:periodic_example |
:periodic_example (context shapes + example contexts), :shapes_only, or :none. |
context_max_size |
Integer |
10_000 |
Context-shape fields, and separately example contexts, kept per telemetry window. |
collect_sync_interval |
Numeric (s) |
60 |
Seconds between telemetry POSTs. |
telemetry_timeout_ms |
Integer (ms) |
15_000 |
Overall deadline for one telemetry POST. |
telemetry_connect_timeout_ms |
Integer (ms) |
5_000 |
TCP connect + TLS deadline for one telemetry POST. |
telemetry_max_retained_batches |
Integer |
5 |
Failed telemetry batches kept for resend. |
telemetry_max_retained_bytes |
Integer |
2_097_152 |
Byte cap (2MB) on kept batches; a single batch larger than this is sent once and never kept. |
telemetry_max_retained_age_ms |
Integer (ms) |
300_000 |
A kept batch older than this is discarded. |
By default the SDK derives every hostname from QUONFIG_DOMAIN (default
quonfig.com):
| Role | URL |
|---|---|
| Config fetch (primary) | https://primary.quonfig.com |
| SSE stream (primary) | https://stream.primary.quonfig.com |
| Config fetch (secondary) | https://secondary.quonfig.com |
| SSE stream (secondary) | https://stream.secondary.quonfig.com |
| Telemetry | https://telemetry.quonfig.com |
Set QUONFIG_DOMAIN to move all of them together (e.g.
QUONFIG_DOMAIN=quonfig-staging.com). Automatic failover and hedging between
the primary and the secondary are on by default — the secondary runs on
separate infrastructure, and the HTTP config-fetch fails over to it if the
primary is unreachable and hedges to it if the primary is slow.
api_urls: replaces the derived list wholesale. To keep automatic failover
with custom URLs, pass both a primary and a secondary URL:
Quonfig::Client.new(
sdk_key: 'your-sdk-key',
api_urls: [
'https://primary.your-proxy.example',
'https://secondary.your-proxy.example'
]
)A single URL disables failover, and the SDK logs a warning at init. See https://docs.quonfig.com/docs/explanations/architecture/resiliency for the full model.
Each typed getter takes a config key and an optional context hash. If the key
is missing or the stored value does not match the requested type, the getter
returns nil.
| Method | Returns |
|---|---|
get_string(key, contexts = nil) |
String or nil |
get_int(key, contexts = nil) |
Integer or nil |
get_float(key, contexts = nil) |
Float or nil |
get_bool(key, contexts = nil) |
true, false, or nil |
get_string_list(key, contexts = nil) |
Array<String> or nil |
get_duration(key, contexts = nil) |
Float (seconds) or nil |
get_json(key, contexts = nil) |
Hash, Array, or nil |
enabled?(feature_name, contexts = nil) |
true or false |
Example:
client.get_string('app.display-name')
client.get_int('rate-limit', user: { key: 'user-123' })
client.get_float('pricing.multiplier')
client.get_bool('flags.new-checkout')
client.get_string_list('allowed-regions')
client.get_duration('request-timeout')
client.get_json('homepage.layout')
client.enabled?('beta-feature', user: { key: 'user-123' })Quonfig can drive per-class log levels at runtime. Set config keys like
log-levels.my_app.foo.bar to one of trace, debug, info, warn, error,
fatal and wire the filter into SemanticLogger:
require 'quonfig'
require 'semantic_logger'
client = Quonfig::Client.new(sdk_key: ENV['QUONFIG_BACKEND_SDK_KEY'])
SemanticLogger.add_appender(io: $stdout, filter: client.semantic_logger_filter)Lookup is exact-match only: logger name MyApp::Foo::Bar normalizes to
log-levels.my_app.foo.bar. If no key is set the log is allowed through and
SemanticLogger's static level decides. There is no hierarchy walk — a value on
log-levels.my_app does not affect log-levels.my_app.foo.bar.
Pass key_prefix: to use a prefix other than log-levels.:
client.semantic_logger_filter(key_prefix: 'debug.')If you use Ruby's built-in ::Logger instead of SemanticLogger, wire the
formatter returned by client.stdlib_formatter into your logger:
require 'quonfig'
require 'logger'
client = Quonfig::Client.new(
sdk_key: ENV['QUONFIG_BACKEND_SDK_KEY'],
logger_key: 'log-level.my-app'
)
logger = ::Logger.new($stdout)
logger.level = ::Logger::DEBUG
logger.formatter = client.stdlib_formatter(logger_name: 'MyApp::Services::Auth')The formatter asks the client should_log?(logger_path:, desired_level:)
for every call; lines below the configured level return an empty string
(which ::Logger writes as zero bytes, suppressing the line). logger_name
is passed to Quonfig verbatim under quonfig-sdk-logging.key so a single
log-level.my-app config can drive per-class overrides via rules like
PROP_STARTS_WITH_ONE_OF "MyApp::Services::".
Omit logger_name: to have the formatter fall through to the Logger's
progname at call time:
logger.formatter = client.stdlib_formatter
logger.progname = 'MyApp::Services::Auth'If both are supplied, the explicit logger_name: wins.
The SDK runs a background SSE thread (and optional polling thread). Ruby
threads do not survive fork(2), so a child process inherits references to
threads that no longer exist and silently stops receiving live updates.
On Ruby 3.1+ the SDK installs a Process._fork hook at load time that
handles this for you. It covers any Process.fork / Kernel#fork path —
Puma's clustered mode, Unicorn, Spring, Resque, a fork { ... } inside a
Sidekiq job, and the parallel gem. No customer wiring is required.
The hook is child-only. A fork never touches the process that forked.
The parent keeps its SSE stream, its poller, its telemetry reporter, and its
live config straight through any number of forks — so a long-lived process
that forks workers and keeps evaluating (a Sidekiq process using the
parallel gem, a rake task that calls fork) stays current.
In the child, the SDK drops the inherited references without touching the
objects — it never closes the inherited socket, because fork(2) duplicates
the file descriptor and closing the child's copy of a TLS connection would
tear down the stream the parent is still using.
Upgrading from 1.3.0 or earlier: if you added a manual
Quonfig.instance.after_fork_in_childcall in the parent as a workaround for the parent going dark, remove it. As of 1.4.0 that call is a no-op in the process that owns the client — the SDK decides that by comparing the current pid against the one it stamped when the client was built, so it is exact whether or not the parent has any threads running. It will not hurt you, but it is no longer doing anything, and the parent needs no call.
After a fork, the child re-initializes on its first use of the client,
exactly like a newly constructed client — including its on_init_failure
policy: it fetches its own config and starts its own threads. It does not
evaluate from the parent's snapshot. The hook itself does no I/O — it drops
what the child inherited and arms the re-initialization. So the first call in
a forked child pays one fetch, and a child that never uses the client costs
nothing: no fetch, no stream, no thread, no telemetry.
Caveats:
-
Ruby 3.0 has no hookable choke point — fall back to manual wiring (below).
-
system("fork-and-exec ...")andProcess.spawnare not covered (they do not go throughProcess._fork), but those execute a new program, so the in-process SSE state is moot. -
The first lookup in a forked child blocks on that child's own config fetch, under the same
init_timeout_msandon_init_failureoptions a fresh client uses. The default ison_init_failure: :raise, so if the child's fetch fails against everyapi_urlsleg (primary and secondary both unreachable) the failure raises out of that first lookup, exactly asClient.newwould at boot, and later lookups keep raising — without re-fetching — until the update channel lands an envelope, at which point the client serves config normally again. On 1.3.0 and earlier a child in that situation silently served the parent's snapshot instead. If you would rather a forked child serve defaults through an outage, seton_init_failure: :return: a failed fetch then logs one line and the child serves defaults until its stream or poller lands the first envelope. -
Other threads wait. Every thread that reaches the client while that first fetch is in flight blocks on it and then sees the fetched config. One fetch, one stream dial, and one telemetry reporter per child, however many threads race the first request.
-
connection_statenever triggers the re-initialization — a diagnostic must not open a socket. A child that has not used the client yet answers:initializing, which is exactly what it is; it flips to:connectedon first use. -
The child's telemetry aggregators start empty. The parent flushes the data it collected before the fork; the child reports only its own.
-
Per-job forking pays per job. A Resque-style worker that forks a child per job (or
Parallel.mapwith one row per process) pays, in each child that touches the client, one config fetch, one SSE dial, and — when the child exits normally — one telemetry POST at exit. That is the price of the child holding its own current config and its own telemetry window, and it is deliberate — the delivery service counts each of those connections as a real client. A child that never uses the client pays none of it.The at-exit drain depends on the child running
at_exithandlers at all.Parallelchildren do. Resque children callexit!by default, which skips everyat_exithandler — so there is no drain and no telemetry POST unless you setRUN_AT_EXIT_HOOKS=1. Nothing else about the child changes; it just never flushes the evaluations it collected. -
In datadir mode a child whose workspace fails to load never dials the network: it logs the failure and, if
data_dir_auto_reloadis on, watches for a repaired workspace; otherwise the next use retries the load. -
A child forked from inside an
on_updatecallback must exit.on_updateruns on the SDK's SSE reader thread, so a child created with non-blockfork(no block) from inside that callback mustexit!(orexec) rather than return from the callback — a child that returns lets the inherited reader thread resume on the parent's socket, where it consumes frames the parent never sees. Block-formfork { ... }and any child that exits are unaffected.
With the automatic fork hook, the typical Puma config needs no Quonfig lifecycle wiring — initialize in your Rails initializer and let the hook handle the rest:
# config/initializers/quonfig.rb
Quonfig.init(Quonfig::Options.new(sdk_key: ENV.fetch('QUONFIG_BACKEND_SDK_KEY')))If you use SemanticLogger you still need to reopen it in each worker — but
Quonfig.fork is not needed in that block on 3.1+. The SDK has already
handled the fork by the time on_worker_boot runs, and since 1.4.0 a
Quonfig.fork call in a child the hook has already prepared simply returns
the same client (so a leftover call from older docs is harmless):
# config/puma.rb (Ruby 3.1+)
on_worker_boot do
SemanticLogger.reopen
endIf you're on Ruby 3.0 (no Process._fork), wire the worker boot hook
manually:
# config/puma.rb (Ruby 3.0 only)
on_worker_boot do
Quonfig.fork # rebuild a fresh client per worker
SemanticLogger.reopen # if you use SemanticLogger
endDo not add a before_fork { Quonfig.instance.stop } — the master's
client does not need to be torn down for the workers to be healthy, and
stopping it means the master stops receiving config.
Sidekiq OSS does not fork: it runs jobs on threads inside one process, so
Quonfig.init in your initializer is all you need on any Ruby version.
Some jobs do fork — the parallel gem, an explicit fork { ... }, or
Sidekiq Enterprise's swarm mode. On Ruby 3.1+ those are covered
automatically, with nothing to call, and (since 1.4.0) the Sidekiq process
itself keeps streaming config the whole time.
Ruby 3.0 is end-of-life and has no Process._fork hook. The parallel gem
has no per-worker boot hook to wire a rebuild into either — Parallel.each
just runs your block in each child, once per row — so calling Quonfig.fork
at the top of the block builds a new client per row, each with its own
SSE stream and telemetry reporter. Upgrade to 3.1+ if you can. If you must
stay on 3.0, rebuild once per child process by memoizing on the pid:
# Ruby 3.0 only — one rebuild per child process, not one per row.
Parallel.each(batch, in_processes: 4) do |row|
Quonfig.fork if $quonfig_pid != Process.pid
$quonfig_pid = Process.pid
# ...
endSpring forks the preloader for each command. On Ruby 3.1+ the automatic hook already rebuilds the client in each spawned command, and the preloader itself keeps streaming. On Ruby 3.0, either:
- Recommended: initialize lazily — wrap
Quonfig.initso it only runs the first timeQuonfig.instanceis called from a non-preloader process. - Or: call
Quonfig.forkfrom aSpring.after_forkhook.
# config/spring.rb (Ruby 3.0 only)
Spring.after_fork do
Quonfig.fork if defined?(Quonfig) && Quonfig.instance_variable_get(:@singleton)
endQuonfig::Client is a long-lived object — keep it out of app/ (where
Zeitwerk reloads classes on every request) and pin it to a constant set in a
Rails initializer. The client itself is reload-safe because it does not
reference any application classes; the failure mode to avoid is creating a
new client per request, which leaks SSE threads and quickly exhausts file
descriptors.
# config/initializers/quonfig.rb
# Quonfig.init is idempotent — a second call warns and returns the existing
# singleton — so it's safe to wrap in to_prepare for reload-friendliness.
Rails.application.config.to_prepare do
Quonfig.init(Quonfig::Options.new(sdk_key: ENV.fetch('QUONFIG_BACKEND_SDK_KEY')))
endQuonfig::Client is safe to share across threads. Reads (get, enabled?,
get_*) and SSE-driven writes to the underlying ConfigStore use
Concurrent::Map for per-key atomicity. Eventual consistency across an
envelope is intentional: a reader concurrent with envelope application may
observe the new value for some keys and the old value for others, then
converge once the envelope finishes applying.
Forking is handled for you on Ruby 3.1+: the child rebuilds automatically and
the parent is left running (see Rails integration). On
Ruby 3.0, Quonfig.fork is the way to "carry" a client into a child — do not
reuse the parent's client object in a child process without it.
Quonfig::Client exposes two read-only getters for monitoring SDK liveness:
client.last_successful_refresh— aTime(UTC) marking the most recent envelope install (any source: datadir, initial HTTP fetch, SSE, or fallback polling). Returnsnilbefore the first install. Preserved acrossstop.client.connection_state— aSymboldescribing the aggregate state::initializing,:connected,:disconnected, or:falling_back.
Do not wire
last_successful_refreshorconnection_statedirectly into a Kubernetes liveness probe. These signals are diagnostic, not pass/fail. A liveness probe based on SDK freshness will amplify transient network blips into restart cascades.
Compose your own threshold from the two getters if you need a dashboard signal — but route alerts through a metrics pipeline, not a probe that restarts the process.
There is intentionally no client.healthy? primitive.
With an SDK key the client sends usage telemetry to telemetry_url so the
Quonfig dashboard can show which flags and configs are evaluated and with what
contexts. Telemetry never affects flag evaluation: every failure below is
contained in the background reporter thread.
What is sent. Evaluation summaries (per flag/config: counts per rule and
value), context shapes (context field names and types), example contexts (up to
one per context key per hour) and failover counters. Opt out with
collect_evaluation_summaries: false and context_upload_mode: :shapes_only
(no example contexts) or :none (no context data). With both off, no reporter
runs.
How it is sent.
- One POST every
collect_sync_intervalseconds (60), with at most one POST in flight. A tick that fires while a POST is still out is skipped and its data rolls into the next window. - Each POST has an overall deadline of
telemetry_timeout_ms(15s) and a connect + TLS deadline oftelemetry_connect_timeout_ms(5s). - When a POST fails (timeout, network error, 408, 429 or 5xx), the serialized
batch is kept byte-for-byte and resent unchanged, never merged with newer
data, so the server can recognize a resend of a batch that did land. Up to 5
batches / 2MB are kept for up to 5 minutes; beyond that the oldest is
dropped. A single batch larger than 2MB is sent once and never kept: if that
one POST fails, the batch is dropped. Large batches come from example
contexts;
context_upload_mode: :shapes_onlyor a lowercontext_max_sizekeeps batches small. - Resends happen no sooner than 30s after a failure and after any
Retry-After(honored up to 10 minutes), oldest first, then the current window. - A 401, 403 or 404 means the SDK key or
telemetry_urlis wrong: the SDK logs one error and disables telemetry for the rest of the process. Any other 4xx drops that one batch with an error (the server rejected the payload) and telemetry continues.
Logging. A failed POST logs at debug only. The first batch actually dropped
logs one warning with the last POST result and queue depth; further drops log
at debug with a summary warning at most every 10 minutes; the first success
after failures logs one info line. The SDK's default logger prints warnings and
errors only; pass logger: to receive the debug and info lines.
Shutdown. stop (and the at_exit hook the reporter registers) sends the
current window once with a 5s deadline, does not resend kept batches, and never
blocks process exit longer than that.
Memory. Everything is bounded: at most 10,000 evaluation-summary keys, 10,000 context-shape fields and 10,000 example contexts per window (keys already seen keep counting at the cap), a 100,000-entry example-context rate-limit map, and the 2MB retained queue.
Full documentation, including SPEC, SDK reference, and operational guides, is available at https://quonfig.com/docs.
MIT