smplkit 3.0.95 → 3.0.97

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