kilden 0.1.0.alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 40a40f42c4bc36e6168968c964a6f027fbb0a5a2e72567d18cc056c199d4ea57
4
+ data.tar.gz: aac87c93c2ae56d86f38ff80e214b95bfec01771122d1a1d38ce08c773fb38cf
5
+ SHA512:
6
+ metadata.gz: 5adedf1b530c90e61417e9b199bb927ccb66099c3abe43055159b41e09136f9d67f937126bf8565338bfbad2c0410365edd81687c362c2e8e16a477809d57aa3
7
+ data.tar.gz: 56835a2ee186f650633226a60ef5b69ca6308ebf4244b42031df016382ea2fc9c055568b90e4ccc22b2f98442d626a256cb416af4cd59d810932c3a0428e52ce
data/CHANGELOG.md ADDED
@@ -0,0 +1,47 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0.alpha.3] - 2026-07-14
11
+
12
+ ### Changed
13
+
14
+ - Repository moved to the kildenhq org; releases publish to RubyGems via
15
+ OIDC trusted publishing.
16
+
17
+ ## [0.1.0.alpha.2] - 2026-07-14
18
+
19
+ ### Fixed
20
+
21
+ - Any 2xx from `/capture` is success; the response body is never parsed
22
+ (spec clarification — a 200 with a corrupt body was retried before).
23
+ - The transport detects responses truncated mid-body (connection cut) and
24
+ classifies them as retryable network errors.
25
+ - `EventQueue#empty?` was missing, silently killing and respawning the
26
+ worker thread after every batch.
27
+
28
+ ## [0.1.0.alpha.1] - 2026-07-14
29
+
30
+ ### Added
31
+
32
+ - `Kilden::Client`: `track`, `identify`, `alias`, `flush`, `close` with a
33
+ bounded in-memory queue, background worker, gzip, and retries with
34
+ exponential backoff honoring `Retry-After`.
35
+ - Fork safety under preforking servers (puma/unicorn), tested against a real
36
+ `puma -w 2 --preload` in CI.
37
+ - `Kilden::IdentitySigner`: hand-rolled HS256 identity tokens, byte-exact
38
+ against the platform's vectors.
39
+ - Feature flags: `enabled?` / `feature_flag` over `/decide` with a 30s
40
+ TTL + LRU cache and `person_properties` / `default:` options.
41
+ - Frozen rollout hashing (spec §8.3), vector-tested for future local eval.
42
+ - Vector runners for the three kilden-sdk-spec vector files, wired into CI
43
+ against the spec's mock capture server.
44
+
45
+ [Unreleased]: https://github.com/kildenhq/kilden-sdk-ruby/compare/v0.1.0-alpha.2...HEAD
46
+ [0.1.0.alpha.2]: https://github.com/kildenhq/kilden-sdk-ruby/compare/v0.1.0-alpha.1...v0.1.0-alpha.2
47
+ [0.1.0.alpha.1]: https://github.com/kildenhq/kilden-sdk-ruby/releases/tag/v0.1.0-alpha.1
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Freshwork S.p.A.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,151 @@
1
+ <p align="center">
2
+ <img src=".github/assets/hero.png" alt="Kilden Ruby SDK" width="800">
3
+ </p>
4
+
5
+ # kilden
6
+
7
+ [![Gem Version](https://img.shields.io/gem/v/kilden)](https://rubygems.org/gems/kilden)
8
+ [![ci](https://github.com/kildenhq/kilden-sdk-ruby/actions/workflows/ci.yml/badge.svg)](https://github.com/kildenhq/kilden-sdk-ruby/actions/workflows/ci.yml)
9
+ [![license](https://img.shields.io/github/license/kildenhq/kilden-sdk-ruby)](LICENSE)
10
+
11
+ [Kilden](https://kilden.io) is a customer data platform — product analytics,
12
+ campaigns and session replay on one event pipeline. This is the server-side
13
+ Ruby SDK: events your backend can vouch for, identity-token signing, and
14
+ feature flags. Zero runtime dependencies, fork-safe under puma and unicorn.
15
+
16
+ ```sh
17
+ gem install kilden --pre
18
+ ```
19
+
20
+ ```ruby
21
+ require "kilden"
22
+
23
+ kilden = Kilden::Client.new(ENV["KILDEN_SECRET_KEY"])
24
+ kilden.track("user_42", "order_completed", { "revenue" => 99.9, "currency" => "CLP" })
25
+ kilden.close # drain before the process exits
26
+ ```
27
+
28
+ Use your project's **secret key** (`sk_…`), never the public one. Events sent
29
+ with the secret key land as `source=server`, `verified=true` — facts the
30
+ campaign engine can act on. The constructor rejects public keys outright, and
31
+ the secret key must never reach a browser.
32
+
33
+ ## Identity verification
34
+
35
+ Anyone can open a devtools console and send events as `ceo@yourcompany.com`
36
+ with your public key. Kilden's fix is a short-lived JWT your backend signs;
37
+ the platform then marks those browser events verified. Signing it is the part
38
+ of the trust model only your backend can do, and it is three lines:
39
+
40
+ ```ruby
41
+ signer = Kilden::IdentitySigner.new(ENV["KILDEN_IDENTITY_SECRET"], kid: "k1")
42
+
43
+ # In the controller that renders your page or serves your token endpoint:
44
+ token = signer.sign(current_user.id.to_s, traits: { "plan" => current_user.plan })
45
+ ```
46
+
47
+ Hand `token` to the web SDK (`kilden.identify(id, traits, { token })` or its
48
+ refresh endpoint). Signed traits override unsigned ones during enrichment.
49
+
50
+ **Only sign a `sub` your backend authenticated.** Signing request input —
51
+ `signer.sign(params[:user_id])` — lets anyone impersonate anyone, with a
52
+ "verified" stamp on top. TTL defaults to 1 hour and is capped at 7 days.
53
+
54
+ A Rails token endpoint, for the web SDK to refresh against:
55
+
56
+ ```ruby
57
+ # config/routes.rb
58
+ post "/kilden/identity", to: "kilden_identity#create"
59
+
60
+ # app/controllers/kilden_identity_controller.rb
61
+ class KildenIdentityController < ApplicationController
62
+ before_action :authenticate_user!
63
+
64
+ def create
65
+ signer = Kilden::IdentitySigner.new(ENV["KILDEN_IDENTITY_SECRET"], kid: "k1")
66
+ render json: {
67
+ distinct_id: current_user.id.to_s,
68
+ token: signer.sign(current_user.id.to_s),
69
+ traits: {}
70
+ }
71
+ end
72
+ end
73
+ ```
74
+
75
+ ## Feature flags
76
+
77
+ Flags are evaluated remotely against `{host}/decide` and cached for 30
78
+ seconds per `distinct_id`. Always pass a `default:` — it is what you get when
79
+ Kilden cannot answer in time:
80
+
81
+ ```ruby
82
+ if kilden.enabled?("new_checkout", "user_42",
83
+ person_properties: { "plan" => "pro" }, default: false)
84
+ render_new_checkout
85
+ end
86
+
87
+ kilden.feature_flag("experiment_button", "user_42", default: false)
88
+ # => false | true | "variant_b"
89
+ ```
90
+
91
+ `person_properties` overrides the stored person traits for that evaluation
92
+ only (and bypasses the cache). The signature is already shaped for local
93
+ evaluation, which will arrive without an API change.
94
+
95
+ ## Batching and shutdown
96
+
97
+ Events queue in memory (bounded, default 10 000) and a background thread
98
+ flushes every 10 seconds or every 20 events, whichever comes first. The
99
+ queue never blocks your request thread; when it is full, new events are
100
+ dropped and counted in `kilden.dropped_count`.
101
+
102
+ **Call `close` when your process exits.** It drains the queue with a
103
+ 10-second deadline and stops the worker. An `at_exit` hook covers the
104
+ forgetful, but a hard kill (SIGKILL, OOM) loses whatever was still queued —
105
+ that is the price of never blocking your app. `flush` forces a synchronous
106
+ drain without shutting down.
107
+
108
+ Retries: 429/5xx/network errors retry up to 3 times with exponential backoff
109
+ and jitter, honoring `Retry-After`. Other 4xx responses are dropped and
110
+ logged — retrying a 401 is spam.
111
+
112
+ ### Preforking servers (puma, unicorn) and Sidekiq
113
+
114
+ Nothing to configure. The SDK detects the PID change after a fork, discards
115
+ the queue inherited from the master (the master still owns those events) and
116
+ starts a fresh worker thread in the child. This is tested in CI against a
117
+ real preforked puma. Sidekiq processes are plain long-lived processes: build
118
+ one client and reuse it.
119
+
120
+ ## Configuration
121
+
122
+ ```ruby
123
+ Kilden::Client.new(
124
+ ENV["KILDEN_SECRET_KEY"],
125
+ host: "https://ingest.kilden.io", # self-hosted: your ingest URL
126
+ flush_at: 20, # queue length that triggers a flush
127
+ flush_interval: 10, # seconds between periodic flushes
128
+ max_queue_size: 10_000, # hard cap; new events drop beyond it
129
+ timeout: 3, # seconds per HTTP request
130
+ debug: false, # verbose logging, $-prefix warnings
131
+ enabled: true # false = full no-op for tests/CI
132
+ )
133
+ ```
134
+
135
+ ## Spec
136
+
137
+ This SDK implements the
138
+ [Kilden server SDK spec](https://github.com/kildenhq/kilden-sdk-spec)
139
+ (v0.1) and runs its frozen test vectors — including byte-exact identity
140
+ tokens and the flag rollout hashing — against the spec's mock capture server
141
+ in CI. Behavior changes land in the spec first.
142
+
143
+ ## Community
144
+
145
+ - [Docs](https://docs.kilden.io)
146
+ - [Discussions](https://github.com/kildenhq/kilden-sdk-ruby/discussions)
147
+ for questions — answers stay searchable.
148
+
149
+ ## License
150
+
151
+ [MIT](LICENSE)
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Kilden
6
+ # The canonical JSON form frozen by the spec (§6.1) so identity tokens are
7
+ # byte-identical across the five SDKs: object keys sorted lexicographically
8
+ # (byte order) at every nesting level, compact separators, UTF-8 preserved,
9
+ # and the three HTML-unsafe ASCII characters escaped the way Go's
10
+ # encoding/json does — the platform's reference generator.
11
+ # @api private
12
+ module CanonicalJSON
13
+ HTML_UNSAFE = { "&" => "\\u0026", "<" => "\\u003c", ">" => "\\u003e" }.freeze
14
+
15
+ module_function
16
+
17
+ def generate(value)
18
+ case value
19
+ when Hash
20
+ pairs = value.keys.map(&:to_s).sort.map do |key|
21
+ raw = value.key?(key) ? value[key] : value[key.to_sym]
22
+ "#{string(key)}:#{generate(raw)}"
23
+ end
24
+ "{#{pairs.join(',')}}"
25
+ when Array
26
+ "[#{value.map { |v| generate(v) }.join(',')}]"
27
+ when String
28
+ string(value)
29
+ when Integer, Float, TrueClass, FalseClass
30
+ JSON.generate(value)
31
+ when nil
32
+ "null"
33
+ else
34
+ string(value.to_s)
35
+ end
36
+ end
37
+
38
+ def string(value)
39
+ JSON.generate(value).gsub(/[&<>]/, HTML_UNSAFE)
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,374 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module Kilden
7
+ # The Kilden server-side client: bounded in-memory queue, background
8
+ # worker, retries with backoff, remote feature flags. Construction is the
9
+ # only place that raises (spec contract 2); after that every public method
10
+ # is exception-safe — telemetry never takes down a request.
11
+ #
12
+ # kilden = Kilden::Client.new(ENV["KILDEN_SECRET_KEY"])
13
+ # kilden.track("user_42", "order_completed", { "revenue" => 99.9 })
14
+ # kilden.close # on shutdown; an at_exit hook covers the forgetful
15
+ class Client
16
+ DEFAULT_HOST = "https://ingest.kilden.io"
17
+ MAX_EVENT_BYTES = 200
18
+ MAX_DISTINCT_ID_BYTES = 512
19
+ BATCH_LIMIT = 1000
20
+ CLOSE_DEADLINE = 10
21
+
22
+ # dropped_count: events lost to a full queue or exhausted retries
23
+ # (contract 7/8) — observable so operators can alert on it.
24
+ def dropped_count
25
+ return 0 unless @enabled
26
+
27
+ @queue.dropped_count + @sender.dropped_total
28
+ end
29
+
30
+ def initialize(write_key, host: DEFAULT_HOST, flush_at: 20, flush_interval: 10,
31
+ max_queue_size: 10_000, timeout: 3, transport: nil, debug: false,
32
+ enabled: true, logger: nil)
33
+ unless write_key.is_a?(String) && !write_key.empty?
34
+ raise ConfigurationError,
35
+ "a write key is required — find your project's secret key (sk_...) in the Kilden panel"
36
+ end
37
+ if write_key.start_with?("wk_")
38
+ raise ConfigurationError,
39
+ "#{write_key[0, 10]}… is a public write key. Server SDKs authenticate with the secret key (sk_...): " \
40
+ "public keys degrade events to source=client and break verified revenue. " \
41
+ "Never ship the secret key to a browser."
42
+ end
43
+
44
+ @debug = debug ? true : false
45
+ @logger = logger || Log.new(@debug ? :debug : :warn)
46
+ @enabled = enabled ? true : false
47
+ @flush_interval = flush_interval
48
+ return unless @enabled
49
+
50
+ @queue = EventQueue.new(max_size: max_queue_size, flush_at: flush_at)
51
+ @sender = Sender.new(
52
+ write_key: write_key,
53
+ host: host,
54
+ transport: transport || Transport::NetHttp.new(timeout: timeout),
55
+ logger: @logger
56
+ )
57
+ @decide = Decide.new(write_key: write_key, host: host, timeout: timeout,
58
+ transport: transport, logger: @logger)
59
+ @flag_cache = FlagCache.new
60
+ @pid = Process.pid
61
+ @worker = nil
62
+ @lifecycle = Mutex.new
63
+ @closed = false
64
+ @shutdown_deadline = nil
65
+
66
+ at_exit { close }
67
+ end
68
+
69
+ def track(distinct_id, event, properties = {}, opts = {})
70
+ guard do
71
+ next unless @enabled && open_for_events?
72
+
73
+ payload = build_event(distinct_id, event, properties, opts)
74
+ enqueue(payload) if payload
75
+ nil
76
+ end
77
+ end
78
+
79
+ def identify(distinct_id, traits = {}, opts = {})
80
+ guard do
81
+ next unless @enabled && open_for_events?
82
+
83
+ traits = {} if traits.nil?
84
+ unless traits.is_a?(Hash)
85
+ @logger.warn("kilden: identify traits must be a Hash; event dropped")
86
+ next
87
+ end
88
+ payload = build_event(distinct_id, "$identify", { "$set" => traits }, opts, reserved: true)
89
+ enqueue(payload) if payload
90
+ nil
91
+ end
92
+ end
93
+
94
+ # `alias` is a Ruby keyword, so the method is defined dynamically; the
95
+ # call site reads naturally: kilden.alias("anon_…", "user_42").
96
+ define_method(:alias) do |previous_id, distinct_id|
97
+ guard do
98
+ next unless @enabled && open_for_events?
99
+
100
+ next unless valid_id?(distinct_id, "alias distinct_id")
101
+
102
+ payload = build_event(previous_id, "$alias", { "$alias" => distinct_id }, {}, reserved: true)
103
+ enqueue(payload) if payload
104
+ nil
105
+ end
106
+ end
107
+
108
+ def enabled?(flag_key, distinct_id, person_properties: nil, default: false)
109
+ guard(default) do
110
+ state, value = fetch_flag(flag_key, distinct_id, person_properties)
111
+ next default unless state == :ok
112
+
113
+ value == true || value.is_a?(String)
114
+ end
115
+ end
116
+
117
+ def feature_flag(flag_key, distinct_id, person_properties: nil, default: false)
118
+ guard(default) do
119
+ state, value = fetch_flag(flag_key, distinct_id, person_properties)
120
+ state == :ok ? value : default
121
+ end
122
+ end
123
+
124
+ # Blocking: drains everything queued at this moment, retries included.
125
+ def flush
126
+ guard do
127
+ next unless @enabled
128
+
129
+ check_fork
130
+ @queue.drain.each_slice(BATCH_LIMIT) { |batch| @sender.send_batch(batch) }
131
+ nil
132
+ end
133
+ end
134
+
135
+ # flush with a 10-second deadline, then worker shutdown. Idempotent;
136
+ # events sent after close are dropped with a warning.
137
+ def close
138
+ guard do
139
+ next unless @enabled
140
+
141
+ @lifecycle.synchronize do
142
+ next if @closed
143
+
144
+ @closed = true
145
+ @shutdown_deadline = monotonic + CLOSE_DEADLINE
146
+ @queue.close
147
+ end
148
+
149
+ worker = @worker
150
+ if worker&.alive?
151
+ worker.join([@shutdown_deadline - monotonic, 0].max)
152
+ if worker.alive?
153
+ worker.kill
154
+ abandoned = @queue.drain.size
155
+ if abandoned.positive?
156
+ @sender.dropped!(abandoned)
157
+ @logger.warn("kilden: close deadline hit; dropped #{abandoned} events")
158
+ end
159
+ end
160
+ else
161
+ @queue.drain.each_slice(BATCH_LIMIT) do |batch|
162
+ @sender.send_batch(batch, deadline: @shutdown_deadline)
163
+ end
164
+ end
165
+ nil
166
+ end
167
+ end
168
+
169
+ # Frozen wire format for timestamps (spec §4.4).
170
+ def self.format_time(time)
171
+ time.getutc.strftime("%Y-%m-%dT%H:%M:%S.%LZ")
172
+ end
173
+
174
+ private
175
+
176
+ def guard(fallback = nil)
177
+ yield
178
+ rescue StandardError => e
179
+ # Contract 1: the public API never raises after construction.
180
+ @logger&.error("kilden: suppressed #{e.class}: #{e.message}")
181
+ fallback
182
+ end
183
+
184
+ def open_for_events?
185
+ if @closed
186
+ @logger.warn("kilden: client is closed; event dropped")
187
+ return false
188
+ end
189
+ true
190
+ end
191
+
192
+ def valid_id?(value, label)
193
+ unless value.is_a?(String) && !value.empty?
194
+ @logger.warn("kilden: #{label} must be a non-empty string; event dropped")
195
+ return false
196
+ end
197
+ if value.bytesize > MAX_DISTINCT_ID_BYTES
198
+ @logger.warn("kilden: #{label} exceeds #{MAX_DISTINCT_ID_BYTES} bytes; event dropped")
199
+ return false
200
+ end
201
+ true
202
+ end
203
+
204
+ def build_event(distinct_id, event, properties, opts, reserved: false)
205
+ return nil unless valid_id?(distinct_id, "distinct_id")
206
+
207
+ unless event.is_a?(String) && !event.empty?
208
+ @logger.warn("kilden: event must be a non-empty string; event dropped")
209
+ return nil
210
+ end
211
+ if event.bytesize > MAX_EVENT_BYTES
212
+ @logger.warn("kilden: event name exceeds #{MAX_EVENT_BYTES} bytes; event dropped")
213
+ return nil
214
+ end
215
+ properties = {} if properties.nil?
216
+ unless properties.is_a?(Hash)
217
+ @logger.warn("kilden: properties must be a Hash; event dropped")
218
+ return nil
219
+ end
220
+ if @debug && !reserved && (event.start_with?("$") || properties.keys.any? { |k| k.to_s.start_with?("$") })
221
+ @logger.debug("kilden: the $ prefix is reserved for Kilden events/properties (sent anyway)")
222
+ end
223
+
224
+ begin
225
+ JSON.generate(properties)
226
+ rescue StandardError
227
+ @logger.warn("kilden: properties are not JSON-serializable; event dropped")
228
+ return nil
229
+ end
230
+
231
+ timestamp = event_timestamp(opts)
232
+ return nil unless timestamp
233
+
234
+ uuid = opts[:uuid] || opts["uuid"]
235
+ if uuid && !UUID.canonical?(uuid)
236
+ @logger.warn("kilden: uuid option is not a canonical UUID; event dropped")
237
+ return nil
238
+ end
239
+
240
+ {
241
+ "uuid" => uuid || UUID.v7,
242
+ "event" => event,
243
+ "distinct_id" => distinct_id,
244
+ "properties" => properties,
245
+ "timestamp" => timestamp
246
+ }
247
+ end
248
+
249
+ def event_timestamp(opts)
250
+ raw = opts[:timestamp] || opts["timestamp"]
251
+ return Client.format_time(Time.now) if raw.nil?
252
+
253
+ time = case raw
254
+ when Time then raw
255
+ when String then Time.iso8601(raw)
256
+ else raw.respond_to?(:to_time) ? raw.to_time : nil
257
+ end
258
+ return Client.format_time(time) if time
259
+
260
+ @logger.warn("kilden: timestamp option is not a time; event dropped")
261
+ nil
262
+ rescue ArgumentError
263
+ @logger.warn("kilden: timestamp option is not a valid ISO 8601 time; event dropped")
264
+ nil
265
+ end
266
+
267
+ def enqueue(payload)
268
+ check_fork
269
+ unless @queue.push(payload)
270
+ @logger.warn("kilden: queue full (#{payload['event']} dropped)")
271
+ return
272
+ end
273
+ ensure_worker
274
+ end
275
+
276
+ # Contract 9: preforking servers (puma, unicorn) fork after boot. The
277
+ # child inherits the parent's queue and a dead worker thread; detect the
278
+ # PID change, discard the inherited events (the parent owns them —
279
+ # resending duplicates) and start a fresh worker.
280
+ def check_fork
281
+ return if @pid == Process.pid
282
+
283
+ @lifecycle.synchronize do
284
+ next if @pid == Process.pid
285
+
286
+ discarded = @queue.reset!
287
+ @flag_cache.clear
288
+ @worker = nil
289
+ @closed = false
290
+ @shutdown_deadline = nil
291
+ @pid = Process.pid
292
+ @logger.info("kilden: fork detected (pid #{@pid}); " \
293
+ "discarded #{discarded} inherited events and restarted the worker")
294
+ end
295
+ end
296
+
297
+ def ensure_worker
298
+ return if @worker&.alive?
299
+
300
+ @lifecycle.synchronize do
301
+ next if @worker&.alive?
302
+
303
+ @worker = Thread.new do
304
+ loop do
305
+ batch = @queue.wait_batch(@flush_interval, max: BATCH_LIMIT)
306
+ @sender.send_batch(batch, deadline: @shutdown_deadline) unless batch.empty?
307
+ break if @queue.closed? && @queue.empty?
308
+ end
309
+ end
310
+ @worker.name = "kilden-worker"
311
+ end
312
+ end
313
+
314
+ def fetch_flag(flag_key, distinct_id, person_properties)
315
+ return [:miss, nil] unless @enabled
316
+
317
+ unless flag_key.is_a?(String) && !flag_key.empty?
318
+ @logger.warn("kilden: flag_key must be a non-empty string")
319
+ return [:miss, nil]
320
+ end
321
+ return [:miss, nil] unless valid_id?(distinct_id, "distinct_id")
322
+
323
+ check_fork
324
+
325
+ # person_properties make the evaluation non-reusable: bypass the cache
326
+ # entirely (no read, no write) per spec §8.2.
327
+ if person_properties.nil? && (cached = @flag_cache.get(distinct_id))
328
+ return cached.key?(flag_key) ? [:ok, cached[flag_key]] : [:miss, nil]
329
+ end
330
+
331
+ flags = @decide.flags_for(distinct_id, person_properties)
332
+ return [:miss, nil] unless flags
333
+
334
+ @flag_cache.set(distinct_id, flags) if person_properties.nil?
335
+ flags.key?(flag_key) ? [:ok, flags[flag_key]] : [:miss, nil]
336
+ end
337
+
338
+ def monotonic
339
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
340
+ end
341
+ end
342
+
343
+ # One-attempt /decide lookups (spec §8.2: a flag answer that arrives after
344
+ # a retry budget is useless — return the default instead).
345
+ # @api private
346
+ class Decide
347
+ def initialize(write_key:, host:, timeout:, transport:, logger:)
348
+ @write_key = write_key
349
+ @url = "#{host.chomp('/')}/decide"
350
+ @transport = transport || Transport::NetHttp.new(timeout: timeout)
351
+ @logger = logger
352
+ end
353
+
354
+ def flags_for(distinct_id, person_properties)
355
+ request = { "write_key" => @write_key, "distinct_id" => distinct_id }
356
+ request["person_properties"] = person_properties unless person_properties.nil?
357
+
358
+ response = @transport.post(@url, JSON.generate(request),
359
+ "Content-Type" => "application/json",
360
+ "User-Agent" => "kilden-ruby/#{VERSION}")
361
+ unless response.status == 200
362
+ reason = response.network_error? ? response.error&.class : "HTTP #{response.status}"
363
+ @logger.warn("kilden: /decide failed (#{reason}); using defaults")
364
+ return nil
365
+ end
366
+
367
+ flags = JSON.parse(response.body)["flags"]
368
+ flags.is_a?(Hash) ? flags : nil
369
+ rescue JSON::ParserError
370
+ @logger.warn("kilden: /decide returned a malformed body; using defaults")
371
+ nil
372
+ end
373
+ end
374
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kilden
4
+ # Bounded in-memory queue (spec contract 7): at capacity the NEW event is
5
+ # dropped, never the old ones, and the drop is counted. Wakes the worker
6
+ # when flush_at is reached.
7
+ # @api private
8
+ class EventQueue
9
+ attr_reader :dropped_count
10
+
11
+ def initialize(max_size:, flush_at:)
12
+ @max_size = max_size
13
+ @flush_at = flush_at
14
+ @items = []
15
+ @dropped_count = 0
16
+ @mutex = Mutex.new
17
+ @signal = ConditionVariable.new
18
+ @closed = false
19
+ end
20
+
21
+ # Returns false when the event was dropped because the queue is full.
22
+ def push(event)
23
+ @mutex.synchronize do
24
+ if @items.size >= @max_size
25
+ @dropped_count += 1
26
+ return false
27
+ end
28
+ @items << event
29
+ @signal.signal if @items.size >= @flush_at
30
+ true
31
+ end
32
+ end
33
+
34
+ # Blocks until flush_at is reached, `interval` elapses, or close; then
35
+ # pops up to `max` events. Returns [] on a quiet interval tick.
36
+ def wait_batch(interval, max: 1000)
37
+ @mutex.synchronize do
38
+ @signal.wait(@mutex, interval) if @items.size < @flush_at && !@closed
39
+ @items.shift(max)
40
+ end
41
+ end
42
+
43
+ # Everything queued at the moment of the call (for flush/shutdown).
44
+ def drain
45
+ @mutex.synchronize { @items.slice!(0, @items.size) }
46
+ end
47
+
48
+ def size
49
+ @mutex.synchronize { @items.size }
50
+ end
51
+
52
+ def empty?
53
+ size.zero?
54
+ end
55
+
56
+ def close
57
+ @mutex.synchronize do
58
+ @closed = true
59
+ @signal.broadcast
60
+ end
61
+ end
62
+
63
+ def closed?
64
+ @mutex.synchronize { @closed }
65
+ end
66
+
67
+ # Fork recovery (contract 9): the child discards the inherited queue —
68
+ # those events belong to the parent; sending them twice would duplicate.
69
+ def reset!
70
+ @mutex.synchronize do
71
+ discarded = @items.size
72
+ @items.clear
73
+ @closed = false
74
+ discarded
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kilden
4
+ # TTL + LRU cache of /decide responses, keyed by distinct_id (spec §8.2:
5
+ # TTL 30s, at most 1000 ids). Ruby's insertion-ordered Hash doubles as the
6
+ # LRU list: delete + reinsert on hit, shift the oldest on overflow.
7
+ # @api private
8
+ class FlagCache
9
+ TTL = 30
10
+ MAX_IDS = 1000
11
+
12
+ def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
13
+ @clock = clock
14
+ @entries = {}
15
+ @mutex = Mutex.new
16
+ end
17
+
18
+ def get(distinct_id)
19
+ @mutex.synchronize do
20
+ entry = @entries.delete(distinct_id)
21
+ return nil unless entry
22
+ return nil if @clock.call >= entry[0]
23
+
24
+ @entries[distinct_id] = entry
25
+ entry[1]
26
+ end
27
+ end
28
+
29
+ def set(distinct_id, flags)
30
+ @mutex.synchronize do
31
+ @entries.delete(distinct_id)
32
+ @entries[distinct_id] = [@clock.call + TTL, flags]
33
+ @entries.shift if @entries.size > MAX_IDS
34
+ end
35
+ end
36
+
37
+ def clear
38
+ @mutex.synchronize { @entries.clear }
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Kilden
6
+ # The frozen rollout hashing (spec §8.3). v1 never evaluates flags locally
7
+ # — /decide does — but the algorithm is pinned now, tested against the
8
+ # platform-generated vectors, so local evaluation can arrive later without
9
+ # an API change or a bucketing flicker.
10
+ # @api private
11
+ module Hashing
12
+ TWO_POW_64 = 2.0**64
13
+
14
+ module_function
15
+
16
+ def bucket(flag_key, distinct_id)
17
+ fraction("#{flag_key}:#{distinct_id}") * 100
18
+ end
19
+
20
+ def variant_for(flag_key, distinct_id, variants)
21
+ point = fraction("#{flag_key}:#{distinct_id}:variant") * 100
22
+ cumulative = 0.0
23
+ variants.each do |variant|
24
+ cumulative += variant.fetch("rollout_percentage")
25
+ return variant.fetch("key") if point < cumulative
26
+ end
27
+ true
28
+ end
29
+
30
+ def fraction(input)
31
+ Digest::SHA256.digest(input)[0, 8].unpack1("Q>") / TWO_POW_64
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Kilden
6
+ # Signs the short-lived identity tokens that make browser events
7
+ # verifiable (Kilden's trust model). Deliberately separate from Client: a
8
+ # controller rendering a page wants a token, not an event queue.
9
+ #
10
+ # signer = Kilden::IdentitySigner.new(ENV["KILDEN_IDENTITY_SECRET"], kid: "k1")
11
+ # token = signer.sign(current_user.id.to_s, traits: { plan: "pro" })
12
+ #
13
+ # Only sign a +sub+ your backend authenticated. Signing user input
14
+ # (params[:user_id]) lets anyone impersonate anyone — with a "verified"
15
+ # stamp on top.
16
+ #
17
+ # HS256 is implemented by hand because the spec freezes the byte form of
18
+ # the token (kilden-sdk-spec §6.1); a JWT library's serialization choices
19
+ # would silently diverge.
20
+ class IdentitySigner
21
+ MAX_TTL = 604_800 # 7 days; identity tokens are short-lived by design
22
+
23
+ def initialize(identity_secret, kid:)
24
+ if !identity_secret.is_a?(String) || identity_secret.empty?
25
+ raise ConfigurationError,
26
+ "identity secret is required"
27
+ end
28
+ if !kid.is_a?(String) || kid.empty?
29
+ raise ConfigurationError,
30
+ "kid is required (the platform looks the secret up by kid)"
31
+ end
32
+
33
+ @secret = identity_secret
34
+ @kid = kid
35
+ end
36
+
37
+ # Returns the signed JWT for +sub+ (the distinct_id the token vouches
38
+ # for). ttl defaults to one hour and is capped at 7 days.
39
+ def sign(sub, ttl: 3600, traits: nil)
40
+ raise ArgumentError, "sub must be a non-empty string" if !sub.is_a?(String) || sub.empty?
41
+ raise ArgumentError, "ttl must be in (0, #{MAX_TTL}] seconds" if !ttl.is_a?(Integer) || ttl <= 0 || ttl > MAX_TTL
42
+
43
+ iat = Time.now.to_i
44
+ build(sub, iat: iat, exp: iat + ttl, traits: traits)
45
+ end
46
+
47
+ private
48
+
49
+ def build(sub, iat:, exp:, traits:)
50
+ header = CanonicalJSON.generate({ "alg" => "HS256", "kid" => @kid, "typ" => "JWT" })
51
+ claims = { "exp" => exp, "iat" => iat, "sub" => sub }
52
+ claims["traits"] = traits if traits && !traits.empty?
53
+ payload = CanonicalJSON.generate(claims)
54
+
55
+ signing_input = "#{b64url(header)}.#{b64url(payload)}"
56
+ signature = OpenSSL::HMAC.digest("SHA256", @secret, signing_input)
57
+ "#{signing_input}.#{b64url(signature)}"
58
+ end
59
+
60
+ # base64url without padding, via Array#pack — the base64 stdlib moved
61
+ # out of the default gems in Ruby 3.4 and this SDK ships zero deps.
62
+ def b64url(bytes)
63
+ [bytes].pack("m0").tr("+/", "-_").delete("=")
64
+ end
65
+ end
66
+ end
data/lib/kilden/log.rb ADDED
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kilden
4
+ # Minimal leveled logger writing to $stderr. The stdlib logger is leaving
5
+ # the default gems (Ruby 4), and this SDK ships zero dependencies — so the
6
+ # default is this. Anything responding to debug/info/warn/error can
7
+ # replace it through the client's logger: option.
8
+ # @api private
9
+ class Log
10
+ LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }.freeze
11
+
12
+ def initialize(level)
13
+ @threshold = LEVELS.fetch(level)
14
+ end
15
+
16
+ LEVELS.each do |name, severity|
17
+ define_method(name) do |message|
18
+ warn("kilden [#{name}] #{message}") if severity >= @threshold
19
+ nil
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "zlib"
5
+
6
+ module Kilden
7
+ # Owns one batch from build to success or exhaustion (spec §4.3). Failed
8
+ # batches never go back into the main queue — that would shuffle ordering
9
+ # and could evict fresh events.
10
+ # @api private
11
+ class Sender
12
+ MAX_RETRIES = 3
13
+ GZIP_THRESHOLD = 1024
14
+
15
+ attr_reader :dropped_count
16
+
17
+ def initialize(write_key:, host:, transport:, logger:, sleeper: nil, rng: Random.new)
18
+ @write_key = write_key
19
+ @capture_url = "#{host.chomp('/')}/capture"
20
+ @transport = transport
21
+ @logger = logger
22
+ @sleeper = sleeper || ->(seconds) { sleep(seconds) }
23
+ @rng = rng
24
+ @dropped = 0
25
+ @mutex = Mutex.new
26
+ end
27
+
28
+ def dropped!(count)
29
+ @mutex.synchronize { @dropped += count }
30
+ end
31
+
32
+ def dropped_total
33
+ @mutex.synchronize { @dropped }
34
+ end
35
+
36
+ # Sends up to MAX_RETRIES + 1 attempts. deadline (monotonic seconds) cuts
37
+ # the loop short during shutdown: telemetry never hangs a process.
38
+ def send_batch(events, deadline: nil)
39
+ return :ok if events.empty?
40
+
41
+ attempt = 0
42
+ loop do
43
+ response = deliver(events)
44
+ return :ok if success?(response)
45
+
46
+ unless retryable?(response)
47
+ drop(events,
48
+ "kilden: dropped #{events.size} events (HTTP #{response.status}: #{response.body.to_s.strip[0, 120]})")
49
+ return :dropped
50
+ end
51
+
52
+ attempt += 1
53
+ if attempt > MAX_RETRIES || past?(deadline)
54
+ drop(events, "kilden: dropped #{events.size} events after #{attempt} attempts")
55
+ return :dropped
56
+ end
57
+
58
+ delay = backoff(attempt, response)
59
+ if deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) + delay > deadline
60
+ drop(events, "kilden: dropped #{events.size} events (shutdown deadline)")
61
+ return :dropped
62
+ end
63
+ @sleeper.call(delay)
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ def deliver(events)
70
+ # sent_at is stamped when the request is built (clock-skew correction
71
+ # happens server-side against this value).
72
+ payload = {
73
+ "write_key" => @write_key,
74
+ "sent_at" => Client.format_time(Time.now),
75
+ "batch" => events
76
+ }
77
+ body = JSON.generate(payload)
78
+ headers = {
79
+ "Content-Type" => "application/json",
80
+ "User-Agent" => "kilden-ruby/#{VERSION}"
81
+ }
82
+ if body.bytesize > GZIP_THRESHOLD
83
+ body = Zlib.gzip(body)
84
+ headers["Content-Encoding"] = "gzip"
85
+ end
86
+ @transport.post(@capture_url, body, headers)
87
+ end
88
+
89
+ def success?(response)
90
+ # Any 2xx is success — the response body is never parsed; the status
91
+ # is the whole signal (§4.3).
92
+ (200..299).cover?(response.status)
93
+ end
94
+
95
+ def retryable?(response)
96
+ response.network_error? || response.status == 429 || response.status >= 500
97
+ end
98
+
99
+ def backoff(retry_number, response)
100
+ if response.status == 429 && (after = response.headers["retry-after"]) && after.to_i.positive?
101
+ return after.to_i
102
+ end
103
+
104
+ base = [0.5 * (2**(retry_number - 1)), 30].min
105
+ base * @rng.rand(0.5..1.5)
106
+ end
107
+
108
+ def past?(deadline)
109
+ deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
110
+ end
111
+
112
+ def drop(events, message)
113
+ dropped!(events.size)
114
+ @logger.warn(message)
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module Kilden
7
+ # Transport seam: anything responding to
8
+ # +post(url, body, headers) -> Kilden::Transport::Response+ can replace the
9
+ # default. A transport never raises — network failures come back as
10
+ # status 0 so the retry loop can treat them uniformly.
11
+ # @api private
12
+ module Transport
13
+ Response = Struct.new(:status, :headers, :body, :error, keyword_init: true) do
14
+ def network_error?
15
+ status.zero?
16
+ end
17
+ end
18
+
19
+ # Default transport on Net::HTTP. One connection per request: the SDK
20
+ # flushes at most every few seconds, so pooling buys nothing and costs
21
+ # state that would go stale across forks.
22
+ class NetHttp
23
+ def initialize(timeout:)
24
+ @timeout = timeout
25
+ end
26
+
27
+ def post(url, body, headers)
28
+ uri = URI.parse(url)
29
+ http = Net::HTTP.new(uri.host, uri.port)
30
+ http.use_ssl = uri.scheme == "https"
31
+ http.open_timeout = @timeout
32
+ http.read_timeout = @timeout
33
+ http.write_timeout = @timeout if http.respond_to?(:write_timeout=)
34
+
35
+ response = http.post(uri.path.empty? ? "/" : uri.path, body, headers)
36
+ normalized = {}
37
+ response.each_header { |k, v| normalized[k.downcase] = v }
38
+ payload = response.body.to_s
39
+ # A body shorter than Content-Length is a connection cut mid-response
40
+ # (Net::HTTP returns the partial read silently). Malformed HTTP is a
41
+ # network error per SPEC §4.3, so the batch retries.
42
+ declared = normalized["content-length"]&.to_i
43
+ if declared && payload.bytesize < declared
44
+ return Response.new(status: 0, headers: normalized, body: payload,
45
+ error: EOFError.new("response truncated at #{payload.bytesize}/#{declared} bytes"))
46
+ end
47
+ Response.new(status: response.code.to_i, headers: normalized, body: payload)
48
+ rescue StandardError => e
49
+ Response.new(status: 0, headers: {}, body: "", error: e)
50
+ ensure
51
+ begin
52
+ http&.finish if http&.started?
53
+ rescue StandardError
54
+ nil
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Kilden
6
+ # UUID v7 (RFC 9562): 48-bit unix milliseconds, then random bits with the
7
+ # version/variant nibbles pinned. Generated client-side per event so
8
+ # retries stay idempotent — the platform deduplicates on this value.
9
+ # @api private
10
+ module UUID
11
+ CANONICAL = /\A[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\z/
12
+ V7 = /\A[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/
13
+
14
+ module_function
15
+
16
+ def v7(now_ms = (Process.clock_gettime(Process::CLOCK_REALTIME) * 1000).to_i)
17
+ bytes = [now_ms >> 16, now_ms & 0xFFFF].pack("NS>") + SecureRandom.bytes(10)
18
+ bytes.setbyte(6, (bytes.getbyte(6) & 0x0F) | 0x70)
19
+ bytes.setbyte(8, (bytes.getbyte(8) & 0x3F) | 0x80)
20
+ hex = bytes.unpack1("H*")
21
+ "#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}"
22
+ end
23
+
24
+ def canonical?(value)
25
+ value.is_a?(String) && CANONICAL.match?(value)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kilden
4
+ VERSION = "0.1.0.alpha.3"
5
+ end
data/lib/kilden.rb ADDED
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Kilden server-side SDK. Public surface: Kilden::Client and
4
+ # Kilden::IdentitySigner — everything else is internal.
5
+ #
6
+ # The behavior of this SDK is specified, together with the other four server
7
+ # SDKs, in https://github.com/kildenhq/kilden-sdk-spec — changes that
8
+ # alter observable behavior land there first.
9
+ module Kilden
10
+ # Raised only at construction time (spec contract 2): bad write key,
11
+ # bad signer configuration. Nothing raises after construction.
12
+ class ConfigurationError < ArgumentError
13
+ end
14
+ end
15
+
16
+ require "kilden/version"
17
+ require "kilden/log"
18
+ require "kilden/uuid"
19
+ require "kilden/canonical_json"
20
+ require "kilden/identity_signer"
21
+ require "kilden/transport"
22
+ require "kilden/sender"
23
+ require "kilden/event_queue"
24
+ require "kilden/hashing"
25
+ require "kilden/flag_cache"
26
+ require "kilden/client"
metadata ADDED
@@ -0,0 +1,62 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: kilden
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0.alpha.3
5
+ platform: ruby
6
+ authors:
7
+ - Freshwork
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Server-side events, identity signing and feature flags for Kilden. Zero
13
+ runtime dependencies, fork-safe under preforking servers.
14
+ email:
15
+ - hello@kilden.io
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - CHANGELOG.md
21
+ - LICENSE
22
+ - README.md
23
+ - lib/kilden.rb
24
+ - lib/kilden/canonical_json.rb
25
+ - lib/kilden/client.rb
26
+ - lib/kilden/event_queue.rb
27
+ - lib/kilden/flag_cache.rb
28
+ - lib/kilden/hashing.rb
29
+ - lib/kilden/identity_signer.rb
30
+ - lib/kilden/log.rb
31
+ - lib/kilden/sender.rb
32
+ - lib/kilden/transport.rb
33
+ - lib/kilden/uuid.rb
34
+ - lib/kilden/version.rb
35
+ homepage: https://github.com/kildenhq/kilden-sdk-ruby
36
+ licenses:
37
+ - MIT
38
+ metadata:
39
+ homepage_uri: https://github.com/kildenhq/kilden-sdk-ruby
40
+ source_code_uri: https://github.com/kildenhq/kilden-sdk-ruby
41
+ changelog_uri: https://github.com/kildenhq/kilden-sdk-ruby/blob/main/CHANGELOG.md
42
+ documentation_uri: https://docs.kilden.io
43
+ bug_tracker_uri: https://github.com/kildenhq/kilden-sdk-ruby/issues
44
+ rubygems_mfa_required: 'true'
45
+ rdoc_options: []
46
+ require_paths:
47
+ - lib
48
+ required_ruby_version: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '3.0'
53
+ required_rubygems_version: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ version: '0'
58
+ requirements: []
59
+ rubygems_version: 3.6.9
60
+ specification_version: 4
61
+ summary: Kilden server-side SDK for Ruby
62
+ test_files: []