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 +7 -0
- data/CHANGELOG.md +47 -0
- data/LICENSE +21 -0
- data/README.md +151 -0
- data/lib/kilden/canonical_json.rb +42 -0
- data/lib/kilden/client.rb +374 -0
- data/lib/kilden/event_queue.rb +78 -0
- data/lib/kilden/flag_cache.rb +41 -0
- data/lib/kilden/hashing.rb +34 -0
- data/lib/kilden/identity_signer.rb +66 -0
- data/lib/kilden/log.rb +23 -0
- data/lib/kilden/sender.rb +117 -0
- data/lib/kilden/transport.rb +59 -0
- data/lib/kilden/uuid.rb +28 -0
- data/lib/kilden/version.rb +5 -0
- data/lib/kilden.rb +26 -0
- metadata +62 -0
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
|
+
[](https://rubygems.org/gems/kilden)
|
|
8
|
+
[](https://github.com/kildenhq/kilden-sdk-ruby/actions/workflows/ci.yml)
|
|
9
|
+
[](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
|
data/lib/kilden/uuid.rb
ADDED
|
@@ -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
|
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: []
|