smplkit 3.0.95 → 3.0.96

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,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ # Registration buffers backing the SDK's batched-discovery sub-clients.
6
+ #
7
+ # Four buffer types, each owned by the sub-client that drains it:
8
+ #
9
+ # - +ContextRegistrationBuffer+ -> +client.platform.contexts._buffer+
10
+ # - +FlagRegistrationBuffer+ -> +client.flags._buffer+
11
+ # - +ConfigRegistrationBuffer+ -> +client.config._buffer+
12
+ # - +LoggerRegistrationBuffer+ -> +client.logging.loggers._buffer+
13
+ #
14
+ # There is exactly one buffer + one bulk-flush implementation per resource.
15
+ module Smplkit
16
+ # When the deduplication LRU exceeds this size, the oldest entry is
17
+ # evicted. The next observation of an evicted entry will re-flush.
18
+ CONTEXT_REGISTRATION_LRU_SIZE = 10_000
19
+
20
+ # Pending-queue size that triggers an immediate background flush from
21
+ # inside +register+. The periodic timer on +Client+ covers the tail
22
+ # case for low-traffic services.
23
+ CONTEXT_BATCH_FLUSH_SIZE = 100
24
+ FLAG_BATCH_FLUSH_SIZE = 50
25
+ LOGGER_BATCH_FLUSH_SIZE = 50
26
+ CONFIG_BATCH_FLUSH_SIZE = 50
27
+
28
+ # Thread-safe batch buffer for context registration.
29
+ class ContextRegistrationBuffer
30
+ def initialize
31
+ @seen = {}
32
+ @pending = []
33
+ @lock = Mutex.new
34
+ end
35
+
36
+ # Queue any unseen contexts.
37
+ def observe(contexts)
38
+ @lock.synchronize do
39
+ contexts.each do |ctx|
40
+ cache_key = [ctx.type, ctx.key]
41
+ next if @seen.key?(cache_key)
42
+
43
+ @seen.shift if @seen.size >= CONTEXT_REGISTRATION_LRU_SIZE
44
+ @seen[cache_key] = ctx.attributes
45
+ @pending << { "type" => ctx.type, "key" => ctx.key, "attributes" => ctx.attributes.dup }
46
+ end
47
+ end
48
+ end
49
+
50
+ # Return and clear the current pending batch.
51
+ def drain
52
+ @lock.synchronize do
53
+ batch = @pending
54
+ @pending = []
55
+ batch
56
+ end
57
+ end
58
+
59
+ def pending_count
60
+ @lock.synchronize { @pending.length }
61
+ end
62
+ end
63
+
64
+ # Thread-safe batch buffer for flag declarations.
65
+ #
66
+ # Use +peek+ + +commit(ids)+ for the send path so a failed POST leaves
67
+ # declarations queued for the next attempt; the legacy +drain+ is
68
+ # unconditional and used only by tests.
69
+ class FlagRegistrationBuffer
70
+ def initialize
71
+ @seen = {}
72
+ @pending = []
73
+ @lock = Mutex.new
74
+ end
75
+
76
+ def add(declaration)
77
+ @lock.synchronize do
78
+ next if @seen.key?(declaration.id)
79
+
80
+ @seen[declaration.id] = true
81
+ item = { "id" => declaration.id, "type" => declaration.type, "default" => declaration.default }
82
+ item["service"] = declaration.service if declaration.service
83
+ item["environment"] = declaration.environment if declaration.environment
84
+ @pending << item
85
+ end
86
+ end
87
+
88
+ def peek
89
+ @lock.synchronize { @pending.dup }
90
+ end
91
+
92
+ def commit(ids)
93
+ return if ids.nil? || ids.empty?
94
+
95
+ committed = ids.to_set
96
+ @lock.synchronize { @pending.reject! { |item| committed.include?(item["id"]) } }
97
+ end
98
+
99
+ def drain
100
+ @lock.synchronize do
101
+ batch = @pending
102
+ @pending = []
103
+ batch
104
+ end
105
+ end
106
+
107
+ def pending_count
108
+ @lock.synchronize { @pending.length }
109
+ end
110
+ end
111
+
112
+ # Thread-safe batch buffer for config declarations.
113
+ #
114
+ # Configs differ from flags/loggers because each entry carries a nested
115
+ # +items+ dict that grows incrementally as the customer's code touches more
116
+ # typed getters on a declared handle. The buffer therefore stores per-config
117
+ # metadata permanently (so post-flush deltas can be re-attributed to the
118
+ # right service/environment) and dedups items per +(config_id, item_key)+ so
119
+ # we never re-send an item that the server has already accepted.
120
+ #
121
+ # Call sites:
122
+ #
123
+ # - +declare+ once per +client.config.bind(id, ...)+.
124
+ # - +add_item+ for every introspected leaf field, or anything else the
125
+ # runtime client observes. Repeated calls with the same
126
+ # +(config_id, item_key)+ after a successful flush are no-ops.
127
+ # - +drain+ returns the pending payload list, clears the pending buffer, and
128
+ # records what was sent.
129
+ #
130
+ # The buffer never drops metadata — only +pending+ is cleared on flush. If
131
+ # the customer's code declares new items via typed getters after a flush, a
132
+ # fresh pending entry is created using the stored metadata so the server can
133
+ # route the delta to the right source row.
134
+ class ConfigRegistrationBuffer
135
+ def initialize
136
+ @pending = {} # config_id -> { id:, items: {}, ...meta }
137
+ @meta = {} # config_id -> { service:, environment:, parent:, name:, description: }
138
+ @sent_items = {} # "#{config_id}::#{item_key}" -> true
139
+ @lock = Mutex.new
140
+ end
141
+
142
+ # Register a configuration. Idempotent within a process.
143
+ def declare(config_id, service:, environment:, parent: nil, name: nil, description: nil)
144
+ @lock.synchronize do
145
+ next if @meta.key?(config_id)
146
+
147
+ @meta[config_id] = {
148
+ service: service, environment: environment,
149
+ parent: parent, name: name, description: description
150
+ }
151
+ @pending[config_id] = build_entry(config_id)
152
+ end
153
+ end
154
+
155
+ # Queue an item declaration if not already sent.
156
+ #
157
+ # Must be preceded by +declare+ for the same +config_id+; otherwise the
158
+ # call is dropped (no implicit declaration).
159
+ def add_item(config_id, item_key, item_type, default, description = nil)
160
+ @lock.synchronize do
161
+ next unless @meta.key?(config_id)
162
+ next if @sent_items.key?("#{config_id}::#{item_key}")
163
+
164
+ entry = (@pending[config_id] ||= build_entry(config_id))
165
+ next if entry["items"].key?(item_key)
166
+
167
+ item = { "value" => default, "type" => item_type }
168
+ item["description"] = description unless description.nil?
169
+ entry["items"][item_key] = item
170
+ end
171
+ end
172
+
173
+ # Return and clear the pending batch; record sent items.
174
+ def drain
175
+ @lock.synchronize do
176
+ entries = @pending.values
177
+ entries.each do |entry|
178
+ entry["items"].each_key { |item_key| @sent_items["#{entry["id"]}::#{item_key}"] = true }
179
+ end
180
+ @pending = {}
181
+ entries
182
+ end
183
+ end
184
+
185
+ def pending_count
186
+ @lock.synchronize { @pending.size }
187
+ end
188
+
189
+ private
190
+
191
+ def build_entry(config_id)
192
+ meta = @meta[config_id]
193
+ entry = { "id" => config_id, "items" => {} }
194
+ %i[service environment parent name description].each do |k|
195
+ v = meta[k]
196
+ entry[k.to_s] = v unless v.nil?
197
+ end
198
+ entry
199
+ end
200
+ end
201
+
202
+ # Thread-safe batch buffer for logger discovery.
203
+ class LoggerRegistrationBuffer
204
+ def initialize
205
+ @seen = {}
206
+ @pending = []
207
+ @lock = Mutex.new
208
+ end
209
+
210
+ def add(source)
211
+ @lock.synchronize do
212
+ next if @seen.key?(source.name)
213
+
214
+ @seen[source.name] = source.resolved_level
215
+ item = { "id" => source.name, "resolved_level" => source.resolved_level&.to_s }
216
+ item["level"] = source.level&.to_s if source.level
217
+ item["service"] = source.service if source.service
218
+ item["environment"] = source.environment if source.environment
219
+ @pending << item
220
+ end
221
+ end
222
+
223
+ def drain
224
+ @lock.synchronize do
225
+ batch = @pending
226
+ @pending = []
227
+ batch
228
+ end
229
+ end
230
+
231
+ def pending_count
232
+ @lock.synchronize { @pending.length }
233
+ end
234
+ end
235
+ end
@@ -2,8 +2,6 @@
2
2
 
