flagnetic 0.1.0

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.
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+
5
+ module Flagnetic
6
+ # Stream is one request of the change stream, read on a thread of its own
7
+ # so that the worker can keep its timers while the stream is quiet. What
8
+ # happens is posted to the worker's inbox as [:stream, id, kind, ...]:
9
+ #
10
+ # - :head, status: the response head arrived.
11
+ # - :frame, kind, value: a parsed frame of a 200 response.
12
+ # - :ended, cause, error: the request is over; error is false for the
13
+ # server closing the stream, true for anything else, a connection that
14
+ # fails, a malformed stream, or silence for longer than the read
15
+ # timeout the stream is held with.
16
+ #
17
+ # The read timeout is the silence limit: Net::HTTP raises when no byte,
18
+ # a heartbeat included, arrives within it.
19
+ class Stream
20
+ def initialize(id, transport, held_version, silence:, inbox:)
21
+ @id = id
22
+ @transport = transport
23
+ @request = transport.stream_request(held_version)
24
+ @silence = silence
25
+ @inbox = inbox
26
+ @thread = Thread.new { run }
27
+ @thread.name = "flagnetic-stream"
28
+ @thread.report_on_exception = false
29
+ end
30
+
31
+ attr_reader :id
32
+
33
+ # close ends the request. Killing the thread runs the ensure of
34
+ # Net::HTTP#start, which closes the socket.
35
+ def close
36
+ @thread.kill
37
+ @thread.join(1)
38
+ end
39
+
40
+ private
41
+
42
+ def run
43
+ http = @transport.http_for(@transport.stream_uri, @transport.timeout)
44
+ http.start { |h| h.request(@request) { |response| read(h, response) } }
45
+ post(:ended, "closed by the server", false)
46
+ rescue Net::ReadTimeout
47
+ post(:ended, "no frame for #{@silence} s", true)
48
+ rescue StandardError => e
49
+ post(:ended, "#{e.class}: #{e.message}", true)
50
+ end
51
+
52
+ def read(http, response)
53
+ status = response.code.to_i
54
+ post(:head, status)
55
+ return unless status == 200
56
+
57
+ http.read_timeout = @silence
58
+ parser = SSEParser.new
59
+ response.read_body { |chunk| parser.feed(chunk) { |kind, value| post(:frame, kind, value) } }
60
+ end
61
+
62
+ def post(*message) = @inbox << [:stream, @id, *message]
63
+ end
64
+ private_constant :Stream
65
+ end
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "event_batch"
5
+ require_relative "reporter"
6
+ require_relative "uuid"
7
+
8
+ module Flagnetic
9
+ # Summaries counts every evaluation and reports each interval's window as
10
+ # one or more evaluation summary events: counts per (flag, variation,
11
+ # reason), the ruleset version at the interval's end, no unit IDs. A
12
+ # window whose report failed merges into the next one rather than being
13
+ # retried under its own event ID; one older than an hour is dropped
14
+ # instead. Offline: the window rotates only on flush and close, and goes
15
+ # to the Recorder instead of the events endpoint.
16
+ class Summaries
17
+ include Reporter
18
+
19
+ MAX_SUMMARY_ROWS = 1000
20
+ # MAX_WINDOW_AGE is how old a window whose report failed may be before
21
+ # it is dropped rather than merged forward: an hour, so a
22
+ # long outage does not eventually deliver counts nobody can place.
23
+ MAX_WINDOW_AGE = 3600
24
+
25
+ # LogicalEvent is one evaluation summary before it is wire-encoded:
26
+ # counts as [flag, variation, reason, count] tuples, reason a symbol.
27
+ LogicalEvent = Struct.new(:event_id, :interval_start, :interval_end, :ruleset_version, :counts, keyword_init: true)
28
+
29
+ # app_version, when given, goes with every summary.
30
+ def initialize(timing:, log:, ruleset_version:, transport: nil, record: nil, app_version: nil)
31
+ @transport = transport
32
+ @app_version = app_version
33
+ @timing = timing
34
+ @log = log
35
+ @record = record
36
+ @ruleset_version = ruleset_version
37
+ @mutex = Mutex.new
38
+ @wall_start = Time.now
39
+ @window_due_since = now
40
+ @current = Hash.new(0)
41
+ @kept = nil
42
+ @backoff = Backoff.new(base: timing.fetch_base, cap: timing.fetch_cap)
43
+ @not_before = 0
44
+ @revoked_logged = false
45
+ @send_mutex = Mutex.new
46
+ end
47
+
48
+ # offline? reports whether the window goes to the Recorder rather than
49
+ # to the events endpoint.
50
+ def offline? = !@record.nil?
51
+
52
+ # count records one served value: the flag, the variation and the
53
+ # evaluation reason, never the value and never the context.
54
+ def count(flag, detail)
55
+ key = [flag, detail.variation, detail.reason]
56
+ @mutex.synchronize { @current[key] += 1 }
57
+ end
58
+
59
+ # due? reports whether the reporting interval has elapsed and the
60
+ # backoff since the last failure, if any, has too.
61
+ def due?(mono = now)
62
+ return false if offline?
63
+
64
+ mono >= @not_before && mono - @window_due_since >= @timing.summary_flush
65
+ end
66
+
67
+ # next_deadline is when due? might next turn true, for a worker's wait
68
+ # to schedule around; nil offline, where nothing drives it.
69
+ def next_deadline
70
+ return nil if offline?
71
+
72
+ [@not_before, @window_due_since + @timing.summary_flush].max
73
+ end
74
+
75
+ # flush_once closes the current window and delivers it (or, offline,
76
+ # hands it to the Recorder), retrying with backoff within budget
77
+ # seconds. Safe to call from any thread; concurrent callers wait for
78
+ # each other only up to budget, so one never blocks behind another
79
+ # past its own.
80
+ def flush_once(budget)
81
+ deadline = now + budget
82
+ return true unless wait_for_send_lock(deadline)
83
+
84
+ begin
85
+ w = take_window
86
+ w ? send_window(w, deadline) : true
87
+ ensure
88
+ @send_mutex.unlock
89
+ end
90
+ end
91
+
92
+ # reset_after_fork empties the window in a forked child, which starts
93
+ # empty; the parent, which never calls this, keeps its own.
94
+ def reset_after_fork
95
+ @mutex.synchronize do
96
+ @current = Hash.new(0)
97
+ @kept = nil
98
+ @wall_start = Time.now
99
+ @window_due_since = now
100
+ end
101
+ @backoff.reset
102
+ @not_before = 0
103
+ end
104
+
105
+ private
106
+
107
+ def kind = "evaluation_summary"
108
+
109
+ # take_window closes the current window, merging in one a failed
110
+ # report kept unless it began over an hour ago, and returns nil when
111
+ # nothing was evaluated.
112
+ def take_window
113
+ wall_end = Time.now
114
+ win, kept = @mutex.synchronize do
115
+ window = { start: @wall_start, end: wall_end, counts: @current }
116
+ held = @kept
117
+ @wall_start = wall_end
118
+ @window_due_since = now
119
+ @current = Hash.new(0)
120
+ @kept = nil
121
+ [window, held]
122
+ end
123
+ merge_kept(win, kept, wall_end) if kept
124
+ win[:counts].empty? ? nil : win
125
+ end
126
+
127
+ def merge_kept(win, kept, wall_end)
128
+ age = wall_end - kept[:start]
129
+ if age > MAX_WINDOW_AGE
130
+ log(:warn, :queue_dropped, kind: kind, rows: kept[:counts].size, cause: "window_older_than_one_hour",
131
+ age: age.round(3))
132
+ return
133
+ end
134
+ win[:start] = kept[:start] if kept[:start] < win[:start]
135
+ kept[:counts].each { |k, n| win[:counts][k] = (win[:counts][k] || 0) + n }
136
+ end
137
+
138
+ # keep puts a window back, to merge into the next one taken.
139
+ def keep(window)
140
+ @mutex.synchronize do
141
+ if @kept.nil?
142
+ @kept = window
143
+ next
144
+ end
145
+ @kept[:start] = window[:start] if window[:start] < @kept[:start]
146
+ window[:counts].each { |k, n| @kept[:counts][k] = (@kept[:counts][k] || 0) + n }
147
+ end
148
+ end
149
+
150
+ def send_window(win, deadline)
151
+ events = build_events(win)
152
+ if offline?
153
+ events.each { |e| @record.call(public_summary(e)) }
154
+ return true
155
+ end
156
+
157
+ wire_events = events.map { |e| wire(e) }
158
+ batches = EventBatch.pack(wire_events, @transport.identity)
159
+ batches.each_with_index do |group, i|
160
+ result = attempt(group, deadline)
161
+ case result.kind
162
+ when :accepted
163
+ @backoff.reset
164
+ @not_before = 0
165
+ @revoked_logged = false
166
+ report_accepted(group, result)
167
+ when :dropped
168
+ report_dropped(group, result)
169
+ else
170
+ keep(window_of(batches[i..].flatten(1)))
171
+ return false
172
+ end
173
+ end
174
+ true
175
+ end
176
+
177
+ # build_events turns a window into logical summary events: the rows
178
+ # ordered, at most 1000 to an event, each with its own event ID and all
179
+ # naming the window's interval and the ruleset version held at its end.
180
+ def build_events(win)
181
+ rows = win[:counts].map { |(flag, variation, reason), n| { flag:, variation:, reason:, count: n } }
182
+ rows.sort_by! { |r| [r[:flag], r[:variation].to_s, r[:reason].to_s] }
183
+ # Before any ruleset was held the version is 0, which no ruleset has.
184
+ version = @ruleset_version.call || 0
185
+ events = []
186
+ events << LogicalEvent.new(event_id: UUID.v7, interval_start: win[:start], interval_end: win[:end],
187
+ ruleset_version: version, counts: rows.shift(MAX_SUMMARY_ROWS)) until rows.empty?
188
+ events
189
+ end
190
+
191
+ def wire(event)
192
+ doc = { "type" => "evaluation_summary", "event_id" => event.event_id,
193
+ "interval_start" => EventBatch.format_time(event.interval_start),
194
+ "interval_end" => EventBatch.format_time(event.interval_end),
195
+ "ruleset_version" => event.ruleset_version,
196
+ "counts" => event.counts.map do |r|
197
+ { "flag" => r[:flag], "variation" => r[:variation], "reason" => r[:reason].to_s, "count" => r[:count] }
198
+ end }
199
+ doc["app_version"] = @app_version if @app_version
200
+ doc
201
+ end
202
+
203
+ def public_summary(event)
204
+ Recorder::EvaluationSummary.new(
205
+ event_id: event.event_id, interval_start: event.interval_start, interval_end: event.interval_end,
206
+ ruleset_version: event.ruleset_version, app_version: @app_version,
207
+ counts: event.counts.map do |r|
208
+ Recorder::SummaryCount.new(flag: r[:flag], variation: r[:variation],
209
+ reason: r[:reason], count: r[:count])
210
+ end
211
+ )
212
+ end
213
+
214
+ # window_of rebuilds a window from the summary events that were not
215
+ # delivered, so their rows merge into the next one.
216
+ def window_of(wire_events)
217
+ counts = Hash.new(0)
218
+ start_t = nil
219
+ end_t = nil
220
+ wire_events.each do |e|
221
+ s = Time.parse(e["interval_start"])
222
+ en = Time.parse(e["interval_end"])
223
+ start_t = s if start_t.nil? || s < start_t
224
+ end_t = en if end_t.nil? || en > end_t
225
+ e["counts"].each { |r| counts[[r["flag"], r["variation"], r["reason"].to_sym]] += r["count"] }
226
+ end
227
+ { start: start_t || Time.now, end: end_t || Time.now, counts: counts }
228
+ end
229
+
230
+ # schedule_retry honours Retry-After, capped at the ordinary backoff
231
+ # ceiling: a header naming minutes must not silence reporting for
232
+ # minutes (or, from a misbehaving proxy, far longer), so it bounds
233
+ # rather than replaces the backoff (the protocol allows either
234
+ # choice; Exposures ignores the header entirely instead).
235
+ def schedule_retry(group, result)
236
+ wait = if result.retry_after
237
+ [result.retry_after,
238
+ @timing.fetch_cap].min
239
+ else
240
+ @backoff.next_delay(cap: @timing.fetch_cap)
241
+ end
242
+ @not_before = now + wait
243
+ log_ingest_failed(group, result, wait)
244
+ wait
245
+ end
246
+
247
+ def report_accepted(group, result)
248
+ return if result.dropped_rows.to_i.zero? && result.rejected.to_a.empty?
249
+
250
+ log(:warn, :queue_dropped, kind: kind, cause: "refused_by_the_platform",
251
+ events: group.map { |e| e["event_id"] }, accepted: result.accepted,
252
+ duplicates: result.duplicates, dropped_rows: result.dropped_rows,
253
+ rejected: result.rejected.map { |r| r["code"] })
254
+ end
255
+ end
256
+ private_constant :Summaries
257
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../flagnetic"
5
+
6
+ module Flagnetic
7
+ # Testing builds offline clients for deterministic tests: from a ruleset
8
+ # document, a file, or a Hash of flag key to value. No client it builds
9
+ # ever touches the network; the recorder holds what it would report.
10
+ #
11
+ # require "flagnetic/testing"
12
+ #
13
+ # client = Flagnetic::Testing.offline(values: { "new_checkout" => true })
14
+ # client.set("new_checkout", false) # now served to everyone as fallthrough
15
+ # client.recorder.summary # evaluation counts
16
+ #
17
+ # Helpers gives RSpec examples and Minitest tests with_flags and
18
+ # with_ruleset, which swap such a client in as Flagnetic.client for a
19
+ # block.
20
+ module Testing
21
+ # VARIATION names the one variation set and values: give a flag.
22
+ VARIATION = "value"
23
+ private_constant :VARIATION
24
+
25
+ # offline builds an OfflineClient from a ruleset document (JSON text or
26
+ # a Hash, exactly as the ruleset endpoint serves it), from file:, or
27
+ # from values:, a Hash of flag key to value where the value's class
28
+ # decides the flag type. Other options go to Client.new. It raises
29
+ # OfflineError when the document does not load.
30
+ def self.offline(document = nil, file: nil, values: nil, **)
31
+ given = [document, file, values].compact.size
32
+ raise ArgumentError, "pass one of a document, file: or values:" unless given == 1
33
+
34
+ source =
35
+ if file then -> { File.binread(file) }
36
+ elsif values then document_from(values).then { |doc| -> { doc } }
37
+ else
38
+ (document.is_a?(Hash) ? JSON.generate(document) : document.to_s).then { |doc| -> { doc } }
39
+ end
40
+ OfflineClient.new(source, **)
41
+ end
42
+
43
+ # document_from builds a ruleset document serving each value as the
44
+ # fallthrough of a flag with one variation.
45
+ def self.document_from(values)
46
+ doc = { "schema_version" => 1, "version" => 1, "scope" => "server", "flags" => [] }
47
+ values.each { |key, value| set_flag(doc, key.to_s, value) }
48
+ JSON.generate(doc)
49
+ end
50
+
51
+ # set_flag replaces or adds the flag key in a parsed ruleset document.
52
+ # An existing flag keeps its type and value must be of it.
53
+ def self.set_flag(doc, key, value)
54
+ flags = (doc["flags"] ||= [])
55
+ at = flags.index { |f| f.is_a?(Hash) && f["key"] == key }
56
+ type = Client.__send__(:type_of, value)
57
+ if at && (existing = flags[at]["type"]) != "json" && existing != type
58
+ raise ArgumentError, "flag #{key.inspect} is #{existing}, a #{value.class} is #{type}"
59
+ end
60
+
61
+ type = flags[at]["type"] if at
62
+ flag = { "key" => key, "type" => type, "on" => true, "variations" => [{ "key" => VARIATION, "value" => value }],
63
+ "off_variation" => VARIATION, "fallthrough" => VARIATION }
64
+ at ? flags[at] = flag : flags << flag
65
+ end
66
+
67
+ # with_flags makes an offline client serving the given flag values
68
+ # Flagnetic.client for the block, as offline(values:) builds it, and
69
+ # restores the previous one afterwards, even when the block raises; a
70
+ # client the Rails integration had not built yet stays unbuilt. Keys
71
+ # may be Strings or Symbols. It yields the client, whose recorder holds
72
+ # what the code under test reported. The swap is process-wide: tests
73
+ # running in parallel threads must not use it.
74
+ #
75
+ # Flagnetic::Testing.with_flags("new_checkout" => true) do |client|
76
+ # get "/checkout"
77
+ # assert client.recorder.summary.any? { |row| row.flag == "new_checkout" }
78
+ # end
79
+ def self.with_flags(values = {}, **more, &)
80
+ swap(offline(values: values.merge(more)), &)
81
+ end
82
+
83
+ # with_ruleset is with_flags for a ruleset document (JSON text or a
84
+ # Hash, exactly as the ruleset endpoint serves it) or file:, for a test
85
+ # needing rules, rollouts or an experiment: the recorder then holds the
86
+ # exposures the code under test reported.
87
+ def self.with_ruleset(document = nil, file: nil, &)
88
+ raise ArgumentError, "pass a document or file:" if document.nil? == file.nil?
89
+
90
+ swap(offline(document, file: file), &)
91
+ end
92
+
93
+ def self.swap(client)
94
+ raise ArgumentError, "a block is required" unless block_given?
95
+
96
+ previous = Flagnetic.__send__(:replace_client, client)
97
+ begin
98
+ yield client
99
+ ensure
100
+ Flagnetic.__send__(:replace_client, previous.first)
101
+ client.close(timeout: 0)
102
+ end
103
+ end
104
+ private_class_method :document_from, :set_flag, :swap
105
+
106
+ # Helpers is with_flags and with_ruleset for test classes:
107
+ #
108
+ # RSpec.configure { |config| config.include Flagnetic::Testing::Helpers }
109
+ #
110
+ # class ActiveSupport::TestCase
111
+ # include Flagnetic::Testing::Helpers
112
+ # end
113
+ module Helpers
114
+ # with_flags is Flagnetic::Testing.with_flags.
115
+ def with_flags(...) = Testing.with_flags(...)
116
+
117
+ # with_ruleset is Flagnetic::Testing.with_ruleset.
118
+ def with_ruleset(...) = Testing.with_ruleset(...)
119
+ end
120
+
121
+ # OfflineClient is an offline Client whose document a test can rewrite.
122
+ class OfflineClient < Client
123
+ def initialize(read, **)
124
+ @read = read
125
+ @document_mutex = Mutex.new
126
+ @document = read.call
127
+ begin
128
+ Eval::Ruleset.decode(@document)
129
+ rescue Eval::DecodeError, TypeError => e
130
+ raise OfflineError, "offline ruleset: #{e.message}"
131
+ end
132
+ super(offline: -> { @document_mutex.synchronize { @document } }, **)
133
+ end
134
+
135
+ alias reload_document reload
136
+ private :reload_document
137
+
138
+ # set rewrites the flag key to serve value to everyone: one variation,
139
+ # served as fallthrough, so the reason reads fallthrough. A key the
140
+ # document lacks is added, typed by the value; an existing flag keeps
141
+ # its type. on_change fires.
142
+ def set(key, value)
143
+ @document_mutex.synchronize do
144
+ doc = JSON.parse(@document)
145
+ Testing.__send__(:set_flag, doc, key.to_s, value)
146
+ @document = JSON.generate(doc)
147
+ end
148
+ reload_document
149
+ end
150
+
151
+ # reload re-reads the source the client was built from, discarding
152
+ # what set changed. on_change fires with the flags that changed.
153
+ def reload
154
+ document = @read.call
155
+ @document_mutex.synchronize { @document = document }
156
+ reload_document
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module Flagnetic
7
+ # Transport speaks the SDK API under SDK_API_PATH for one server key. Every
8
+ # request, the change stream included, goes through Net::HTTP, so the
9
+ # proxy the process is configured with (the http_proxy, https_proxy and
10
+ # no_proxy environment variables) applies to all of them.
11
+ class Transport
12
+ # Response is the outcome of a ruleset fetch; body is decompressed.
13
+ Response = Struct.new(:status, :body, keyword_init: true)
14
+
15
+ # EventsResponse is the outcome of an events POST; EventBatch classifies
16
+ # it. body is decompressed; retry_after is the raw header value.
17
+ EventsResponse = Struct.new(:status, :body, :retry_after, keyword_init: true)
18
+
19
+ attr_reader :stream_uri, :timeout
20
+
21
+ def initialize(sdk_key:, base_url:, platform:, timeout:)
22
+ base = URI(base_url.to_s.chomp("/"))
23
+ raise ArgumentError, "base_url must be an http or https URL, got #{base_url.inspect}" unless
24
+ base.is_a?(URI::HTTP) && base.host
25
+
26
+ @ruleset_uri = URI("#{base}#{SDK_API_PATH}/ruleset")
27
+ @stream_uri = URI("#{base}#{SDK_API_PATH}/stream")
28
+ @events_uri = URI("#{base}#{SDK_API_PATH}/events")
29
+ @platform = platform
30
+ @sdk_key = sdk_key.to_s
31
+ @user_agent = "flagnetic-ruby/#{VERSION} (#{platform})"
32
+ @timeout = timeout
33
+ end
34
+
35
+ # identity is this SDK's entry in an events envelope's sdk member.
36
+ def identity
37
+ { "name" => "flagnetic-ruby", "version" => VERSION, "platform" => @platform }
38
+ end
39
+
40
+ # post_events POSTs a gzipped events batch and returns the raw
41
+ # response; EventBatch classifies it. It raises on network errors and
42
+ # timeouts, as fetch_ruleset does. timeout bounds this one call, so a
43
+ # flush bounded by a budget shorter than the client's own
44
+ # request_timeout still returns within it.
45
+ def post_events(gzipped_body, timeout = @timeout)
46
+ request = Net::HTTP::Post.new(@events_uri)
47
+ headers.each { |k, v| request[k] = v }
48
+ request["Content-Type"] = "application/json"
49
+ request["Content-Encoding"] = "gzip"
50
+ request["Flagnetic-Schema-Version"] = SCHEMA_VERSION_MAX.to_s
51
+ request.body = gzipped_body
52
+ response = http_for(@events_uri, timeout).start { |h| h.request(request) }
53
+ EventsResponse.new(status: response.code.to_i, body: response.body.to_s, retry_after: response["Retry-After"])
54
+ end
55
+
56
+ # fetch_ruleset GETs the ruleset, revalidating the held version with
57
+ # If-None-Match. It raises on network errors and timeouts; Net::HTTP
58
+ # asks for and inflates gzip.
59
+ def fetch_ruleset(held_version)
60
+ request = Net::HTTP::Get.new(@ruleset_uri)
61
+ headers.each { |k, v| request[k] = v }
62
+ request["Accept"] = "application/json"
63
+ request["Flagnetic-Schema-Version"] = SCHEMA_VERSION_MAX.to_s
64
+ request["If-None-Match"] = %("#{held_version}") if held_version
65
+ http = http_for(@ruleset_uri, @timeout)
66
+ response = http.start { |h| h.request(request) }
67
+ Response.new(status: response.code.to_i, body: response.body.to_s)
68
+ end
69
+
70
+ # stream_request is the change stream's request; Last-Event-ID is the
71
+ # held version, so the platform emits only what is newer. The stream is
72
+ # asked for uncompressed: a compressed one would hold frames back until
73
+ # the compressor flushed.
74
+ def stream_request(held_version)
75
+ request = Net::HTTP::Get.new(@stream_uri)
76
+ headers.each { |k, v| request[k] = v }
77
+ request["Accept"] = "text/event-stream"
78
+ request["Cache-Control"] = "no-cache"
79
+ request["Accept-Encoding"] = "identity"
80
+ request["Last-Event-ID"] = held_version.to_s if held_version
81
+ request
82
+ end
83
+
84
+ # http_for builds the connection to uri's host. Net::HTTP.new takes the
85
+ # proxy from the environment, honouring no_proxy; retries are off
86
+ # because a failed request is retried by the client's backoff alone.
87
+ def http_for(uri, timeout)
88
+ http = Net::HTTP.new(uri.host, uri.port)
89
+ http.use_ssl = uri.scheme == "https"
90
+ http.open_timeout = http.read_timeout = http.write_timeout = http.ssl_timeout = timeout
91
+ http.max_retries = 0
92
+ http
93
+ end
94
+
95
+ # inspect leaves the server key out, so a client printed in a console
96
+ # or attached to an error report does not disclose it.
97
+ def inspect = "#<#{self.class} #{@ruleset_uri.host}:#{@ruleset_uri.port}>"
98
+
99
+ private
100
+
101
+ def headers
102
+ { "Authorization" => "Bearer #{@sdk_key}", "User-Agent" => @user_agent }
103
+ end
104
+ end
105
+ private_constant :Transport
106
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Flagnetic
6
+ # UUID generates the event IDs of the events endpoint.
7
+ module UUID
8
+ module_function
9
+
10
+ # v7 returns a lowercase UUIDv7 whose first 48 bits are milliseconds
11
+ # since the epoch, so the platform reads an event's time from the ID
12
+ # itself and drops a retry as a duplicate.
13
+ def v7(time: Time.now)
14
+ ms = [(time.to_r * 1000).to_i, 0].max
15
+ timestamp = [ms].pack("Q>").byteslice(2, 6) # the top 48 bits, big-endian
16
+ entropy = SecureRandom.random_bytes(10)
17
+ version_and_rand_a = ((entropy.getbyte(0) & 0x0f) | 0x70).chr
18
+ variant_and_rand_b = ((entropy.getbyte(1) & 0x3f) | 0x80).chr
19
+ bytes = timestamp + version_and_rand_a + entropy.byteslice(2, 1) + variant_and_rand_b + entropy.byteslice(3, 7)
20
+ hex = bytes.unpack1("H*")
21
+ "#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}"
22
+ end
23
+ end
24
+ private_constant :UUID
25
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flagnetic
4
+ VERSION = "0.1.0"
5
+
6
+ # The schema_version range of the documents this SDK reads and the SDK API
7
+ # path it speaks. A ruleset outside the range is a fallback chain failure,
8
+ # logged once as schema_unsupported; every ruleset and events request asks
9
+ # for SCHEMA_VERSION_MAX in Flagnetic-Schema-Version (the change stream
10
+ # carries no document). A change to any of these needs a row in
11
+ # docs/sdks/compatibility.md, and a new SDK_API_PATH is a new major
12
+ # VERSION.
13
+ SCHEMA_VERSION_MIN = 1
14
+ SCHEMA_VERSION_MAX = 1
15
+ SDK_API_PATH = "/sdk/v1"
16
+ end