3
3
  require "concurrent"
4
4
 
5
- require "smplkit/audit/client"
6
-
7
5
  module Smplkit
8
6
  # Synchronous entry point for the smplkit SDK.
9
7
  #
@@ -28,9 +26,12 @@ module Smplkit
28
26
  # internal SDK-owned threads; public methods block the calling thread and
29
27
  # return values directly.
30
28
  class Client
29
+ # Periodic flush of all sub-client registration buffers (contexts, flags,
30
+ # loggers). Threshold flushes still fire immediately when buffers fill up;
31
+ # this timer is the liveness guarantee for the tail.
31
32
  PERIODIC_FLUSH_INTERVAL = 60.0
32
33
 
33
- attr_reader :manage, :config, :flags, :logging, :audit
34
+ attr_reader :platform, :account, :config, :flags, :logging, :audit, :jobs
34
35
 
35
36
  # Construct, yield to the block, and close on exit.
36
37
  def self.open(**kwargs)
@@ -42,7 +43,7 @@ module Smplkit
42
43
  end
43
44
  end
44
45
 
45
- def initialize(api_key: nil, environment: nil, service: nil, profile: nil, # rubocop:disable Metrics/AbcSize
46
+ def initialize(api_key: nil, environment: nil, service: nil, profile: nil,
46
47
  base_domain: nil, scheme: nil, debug: nil, telemetry: nil,
47
48
  extra_headers: nil)
48
49
  cfg = ConfigResolution.resolve_config(
@@ -56,6 +57,7 @@ module Smplkit
56
57
  @service = cfg.service
57
58
  @base_domain = cfg.base_domain
58
59
  @scheme = cfg.scheme
60
+ @extra_headers = extra_headers
59
61
 
60
62
  masked_key = cfg.api_key.length > 10 ? "#{cfg.api_key[0, 10]}..." : cfg.api_key
61
63
  Smplkit.debug(
@@ -65,54 +67,86 @@ module Smplkit
65
67
  "debug=#{cfg.debug} telemetry=#{cfg.telemetry}"
66
68
  )
67
69
 
68
- mgmt_cfg = ConfigResolution::ResolvedManagementConfig.new(
69
- api_key: cfg.api_key, base_domain: cfg.base_domain, scheme: cfg.scheme, debug: cfg.debug
70
- )
71
- @extra_headers = extra_headers
72
- @manage = ManagementClient.from_resolved(mgmt_cfg, extra_headers: extra_headers)
70
+ # Build the per-service HTTP transports + the context-registration buffer.
71
+ # Side-effect-free: each transport connects lazily on first call.
72
+ # client.platform owns the buffer; client.config/flags/logging/jobs borrow
73
+ # their transports from here.
74
+ @transports = Transport.build_service_transports(Transport.to_transport_config(cfg, extra_headers))
73
75
 
74
- app_url = ConfigResolution.service_url(cfg.scheme, "app", cfg.base_domain)
75
- flags_url = ConfigResolution.service_url(cfg.scheme, "flags", cfg.base_domain)
76
- logging_url = ConfigResolution.service_url(cfg.scheme, "logging", cfg.base_domain)
76
+ app_url = @transports.app_url
77
77
  audit_url = ConfigResolution.service_url(cfg.scheme, "audit", cfg.base_domain)
78
+
79
+ # Alias the shared HTTP transports — single connection pool per service.
80
+ @http_client = @transports.config_http
81
+ @app_http = @transports.app_http
78
82
  @app_base_url = app_url
79
83
 
84
+ # Metrics reporter
80
85
  @metrics = if cfg.telemetry
81
- MetricsReporter.new(http_client: @manage._app_http,
82
- environment: cfg.environment,
83
- service: cfg.service)
86
+ MetricsReporter.new(http_client: @app_http, environment: cfg.environment, service: cfg.service)
84
87
  end
85
88
 
86
89
  @ws_manager = nil
87
- @config = Config::ConfigClient.new(self, manage: @manage, metrics: @metrics)
88
- @flags = Flags::FlagsClient.new(self, manage: @manage, metrics: @metrics,
89
- flags_base_url: flags_url, app_base_url: app_url)
90
- @logging = Logging::LoggingClient.new(self, manage: @manage, metrics: @metrics,
91
- logging_base_url: logging_url, app_base_url: app_url)
92
- @audit = Audit::AuditClient.new(api_key: cfg.api_key, base_url: audit_url,
93
- environment: cfg.environment, extra_headers: extra_headers)
90
+ # Platform's cross-cutting CRUD on one client; wired into this parent so
91
+ # it borrows the shared app transport, and owns the context-registration
92
+ # buffer. Built BEFORE flags so the contexts seam below is available.
93
+ @platform = Platform::PlatformClient.new(app_transport: @app_http)
94
+ # Account-level settings on one client; built from the app url + api key
95
+ # (the settings sub-client uses Faraday directly).
96
+ @account = Account::AccountClient.new(api_key: cfg.api_key, base_url: app_url, extra_headers: extra_headers)
97
+ # Config's full surface on one client; wired into this parent so it
98
+ # borrows the shared config transport and WebSocket.
99
+ @config = Config::ConfigClient.new(parent: self, transport: @transports.config_http, metrics: @metrics)
100
+ # Flags' full surface on one client; wired into this parent so it borrows
101
+ # the shared flags transport and WebSocket. ``contexts`` is the injection
102
+ # seam for evaluation-context registration, wired to
103
+ # ``client.platform.contexts``.
104
+ @flags = Flags::FlagsClient.new(
105
+ parent: self, transport: @transports.flags_http, contexts: @platform.contexts, metrics: @metrics
106
+ )
107
+ # Logging's full surface on one client; wired into this parent so it
108
+ # borrows the shared logging transport and WebSocket. The two management
109
+ # sub-clients live at client.logging.loggers / client.logging.log_groups.
110
+ @logging = Logging::LoggingClient.new(parent: self, transport: @transports.logging_http, metrics: @metrics)
111
+ # Audit's full surface on one client; this runtime instance carries the
112
+ # configured environment as ``X-Smplkit-Environment`` and owns its own
113
+ # transport (closed in ``close``).
114
+ @audit = Audit::AuditClient.new(
115
+ api_key: cfg.api_key, base_url: audit_url, environment: cfg.environment, extra_headers: extra_headers
116
+ )
117
+ # Jobs has no runtime/management split — reuse the shared jobs transport
118
+ # (single connection pool) so ``client.jobs`` is one-stop.
119
+ @jobs = Jobs::JobsClient.new(auth_client: @transports.jobs_http)
94
120
 
121
+ # Construction is side-effect-free: no background threads, no phone-home.
122
+ # The periodic registration-buffer flush and the service-context
123
+ # registration are deferred until the first config/flags/logging operation
124
+ # or set_context via ``_ensure_started`` — so an audit-only or jobs-only
125
+ # customer pays zero threads and zero network at construction.
95
126
  @closed = false
96
- schedule_periodic_flush
97
-
98
- @init_thread = Thread.new(@manage, @environment, @service, @app_base_url) do |mgmt, env, svc, app_url|
99
- register_service_context(mgmt, env, svc, app_url)
100
- end
127
+ @started = false
128
+ @start_lock = Mutex.new
129
+ @flush_timer = nil
130
+ @init_thread = nil
101
131
  end
102
132
 
103
- # Eagerly initialize the SDK and block until it is fully ready.
133
+ # Optionally pre-warm the SDK and block until the live socket is up.
104
134
  #
105
- # Pre-fetches all flags and configs into the local cache, opens the
106
- # live-updates WebSocket, and waits for the handshake to complete.
107
- # After this returns, +flag.get+ / +client.config.get+ hit cache (no
108
- # first-request connect tax) and any +on_change+ listeners receive every
109
- # server event from this point forward.
135
+ # Eagerly connects config and flags flushing discovery, pre-fetching all
136
+ # flags and configs into the local cache, opening the live-updates WebSocket
137
+ # — and waits for the handshake to complete. After this returns, +flag.get+
138
+ # / +client.config.subscribe+ hit cache (no first-request connect tax) and
139
+ # any +on_change+ listeners receive every server event from this point
140
+ # forward.
110
141
  #
111
- # Logging integration is *not* installed here call
112
- # +client.logging.install+ separately if you want it.
142
+ # Optional: config and flags connect lazily on first live use, so this is
143
+ # purely a pre-warm / WebSocket-ready barrier. Logging integration is *not*
144
+ # connected here — call +client.logging.install+ separately if you want it
145
+ # (it installs adapters and hooks into your application's logger, which
146
+ # should be opt-in).
113
147
  def wait_until_ready(timeout: 10.0)
114
- @flags.start
115
- @config.start
148
+ @flags._ensure_connected
149
+ @config._ensure_connected
116
150
  ws = _ensure_ws
117
151
  deadline = monotonic_now + timeout
118
152
  while ws.connection_status != "connected"
@@ -127,21 +161,26 @@ module Smplkit
127
161
 
128
162
  # Stash +contexts+ as the current request's evaluation context.
129
163
  #
164
+ # Typical use is from middleware — set the context once at request entry and
165
+ # every +flag.get+ (and other context-sensitive evaluations) inside that
166
+ # request automatically picks it up.
167
+ #
168
+ # Each unique +(type, key)+ is also queued for bulk registration on the
169
+ # management API (deduplicated; flushed in the background).
170
+ #
130
171
  # Two usage shapes:
131
172
  #
132
173
  # # Fire-and-forget (typical middleware)
133
174
  # client.set_context([Smplkit::Context.new("user", "u-123")])
134
175
  #
135
- # # Scoped block (impersonation or one-off override)
176
+ # # Scoped block (e.g. impersonation or one-off override)
136
177
  # client.set_context([Smplkit::Context.new("user", "impersonated")]) do
137
178
  # # ...
138
179
  # end
139
180
  # # original context restored here
140
- #
141
- # Each unique +(type, key)+ is also queued for bulk registration on the
142
- # management API.
143
181
  def set_context(contexts, &block)
144
- @manage.contexts.register(contexts) if contexts && !contexts.empty?
182
+ _ensure_started
183
+ @platform.contexts.register(contexts) if contexts && !contexts.empty?
145
184
 
146
185
  scope = Smplkit.set_request_context(contexts || [])
147
186
  if block
@@ -151,19 +190,23 @@ module Smplkit
151
190
  end
152
191
  end
153
192
 
193
+ # Release all resources held by this client.
154
194
  def close
155
195
  Smplkit.debug("lifecycle", "Client.close called")
156
196
  @closed = true
157
197
  @flush_timer&.shutdown
198
+ @flush_timer = nil
158
199
  final_flush
159
200
  @metrics&.close
160
201
  @logging._close
161
202
  @flags._close
162
- @config._close
163
203
  @audit._close
164
204
  @ws_manager&.stop
165
205
  @ws_manager = nil
166
- @manage.close
206
+ # Close the shared per-service HTTP transports (app/config/flags/logging/
207
+ # jobs). client.platform/account borrow the app transport and close
208
+ # nothing; client.audit owns and closed its own transport above.
209
+ @transports.close
167
210
  end
168
211
 
169
212
  # Internal accessors used by sub-clients --------------------------------
@@ -175,15 +218,29 @@ module Smplkit
175
218
  def _metrics = @metrics
176
219
  def _extra_headers = @extra_headers
177
220
 
178
- def _ensure_ws
179
- @_ensure_ws ||= begin
180
- ws = SharedWebSocket.new(app_base_url: @app_base_url, api_key: @api_key, metrics: @metrics)
181
- ws.start
182
- ws
221
+ # Start the deferred background machinery exactly once.
222
+ #
223
+ # Idempotent and thread-safe (lock + flag); a no-op after +close+. Triggered
224
+ # by the first config/flags/logging operation, +set_context+,
225
+ # +wait_until_ready+, or WebSocket open — never at construction.
226
+ def _ensure_started
227
+ @start_lock.synchronize do
228
+ return if @started || @closed
229
+
230
+ @started = true
183
231
  end
232
+ schedule_periodic_flush
233
+ @init_thread = Thread.new { register_service_context }
184
234
  end
185
235
 
186
- def _flags_transport = @manage.flags
236
+ def _ensure_ws
237
+ _ensure_started
238
+ if @ws_manager.nil?
239
+ @ws_manager = SharedWebSocket.new(app_base_url: @app_base_url, api_key: @api_key, metrics: @metrics)
240
+ @ws_manager.start
241
+ end
242
+ @ws_manager
243
+ end
187
244
 
188
245
  private
189
246
 
@@ -198,38 +255,40 @@ module Smplkit
198
255
  @flush_timer.execute
199
256
  end
200
257
 
201
- # Extracted as a private method so the timer body is reachable from
202
- # tests without poking into Concurrent::TimerTask internals.
258
+ # Extracted as a private method so the timer body is reachable from tests
259
+ # without poking into Concurrent::TimerTask internals.
203
260
  def run_periodic_flush
204
261
  return if @closed
205
262
 
206
- @manage.contexts.flush
207
- @manage.flags.flush
208
- @manage.loggers.flush
263
+ @platform.contexts.flush
264
+ @flags.flush
265
+ @logging.loggers.flush
266
+ @config.flush
209
267
  rescue StandardError => e
210
268
  Smplkit.debug("registration", "periodic flush failed: #{e.class}: #{e.message}")
211
269
  end
212
270
 
213
271
  def final_flush
214
- [@manage.contexts, @manage.flags, @manage.loggers].each do |ns|
215
- ns.flush
272
+ [@platform.contexts, @flags, @logging.loggers, @config].each do |target|
273
+ target.flush
216
274
  rescue StandardError => e
217
275
  Smplkit.debug("registration", "final flush failed: #{e.class}: #{e.message}")
218
276
  end
219
277
  end
220
278
 
221
- def register_service_context(mgmt, env, svc, app_url)
222
- # Bulk-register the environment + service as +Smplkit::Context+
223
- # instances on the platform so they show up in the Console alongside
224
- # any user-provided contexts. The buffer dedupes on +(type, key)+, so
225
- # this is safe to call on every client construction.
279
+ def register_service_context
280
+ # Register the environment and/or service as context instances. Only the
281
+ # values that are set are registered; if neither environment nor service
282
+ # was provided the POST is skipped entirely (an audit/jobs-only customer
283
+ # has nothing to register).
226
284
  contexts = []
227
- contexts << Smplkit::Context.new("environment", env) if env
228
- contexts << Smplkit::Context.new("service", svc, name: svc) if svc
229
- mgmt.contexts.register(contexts)
230
- mgmt.contexts.flush
285
+ contexts << Smplkit::Context.new("environment", @environment) if @environment
286
+ contexts << Smplkit::Context.new("service", @service, { "name" => @service }) if @service
287
+ return if contexts.empty?
288
+
289
+ @platform.contexts.register(contexts, flush: true)
231
290
  rescue StandardError => e
232
- Smplkit.debug("lifecycle", "register service context failed (app: #{app_url}): #{e.class}: #{e.message}")
291
+ Smplkit.debug("lifecycle", "register service context failed (app: #{@app_base_url}): #{e.class}: #{e.message}")
233
292
  end
234
293
  end
235
294
  end