smplkit 3.0.129 → 3.0.132

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0b6297c50f89cbe656fb1a9b3d26990f853fccc9f1894957d0e3a43fadca226f
4
- data.tar.gz: 97364f6d0e110220f5945b1eb5e3060c843a8855921884e344ebaf0f765e676d
3
+ metadata.gz: b949ca8853996a6f2963a8d281f7d9c78b1ffd076d8bdf592c01ba8b536af7fe
4
+ data.tar.gz: ae9880e7ed0b32619b88edbdaf45a9755d657c53b2dae211164e394cad33d27b
5
5
  SHA512:
6
- metadata.gz: deeb4adfddff9d195207ef70887157829816d0f933daca4bcecdbe149c8b2f93fe9798993cda6c26791168136ac4e5a2cd259f2481019e91c72fff2e97e10e64
7
- data.tar.gz: 6beb5e6fff975ea31e05f02934003736228c8ec63431f9d3ffd74479f029328cac26c7c63af2caf85fe859eba43af26850a6868ccab45f0b2521337b207bd389
6
+ metadata.gz: 8ae977dfa3241a4f325ff0c86c60e917ff26c412b889408eddbf8bc39f1dc6c525a3238dc70917364828e04b68424b953b4a1c4f64e8cdcd2ba13aeaec017144
7
+ data.tar.gz: 77bf283d211b3fe647c0ec00f59f928b8727b780cc101aeecae68a4e7979c740d258f94b35ea60fd33d6a88f0b759183f4ed8e8cd230fdabd15a231c15c471b0
@@ -36,7 +36,12 @@ module Smplkit
36
36
  @headers = {
37
37
  "Authorization" => "Bearer #{api_key}",
38
38
  "Content-Type" => "application/json"
39
- }.merge(extra_headers || {})
39
+ }
40
+ # Default SDK User-Agent unless the caller supplied one (any casing) —
41
+ # without it this raw Faraday connection would fall through to
42
+ # Faraday's own "Faraday vX.Y.Z" default.
43
+ @headers["User-Agent"] = Smplkit.user_agent unless Transport.user_agent_supplied?(extra_headers)
44
+ @headers.merge!(extra_headers || {})
40
45
  end
41
46
 
42
47
  # Fetch the authenticated account's current settings.
@@ -23,7 +23,8 @@ module Smplkit
23
23
  # @param environment [String, nil] Deployment environment to scope recording
24
24
  # and reads to. Sent on the event request body when recording and as the
25
25
  # default +filter[environment]+ on the read surfaces (events list and the
26
- # resource_type / event_type / category discovery lists). Optional
26
+ # resource_type / event_type / category discovery lists). When omitted,
27
+ # resolved from +SMPLKIT_ENVIRONMENT+ or +~/.smplkit+. Optional —
27
28
  # forwarder CRUD is environment-agnostic, and reads accept an explicit
28
29
  # +environments: [...]+ filter that overrides this default. With no
29
30
  # configured environment, recording falls back to the server-side default
@@ -35,28 +36,36 @@ module Smplkit
35
36
  # @param debug [Boolean, nil] Enable SDK debug logging.
36
37
  # @param timeout [Float] Per-request timeout, in seconds. Defaults to +10.0+.
37
38
  # @param extra_headers [Hash{String => String}, nil] Extra headers attached
38
- # to every request. SDK-owned headers (authorization, content-type,
39
- # user-agent) cannot be overridden.
39
+ # to every request. SDK-owned headers (authorization, content-type)
40
+ # cannot be overridden. A caller-supplied User-Agent (any casing)
41
+ # replaces the SDK default +smplkit-sdk-ruby/<version>+.
42
+ # @param buffered [Boolean] Buffer event recording (default +true+):
43
+ # +events.record+ enqueues onto an in-memory buffer that a background
44
+ # worker drains with retry, and returns immediately. Set +false+ for the
45
+ # stateless write path: +events.record+ performs one blocking POST per
46
+ # call and raises on failure, +events.flush+ is a no-op, and no worker
47
+ # thread or background state is created — the right shape for serverless
48
+ # and edge runtimes, where an instance may be constructed per request.
40
49
  class AuditClient
41
50
  attr_reader :events, :resource_types, :event_types, :categories, :forwarders
42
51
 
43
- SDK_OWNED_HEADERS = %w[authorization content-type user-agent].freeze
44
-
45
52
  def initialize(api_key: nil, base_url: nil, environment: nil, profile: nil,
46
53
  base_domain: nil, scheme: nil, debug: nil, timeout: 10.0,
47
- extra_headers: nil)
48
- # +base_url+/+api_key+ are used directly when both are supplied (the
49
- # path the top-level client takes after it has already resolved them);
50
- # otherwise the config resolver fills in whatever is missing
51
- # (+~/.smplkit+ / env vars / defaults) and the audit base URL is derived
52
- # from +base_domain+/+scheme+ via +service_url+.
53
- if api_key.nil? || base_url.nil?
54
+ extra_headers: nil, buffered: true)
55
+ # +api_key+/+base_url+/+environment+ are used directly when all three
56
+ # are supplied (the path the top-level client takes after it has
57
+ # already resolved them); otherwise the config resolver fills in
58
+ # whatever is missing (+~/.smplkit+ / env vars / defaults) and the
59
+ # audit base URL is derived from +base_domain+/+scheme+ via
60
+ # +service_url+.
61
+ if api_key.nil? || base_url.nil? || environment.nil?
54
62
  resolved = ConfigResolution.resolve_client_config(
55
63
  profile: profile, api_key: api_key, base_domain: base_domain,
56
- scheme: scheme, debug: debug
64
+ scheme: scheme, environment: environment, debug: debug
57
65
  )
58
66
  api_key = resolved.api_key if api_key.nil?
59
67
  base_url = ConfigResolution.service_url(resolved.scheme, "audit", resolved.base_domain) if base_url.nil?
68
+ environment = resolved.environment if environment.nil?
60
69
  debug = resolved.debug if debug.nil?
61
70
  end
62
71
  cfg = SmplkitGeneratedClient::Audit::Configuration.new
@@ -67,18 +76,19 @@ module Smplkit
67
76
  cfg.debugging = debug unless debug.nil?
68
77
  HttpPool.configure(cfg)
69
78
  api_client = SmplkitGeneratedClient::Audit::ApiClient.new(cfg)
70
- api_client.default_headers["User-Agent"] = "smplkit-ruby-sdk/#{Smplkit::VERSION}"
71
79
  # Runtime audit ops are environment-scoped, but the scoping is
72
80
  # body-driven (ADR-055): +events.record+ stamps the configured
73
81
  # environment onto the event request body, and the read surfaces
74
82
  # (events list plus the resource_type / event_type / category discovery
75
83
  # lists) default +filter[environment]+ to it. The transport therefore
76
- # carries only auth plus any caller-supplied +extra_headers+ — no
77
- # environment header. Forwarder CRUD is environment-agnostic.
78
- extra_headers&.each do |k, v|
79
- api_client.default_headers[k] = v unless SDK_OWNED_HEADERS.include?(k.downcase)
80
- end
81
- @events = Events.new(SmplkitGeneratedClient::Audit::EventsApi.new(api_client), environment: environment)
84
+ # carries only auth, the default User-Agent, and any caller-supplied
85
+ # +extra_headers+ — no environment header. Forwarder CRUD is
86
+ # environment-agnostic.
87
+ Transport.apply_headers(api_client, extra_headers)
88
+ @events = Events.new(
89
+ SmplkitGeneratedClient::Audit::EventsApi.new(api_client),
90
+ environment: environment, buffered: buffered
91
+ )
82
92
  @resource_types = ResourceTypes.new(
83
93
  SmplkitGeneratedClient::Audit::ResourceTypesApi.new(api_client), environment: environment
84
94
  )
@@ -7,22 +7,33 @@ module Smplkit
7
7
  # +#record+ is fire-and-forget by default — the call enqueues the event
8
8
  # onto an in-memory bounded buffer and returns immediately. Pass
9
9
  # +flush: true+ to block until the event is durable before continuing.
10
+ # In stateless mode (+buffered: false+ on the client) there is no buffer:
11
+ # every +#record+ performs one blocking POST and raises on failure.
10
12
  # +#list+ and +#get+ are synchronous reads.
11
13
  class Events
12
- def initialize(api, environment: nil)
14
+ def initialize(api, environment: nil, buffered: true)
13
15
  @api = api
14
16
  @environment = environment
15
- @buffer = EventBuffer.new(api)
17
+ # Stateless mode installs no buffer at all — no worker thread, no
18
+ # background state — so a client may be constructed per request in
19
+ # serverless or edge runtimes.
20
+ @buffer = buffered ? EventBuffer.new(api) : nil
16
21
  end
17
22
 
18
- # Enqueue an audit event for asynchronous delivery.
23
+ # Record an audit event.
19
24
  #
20
- # Returns immediately when +flush+ is +false+ (the default) the
21
- # buffer's worker thread performs the actual POST with retry on
22
- # transient failures. When +flush: true+, this call blocks until the
23
- # buffer has drained or +flush_timeout+ elapses; use it when the caller
24
- # needs the event durable before continuing (CLI tools, in-test
25
- # assertions, or any flow about to terminate the process).
25
+ # In the default buffered mode this enqueues for asynchronous delivery
26
+ # and returns immediately — the buffer's worker thread performs the
27
+ # actual POST with retry on transient failures. When +flush: true+,
28
+ # the call blocks until the buffer has drained or +flush_timeout+
29
+ # elapses; use it when the caller needs the event durable before
30
+ # continuing (CLI tools, in-test assertions, or any flow about to
31
+ # terminate the process).
32
+ #
33
+ # In stateless mode (+buffered: false+ on the client) every call
34
+ # performs one blocking POST and raises one of the SDK's typed errors
35
+ # on failure; +flush+/+flush_timeout+ are meaningless there and
36
+ # ignored.
26
37
  #
27
38
  # Actor attribution (+actor_type+, +actor_id+, +actor_label+) is
28
39
  # customer-supplied and free-form. The audit service stores
@@ -69,10 +80,11 @@ module Smplkit
69
80
  # enabled forwarder so the skip is visible in the forwarder delivery log.
70
81
  # @param flush [Boolean] When +true+, block until the buffer has drained
71
82
  # (or +flush_timeout+ elapses) before returning. Defaults to +false+
72
- # (fire-and-forget).
83
+ # (fire-and-forget). Ignored in stateless mode (+buffered: false+),
84
+ # where every record is already durable on return.
73
85
  # @param flush_timeout [Float, nil] Upper bound on the blocking flush, in
74
- # seconds. Ignored when +flush+ is +false+. +nil+ blocks indefinitely.
75
- # Defaults to +5.0+.
86
+ # seconds. Ignored when +flush+ is +false+ and in stateless mode.
87
+ # +nil+ blocks indefinitely. Defaults to +5.0+.
76
88
  # @return [void]
77
89
  def record(event_type:, resource_type:, resource_id:,
78
90
  occurred_at: nil, actor_type: nil, actor_id: nil,
@@ -124,6 +136,14 @@ module Smplkit
124
136
  attributes: attrs
125
137
  )
126
138
  body = SmplkitGeneratedClient::Audit::EventRequest.new(data: resource)
139
+ if @buffer.nil?
140
+ # Stateless path: one blocking POST per call — the same wire call
141
+ # the buffer's worker performs, but failures surface to the caller
142
+ # as the SDK's typed errors instead of being retried in background.
143
+ opts = idempotency_key ? { idempotency_key: idempotency_key } : {}
144
+ Smplkit::Audit.call_api { @api.record_event(body, opts) }
145
+ return nil
146
+ end
127
147
  @buffer.enqueue(body, idempotency_key)
128
148
  @buffer.flush(timeout: flush_timeout) if flush
129
149
  nil
@@ -171,6 +191,9 @@ module Smplkit
171
191
  # equals this value. Omit to match any.
172
192
  # @param actor_id [String, nil] Return only events whose +actor_id+
173
193
  # matches this value as a literal string. Omit to match any.
194
+ # @param category [String, nil] Filter to this exact category — the
195
+ # indexed correlation label callers stamp on related events. Omit to
196
+ # match any.
174
197
  # @param occurred_at_range [String, nil] Restrict to events whose
175
198
  # +occurred_at+ falls in this range. Omit to leave the time window open.
176
199
  # @param search [String, nil] Optional free-text filter — returns only
@@ -189,7 +212,7 @@ module Smplkit
189
212
  # @return [Smplkit::Audit::ListEventsPage] A page of the matching events;
190
213
  # its +#next_cursor+ is set when more pages are available.
191
214
  def list(event_type: nil, resource_type: nil, resource_id: nil,
192
- actor_type: nil, actor_id: nil, occurred_at_range: nil,
215
+ actor_type: nil, actor_id: nil, category: nil, occurred_at_range: nil,
193
216
  search: nil, environments: nil, page_size: nil, page_after: nil)
194
217
  # Generated client opts use snake_case keys that internally map
195
218
  # to the JSON:API ``filter[*]`` / ``page[*]`` query-string format
@@ -202,6 +225,7 @@ module Smplkit
202
225
  opts[:filter_resource_id] = resource_id if resource_id
203
226
  opts[:filter_actor_type] = actor_type if actor_type
204
227
  opts[:filter_actor_id] = actor_id if actor_id
228
+ opts[:filter_category] = category if category
205
229
  opts[:filter_occurred_at] = occurred_at_range if occurred_at_range
206
230
  opts[:filter_search] = search if search
207
231
  resolved_environment = Smplkit::Audit.resolve_environment_filter(environments, @environment)
@@ -217,18 +241,22 @@ module Smplkit
217
241
  # Block until the in-memory buffer is drained or the timeout elapses.
218
242
  #
219
243
  # Useful for draining buffered events at process shutdown or after a batch
220
- # of fire-and-forget records.
244
+ # of fire-and-forget records. A no-op in stateless mode
245
+ # (+buffered: false+), where every record is already durable on return.
221
246
  #
222
247
  # @param timeout [Float, nil] Upper bound on the blocking flush, in
223
248
  # seconds. +nil+ blocks indefinitely. Defaults to +5.0+.
224
249
  # @return [void]
225
250
  def flush(timeout: 5.0)
226
- @buffer.flush(timeout: timeout)
251
+ @buffer&.flush(timeout: timeout)
252
+ nil
227
253
  end
228
254
 
229
- # @api private — drains best-effort and stops the worker thread.
255
+ # @api private — drains best-effort and stops the worker thread. A no-op
256
+ # in stateless mode (+buffered: false+), which has no worker to stop.
230
257
  def _close
231
- @buffer.close
258
+ @buffer&.close
259
+ nil
232
260
  end
233
261
  end
234
262
 
@@ -337,8 +337,9 @@ module Smplkit
337
337
  # +base_url+/+api_key+ are used directly when supplied (the path a top-level
338
338
  # client takes after it has already resolved them); otherwise the config
339
339
  # resolver fills in whatever is missing (+~/.smplkit+ / env vars /
340
- # defaults). The app base URL is returned alongside so a standalone client
341
- # can open its own WebSocket against the event gateway.
340
+ # defaults). +environment+/+service+ resolve the same way (constructor
341
+ # argument wins). The app base URL is returned alongside so a standalone
342
+ # client can open its own WebSocket against the event gateway.
342
343
  #
343
344
  # @api private
344
345
  # @param api_key [String, nil] API key, or +nil+ to resolve it.
@@ -347,14 +348,20 @@ module Smplkit
347
348
  # @param profile [String, nil] Named +~/.smplkit+ profile section.
348
349
  # @param base_domain [String, nil] Base domain for API requests.
349
350
  # @param scheme [String, nil] URL scheme.
351
+ # @param environment [String, nil] Deployment environment, or +nil+ to
352
+ # resolve it.
353
+ # @param service [String, nil] Service name, or +nil+ to resolve it.
350
354
  # @param debug [Boolean, nil] Enable SDK debug logging.
351
355
  # @param extra_headers [Hash{String => String}, nil] Headers attached to
352
356
  # every request.
353
- # @return [Array(Object, String, String)] The transport, the app base URL,
354
- # and the resolved API key.
355
- def self.config_transport(api_key:, base_url:, profile:, base_domain:, scheme:, debug:, extra_headers:)
357
+ # @return [Array(Object, String, String, String, String)] The transport,
358
+ # the app base URL, the resolved API key, the resolved environment, and
359
+ # the resolved service (the last two +nil+ when configured nowhere).
360
+ def self.config_transport(api_key:, base_url:, profile:, base_domain:, scheme:,
361
+ environment:, service:, debug:, extra_headers:)
356
362
  cfg = ConfigResolution.resolve_client_config(
357
- profile: profile, api_key: api_key, base_domain: base_domain, scheme: scheme, debug: debug
363
+ profile: profile, api_key: api_key, base_domain: base_domain, scheme: scheme,
364
+ environment: environment, service: service, debug: debug
358
365
  )
359
366
  resolved_key = api_key.nil? ? cfg.api_key : api_key
360
367
  merged = {}
@@ -366,7 +373,7 @@ module Smplkit
366
373
  )
367
374
  app_url = ConfigResolution.service_url(cfg.scheme, "app", cfg.base_domain)
368
375
  transport = Transport.build_api_client(SmplkitGeneratedClient::Config, "config", tcfg, base_url: base_url)
369
- [transport, app_url, resolved_key]
376
+ [transport, app_url, resolved_key, cfg.environment, cfg.service]
370
377
  end
371
378
 
372
379
  # The Smpl Config client (sync).
@@ -396,7 +403,11 @@ module Smplkit
396
403
  # @param api_key [String, nil] API key. When omitted, resolved from
397
404
  # +SMPLKIT_API_KEY+ or +~/.smplkit+.
398
405
  # @param environment [String, nil] Deployment environment used to resolve
399
- # runtime config values and to scope discovery declarations.
406
+ # runtime config values and to scope discovery declarations. When
407
+ # omitted, resolved from +SMPLKIT_ENVIRONMENT+ or +~/.smplkit+.
408
+ # @param service [String, nil] Service name attached to discovery
409
+ # declarations. When omitted, resolved from +SMPLKIT_SERVICE+ or
410
+ # +~/.smplkit+. Optional.
400
411
  # @param base_url [String, nil] Full config-service base URL. Usually
401
412
  # resolved from +base_domain+/+scheme+; supplied directly by the
402
413
  # top-level clients which have already computed it.
@@ -407,30 +418,49 @@ module Smplkit
407
418
  # @param debug [Boolean, nil] Enable SDK debug logging.
408
419
  # @param extra_headers [Hash{String => String}, nil] Extra headers
409
420
  # attached to every request.
421
+ # @param streaming [Boolean] Live updates over WebSocket (default
422
+ # +true+): the first live call opens a shared socket and config changes
423
+ # stream in. Set +false+ for the stateless read-through surface: the
424
+ # first live call still fetches and resolves every config once
425
+ # (blocking), reads stay local, +refresh+ re-fetches on demand, and NO
426
+ # socket or background thread is ever created — the right shape for
427
+ # serverless and edge runtimes. +on_change+ listeners fire only from
428
+ # explicit +refresh+ calls in this mode (there is no stream to drive
429
+ # them).
410
430
  # @param parent [Smplkit::Client, nil] Internal — the owning client. Not
411
431
  # for direct use.
412
432
  # @param transport [Object, nil] Internal — a pre-built config transport
413
433
  # supplied by a top-level client so the config surface shares one
414
434
  # connection pool. Not for direct use.
415
435
  # @param metrics [Object, nil] Internal — the parent's metrics reporter.
416
- def initialize(api_key = nil, environment: nil, base_url: nil, profile: nil,
436
+ def initialize(api_key = nil, environment: nil, service: nil, base_url: nil, profile: nil,
417
437
  base_domain: nil, scheme: nil, debug: nil, extra_headers: nil,
418
- parent: nil, transport: nil, metrics: nil)
438
+ streaming: true, parent: nil, transport: nil, metrics: nil)
419
439
  @parent = parent
420
440
  @metrics = metrics
421
- @environment = parent.nil? ? environment : parent._environment
422
- @service = parent&._service
441
+ @streaming = streaming ? true : false
423
442
  @standalone_api_key = nil
424
443
  if transport.nil?
425
- @http, @app_base_url, @standalone_api_key = Smplkit::Config.config_transport(
426
- api_key: api_key, base_url: base_url, profile: profile,
427
- base_domain: base_domain, scheme: scheme, debug: debug, extra_headers: extra_headers
428
- )
444
+ # Standalone: resolve like Smplkit::Client — defaults → ~/.smplkit →
445
+ # SMPLKIT_* env vars constructor args — environment and service
446
+ # included.
447
+ @http, @app_base_url, @standalone_api_key, resolved_env, resolved_service =
448
+ Smplkit::Config.config_transport(
449
+ api_key: api_key, base_url: base_url, profile: profile,
450
+ base_domain: base_domain, scheme: scheme, environment: environment,
451
+ service: service, debug: debug, extra_headers: extra_headers
452
+ )
453
+ @environment = parent.nil? ? resolved_env : parent._environment
454
+ @service = parent.nil? ? resolved_service : parent._service
429
455
  @owns_transport = true
430
456
  else
431
457
  @http = transport
432
458
  @app_base_url = nil
433
459
  @owns_transport = false
460
+ # Wired: the parent has already resolved environment/service once —
461
+ # its values win over both the raw kwargs and re-resolution.
462
+ @environment = parent.nil? ? environment : parent._environment
463
+ @service = parent.nil? ? service : parent._service
434
464
  end
435
465
  @api = SmplkitGeneratedClient::Config::ConfigsApi.new(@http)
436
466
 
@@ -839,7 +869,8 @@ module Smplkit
839
869
  # Flushes any buffered discovery declarations, fetches and resolves every
840
870
  # config for the configured environment into the local cache, opens the
841
871
  # shared WebSocket, and subscribes to +config_changed+ / +config_deleted+
842
- # / +configs_changed+ events.
872
+ # / +configs_changed+ events. In stateless mode (+streaming: false+) no
873
+ # socket is ever created; +refresh+ re-fetches on demand.
843
874
  #
844
875
  # Idempotent and internal — every live method calls it on first use, so
845
876
  # the live surface auto-connects with no explicit step.
@@ -859,6 +890,7 @@ module Smplkit
859
890
  # old_cache, so any registered listeners see "initial" events).
860
891
  do_refresh("initial")
861
892
  @connected = true
893
+ return unless @streaming
862
894
 
863
895
  @ws_manager = ensure_ws
864
896
  @ws_manager.on("config_changed") { |data| handle_config_changed(data) }
@@ -992,7 +1024,13 @@ module Smplkit
992
1024
  def trigger_background_flush_if_needed
993
1025
  return unless @buffer.pending_count >= CONFIG_BATCH_FLUSH_SIZE
994
1026
 
995
- Thread.new { threshold_flush }
1027
+ # Stateless mode (+streaming: false+) never spawns background threads —
1028
+ # the threshold flush runs inline (blocking) instead.
1029
+ if @streaming
1030
+ Thread.new { threshold_flush }
1031
+ else
1032
+ threshold_flush
1033
+ end
996
1034
  end
997
1035
 
998
1036
  def threshold_flush
@@ -36,7 +36,7 @@ module Smplkit
36
36
  )
37
37
 
38
38
  ResolvedClientConfig = Struct.new(
39
- :api_key, :base_domain, :scheme, :debug, :extra_headers,
39
+ :api_key, :base_domain, :scheme, :environment, :service, :debug, :extra_headers,
40
40
  keyword_init: true
41
41
  )
42
42
 
@@ -160,18 +160,21 @@ module Smplkit
160
160
  end
161
161
 
162
162
  def resolve_client_config(profile: nil, api_key: nil, base_domain: nil,
163
- scheme: nil, debug: nil, home_dir: nil)
163
+ scheme: nil, environment: nil, service: nil,
164
+ debug: nil, home_dir: nil)
164
165
  resolved = {
165
166
  "api_key" => nil,
166
167
  "base_domain" => "smplkit.com",
167
168
  "scheme" => "https",
169
+ "environment" => nil,
170
+ "service" => nil,
168
171
  "debug" => false
169
172
  }
170
173
 
171
174
  active_profile = profile || ENV["SMPLKIT_PROFILE"] || "default"
172
175
 
173
176
  file_values = read_config_file(active_profile, home_dir: home_dir)
174
- %w[api_key base_domain scheme debug].each do |key|
177
+ %w[api_key base_domain scheme environment service debug].each do |key|
175
178
  next unless file_values.key?(key)
176
179
 
177
180
  val = file_values[key]
@@ -180,7 +183,8 @@ module Smplkit
180
183
 
181
184
  [
182
185
  %w[api_key SMPLKIT_API_KEY], %w[base_domain SMPLKIT_BASE_DOMAIN],
183
- %w[scheme SMPLKIT_SCHEME], %w[debug SMPLKIT_DEBUG]
186
+ %w[scheme SMPLKIT_SCHEME], %w[environment SMPLKIT_ENVIRONMENT],
187
+ %w[service SMPLKIT_SERVICE], %w[debug SMPLKIT_DEBUG]
184
188
  ].each do |key, env_var|
185
189
  env_val = ENV.fetch(env_var, "")
186
190
  next if env_val.empty?
@@ -188,7 +192,10 @@ module Smplkit
188
192
  resolved[key] = key == "debug" ? parse_bool(env_val, env_var) : env_val
189
193
  end
190
194
 
191
- ctor = { "api_key" => api_key, "base_domain" => base_domain, "scheme" => scheme, "debug" => debug }
195
+ ctor = {
196
+ "api_key" => api_key, "base_domain" => base_domain, "scheme" => scheme,
197
+ "environment" => environment, "service" => service, "debug" => debug
198
+ }
192
199
  ctor.each { |k, v| resolved[k] = v unless v.nil? }
193
200
 
194
201
  missing_required(resolved, "api_key", active_profile)
@@ -197,6 +204,11 @@ module Smplkit
197
204
  api_key: resolved["api_key"].to_s,
198
205
  base_domain: resolved["base_domain"].to_s,
199
206
  scheme: resolved["scheme"].to_s,
207
+ # Preserve nil rather than coercing to the literal string "" —
208
+ # +environment+ and +service+ are optional and their absence is
209
+ # meaningful (no environment signal is sent on the wire).
210
+ environment: resolved["environment"]&.to_s,
211
+ service: resolved["service"]&.to_s,
200
212
  debug: resolved["debug"] ? true : false
201
213
  )
202
214
  end
@@ -145,14 +145,17 @@ module Smplkit
145
145
  # +base_url+/+api_key+ are used directly when supplied (the path a top-level
146
146
  # client takes after it has already resolved them); otherwise the management
147
147
  # config resolver fills in whatever is missing (+~/.smplkit+ / env vars /
148
- # defaults). The app transport backs the standalone contexts client
148
+ # defaults). +environment+/+service+ resolve the same way (constructor
149
+ # argument wins). The app transport backs the standalone contexts client
149
150
  # (evaluation-context registration); the app base URL is returned so a
150
151
  # standalone client can open its own WebSocket against the event gateway.
151
152
  #
152
153
  # @api private
153
- def self.flags_transport(api_key:, base_url:, profile:, base_domain:, scheme:, debug:, extra_headers:)
154
+ def self.flags_transport(api_key:, base_url:, profile:, base_domain:, scheme:,
155
+ environment:, service:, debug:, extra_headers:)
154
156
  cfg = ConfigResolution.resolve_client_config(
155
- profile: profile, api_key: api_key, base_domain: base_domain, scheme: scheme, debug: debug
157
+ profile: profile, api_key: api_key, base_domain: base_domain, scheme: scheme,
158
+ environment: environment, service: service, debug: debug
156
159
  )
157
160
  resolved_key = api_key.nil? ? cfg.api_key : api_key
158
161
  merged = {}
@@ -165,7 +168,7 @@ module Smplkit
165
168
  app_url = ConfigResolution.service_url(cfg.scheme, "app", cfg.base_domain)
166
169
  flags_http = Transport.build_api_client(SmplkitGeneratedClient::Flags, "flags", tcfg, base_url: base_url)
167
170
  app_http = Transport.build_api_client(SmplkitGeneratedClient::App, "app", tcfg)
168
- [flags_http, app_http, app_url, resolved_key]
171
+ [flags_http, app_http, app_url, resolved_key, cfg.environment, cfg.service]
169
172
  end
170
173
 
171
174
  # The Smpl Flags client (sync).
@@ -186,24 +189,68 @@ module Smplkit
186
189
  # definitions into the local cache, and opens the live-updates WebSocket. No
187
190
  # explicit install step is required.
188
191
  class FlagsClient
189
- def initialize(api_key = nil, environment: nil, base_url: nil, profile: nil,
192
+ # @param api_key [String, nil] API key. When omitted, resolved from
193
+ # +SMPLKIT_API_KEY+ or +~/.smplkit+.
194
+ # @param environment [String, nil] Deployment environment used to resolve
195
+ # runtime flag values and to scope discovery declarations. When
196
+ # omitted, resolved from +SMPLKIT_ENVIRONMENT+ or +~/.smplkit+.
197
+ # @param service [String, nil] Service name attached to discovery
198
+ # declarations and auto-injected into the evaluation context. When
199
+ # omitted, resolved from +SMPLKIT_SERVICE+ or +~/.smplkit+. Optional.
200
+ # @param base_url [String, nil] Full flags-service base URL. Usually
201
+ # resolved from +base_domain+/+scheme+; supplied directly by the
202
+ # top-level clients which have already computed it.
203
+ # @param profile [String, nil] Named +~/.smplkit+ profile section.
204
+ # @param base_domain [String, nil] Base domain for API requests (default
205
+ # +"smplkit.com"+).
206
+ # @param scheme [String, nil] URL scheme (default +"https"+).
207
+ # @param debug [Boolean, nil] Enable SDK debug logging.
208
+ # @param extra_headers [Hash{String => String}, nil] Extra headers
209
+ # attached to every request.
210
+ # @param streaming [Boolean] Live updates over WebSocket (default
211
+ # +true+): the first live call opens a shared socket and flag changes
212
+ # stream in. Set +false+ for the stateless read-through surface: the
213
+ # first live call still fetches all flag definitions once (blocking),
214
+ # evaluation stays local, +refresh+ re-fetches on demand, and NO socket
215
+ # or background thread is ever created — the right shape for serverless
216
+ # and edge runtimes. +on_change+ listeners fire only from explicit
217
+ # +refresh+ calls in this mode (there is no stream to drive them).
218
+ # @param parent [Smplkit::Client, nil] Internal — the owning client. Not
219
+ # for direct use.
220
+ # @param transport [Object, nil] Internal — a pre-built flags transport
221
+ # supplied by a top-level client so the flags surface shares one
222
+ # connection pool. Not for direct use.
223
+ # @param contexts [Object, nil] Internal — +client.platform.contexts+
224
+ # used for evaluation-context registration. Not for direct use.
225
+ # @param metrics [Object, nil] Internal — the parent's metrics reporter.
226
+ def initialize(api_key = nil, environment: nil, service: nil, base_url: nil, profile: nil,
190
227
  base_domain: nil, scheme: nil, debug: nil, extra_headers: nil,
191
- parent: nil, transport: nil, contexts: nil, metrics: nil)
228
+ streaming: true, parent: nil, transport: nil, contexts: nil, metrics: nil)
192
229
  @parent = parent
193
230
  @metrics = metrics
194
- @environment = parent.nil? ? environment : parent._environment
195
- @service = parent&._service
231
+ @streaming = streaming ? true : false
196
232
  @standalone_api_key = nil
197
233
  if transport.nil?
198
- @flags_http, app_http, @app_base_url, @standalone_api_key = Flags.flags_transport(
199
- api_key: api_key, base_url: base_url, profile: profile,
200
- base_domain: base_domain, scheme: scheme, debug: debug, extra_headers: extra_headers
201
- )
234
+ # Standalone: resolve like Smplkit::Client — defaults → ~/.smplkit →
235
+ # SMPLKIT_* env vars constructor args — environment and service
236
+ # included.
237
+ @flags_http, app_http, @app_base_url, @standalone_api_key, resolved_env, resolved_service =
238
+ Flags.flags_transport(
239
+ api_key: api_key, base_url: base_url, profile: profile,
240
+ base_domain: base_domain, scheme: scheme, environment: environment,
241
+ service: service, debug: debug, extra_headers: extra_headers
242
+ )
243
+ @environment = parent.nil? ? resolved_env : parent._environment
244
+ @service = parent.nil? ? resolved_service : parent._service
202
245
  # Standalone: build our own contexts client (and own its app transport).
203
246
  @contexts = Platform::ContextsClient.new(app_http, ContextRegistrationBuffer.new)
204
247
  else
205
248
  @flags_http = transport
206
249
  @app_base_url = nil
250
+ # Wired: the parent has already resolved environment/service once —
251
+ # its values win over both the raw kwargs and re-resolution.
252
+ @environment = parent.nil? ? environment : parent._environment
253
+ @service = parent.nil? ? service : parent._service
207
254
  # Wired: borrow client.platform.contexts as the evaluation-context
208
255
  # registration seam.
209
256
  @contexts = contexts
@@ -372,7 +419,13 @@ module Smplkit
372
419
  end
373
420
  return unless @buffer.pending_count >= FLAG_BATCH_FLUSH_SIZE
374
421
 
375
- Thread.new { threshold_flush }
422
+ # Stateless mode (+streaming: false+) never spawns background threads —
423
+ # the threshold flush runs inline (blocking) instead.
424
+ if @streaming
425
+ Thread.new { threshold_flush }
426
+ else
427
+ threshold_flush
428
+ end
376
429
  end
377
430
 
378
431
  # POST pending declarations to the flags bulk endpoint.
@@ -648,6 +701,8 @@ module Smplkit
648
701
  # Flushes any buffered discovery declarations, fetches all flag
649
702
  # definitions into the local cache, opens the shared WebSocket, and
650
703
  # subscribes to +flag_changed+ / +flag_deleted+ / +flags_changed+ events.
704
+ # In stateless mode (+streaming: false+) no socket is ever created;
705
+ # +refresh+ re-fetches on demand.
651
706
  #
652
707
  # Idempotent and internal — every live method calls it on first use, so
653
708
  # the live surface auto-connects with no explicit step.
@@ -666,6 +721,7 @@ module Smplkit
666
721
  fetch_all_flags
667
722
  @cache.clear
668
723
  @connected = true
724
+ return unless @streaming
669
725
 
670
726
  @ws_manager = ensure_ws
671
727
  return if @ws_subscribed
@@ -284,12 +284,14 @@ module Smplkit
284
284
  # Reuses the config resolver (jobs is account-global and never
285
285
  # environment-scoped at the transport layer) so a standalone jobs client
286
286
  # resolves credentials/base-domain from +~/.smplkit+ / env vars /
287
- # constructor args exactly like the top-level clients do. Smpl Jobs is
288
- # JSON:API, so the transport carries the +application/vnd.api+json+ Accept
289
- # header.
290
- def self.jobs_transport(api_key:, profile:, base_domain:, scheme:, debug:, extra_headers:)
287
+ # constructor args exactly like the top-level clients do. The client-level
288
+ # default +environment+ resolves through the same chain (constructor
289
+ # argument wins) and is returned alongside. Smpl Jobs is JSON:API, so the
290
+ # transport carries the +application/vnd.api+json+ Accept header.
291
+ def self.jobs_transport(api_key:, profile:, base_domain:, scheme:, environment:, debug:, extra_headers:)
291
292
  cfg = ConfigResolution.resolve_client_config(
292
- profile: profile, api_key: api_key, base_domain: base_domain, scheme: scheme, debug: debug
293
+ profile: profile, api_key: api_key, base_domain: base_domain, scheme: scheme,
294
+ environment: environment, debug: debug
293
295
  )
294
296
  merged = {}
295
297
  merged.merge!(cfg.extra_headers || {})
@@ -298,7 +300,10 @@ module Smplkit
298
300
  api_key: cfg.api_key, base_domain: cfg.base_domain, scheme: cfg.scheme,
299
301
  debug: cfg.debug, extra_headers: merged
300
302
  )
301
- Transport.build_api_client(SmplkitGeneratedClient::Jobs, "jobs", tcfg, accept: "application/vnd.api+json")
303
+ transport = Transport.build_api_client(
304
+ SmplkitGeneratedClient::Jobs, "jobs", tcfg, accept: "application/vnd.api+json"
305
+ )
306
+ [transport, cfg.environment]
302
307
  end
303
308
 
304
309
  # The active-record entry points are {#new_recurring_job}, {#new_manual_job},
@@ -327,18 +332,26 @@ module Smplkit
327
332
  # @param environment [String, nil] Default environment for
328
333
  # environment-scoped operations — the environment a one-off job created
329
334
  # through this client is born in, the default a manual run executes in,
330
- # and the default scope for +runs.list+. +nil+ leaves these unset (the
335
+ # and the default scope for +runs.list+. When omitted, resolved from
336
+ # +SMPLKIT_ENVIRONMENT+ or +~/.smplkit+. +nil+ leaves these unset (the
331
337
  # credential's permitted environment is implied where unambiguous).
332
338
  # @param auth_client [Object, nil] Internal — a pre-built transport
333
339
  # supplied by a top-level client so the jobs surface shares one
334
340
  # connection pool. Not for direct use.
335
341
  def initialize(api_key = nil, profile: nil, base_domain: nil, scheme: nil,
336
342
  debug: nil, extra_headers: nil, environment: nil, auth_client: nil)
337
- auth = auth_client || Jobs.jobs_transport(
338
- api_key: api_key, profile: profile, base_domain: base_domain,
339
- scheme: scheme, debug: debug, extra_headers: extra_headers
340
- )
341
- @environment = environment
343
+ if auth_client.nil?
344
+ # Standalone: resolve like Smplkit::Client defaults → ~/.smplkit →
345
+ # SMPLKIT_* env vars constructor args — environment included.
346
+ auth, resolved_env = Jobs.jobs_transport(
347
+ api_key: api_key, profile: profile, base_domain: base_domain,
348
+ scheme: scheme, environment: environment, debug: debug, extra_headers: extra_headers
349
+ )
350
+ @environment = resolved_env
351
+ else
352
+ auth = auth_client
353
+ @environment = environment
354
+ end
342
355
  @api = SmplkitGeneratedClient::Jobs::JobsApi.new(auth)
343
356
  @runs = RunsClient.new(SmplkitGeneratedClient::Jobs::RunsApi.new(auth), environment: environment)
344
357
  @retry_policies = RetryPoliciesClient.new(SmplkitGeneratedClient::Jobs::RetryPoliciesApi.new(auth))
@@ -44,14 +44,18 @@ module Smplkit
44
44
  # +base_url+/+api_key+ are used directly when supplied (the path a top-level
45
45
  # client takes after it has already resolved them); otherwise the management
46
46
  # config resolver fills in whatever is missing (+~/.smplkit+ / env vars /
47
- # defaults). The app transport is needed for the WebSocket gateway, which
48
- # lives on the app service (like flags); the app base URL is returned so a
49
- # standalone client can open its own WebSocket against the event gateway.
47
+ # defaults). +environment+/+service+ resolve the same way (constructor
48
+ # argument wins). The app transport is needed for the WebSocket gateway,
49
+ # which lives on the app service (like flags); the app base URL is returned
50
+ # so a standalone client can open its own WebSocket against the event
51
+ # gateway.
50
52
  #
51
53
  # @api private
52
- def self.logging_transport(api_key:, base_url:, profile:, base_domain:, scheme:, debug:, extra_headers:)
54
+ def self.logging_transport(api_key:, base_url:, profile:, base_domain:, scheme:,
55
+ environment:, service:, debug:, extra_headers:)
53
56
  cfg = ConfigResolution.resolve_client_config(
54
- profile: profile, api_key: api_key, base_domain: base_domain, scheme: scheme, debug: debug
57
+ profile: profile, api_key: api_key, base_domain: base_domain, scheme: scheme,
58
+ environment: environment, service: service, debug: debug
55
59
  )
56
60
  resolved_key = api_key.nil? ? cfg.api_key : api_key
57
61
  merged = {}
@@ -64,7 +68,7 @@ module Smplkit
64
68
  app_url = ConfigResolution.service_url(cfg.scheme, "app", cfg.base_domain)
65
69
  logging_http = Transport.build_api_client(SmplkitGeneratedClient::Logging, "logging", tcfg, base_url: base_url)
66
70
  app_http = Transport.build_api_client(SmplkitGeneratedClient::App, "app", tcfg)
67
- [logging_http, app_http, app_url, resolved_key]
71
+ [logging_http, app_http, app_url, resolved_key, cfg.environment, cfg.service]
68
72
  end
69
73
 
70
74
  # Discover and load the SDK's built-in logging adapters.
@@ -107,9 +111,15 @@ module Smplkit
107
111
  # +LoggingClient+ and shared here so discovery (driven by
108
112
  # +LoggingClient#install+) and explicit +register+ drain through one queue.
109
113
  class LoggersClient
110
- def initialize(http_client, buffer:)
114
+ # @param http_client [Object] The logging-service transport.
115
+ # @param buffer [LoggerRegistrationBuffer] The shared discovery buffer,
116
+ # owned by the fused +LoggingClient+.
117
+ # @param streaming [Boolean] Internal — +false+ runs threshold flushes
118
+ # inline instead of on a background thread (stateless mode).
119
+ def initialize(http_client, buffer:, streaming: true)
111
120
  @api = SmplkitGeneratedClient::Logging::LoggersApi.new(http_client)
112
121
  @buffer = buffer
122
+ @streaming = streaming ? true : false
113
123
  end
114
124
 
115
125
  # Queue one or more logger sources for registration with the server.
@@ -138,7 +148,13 @@ module Smplkit
138
148
  end
139
149
  return unless @buffer.pending_count >= LOGGER_BATCH_FLUSH_SIZE
140
150
 
141
- Thread.new { threshold_flush }
151
+ # Stateless mode (+streaming: false+) never spawns background threads —
152
+ # the threshold flush runs inline (blocking) instead.
153
+ if @streaming
154
+ Thread.new { threshold_flush }
155
+ else
156
+ threshold_flush
157
+ end
142
158
  end
143
159
 
144
160
  # Drain the buffer and POST pending logger sources to the bulk endpoint.
@@ -423,28 +439,70 @@ module Smplkit
423
439
  class LoggingClient
424
440
  attr_reader :loggers, :log_groups
425
441
 
426
- def initialize(api_key = nil, environment: nil, base_url: nil, profile: nil,
442
+ # @param api_key [String, nil] API key. When omitted, resolved from
443
+ # +SMPLKIT_API_KEY+ or +~/.smplkit+.
444
+ # @param environment [String, nil] Deployment environment used to resolve
445
+ # logger levels and to scope discovery declarations. When omitted,
446
+ # resolved from +SMPLKIT_ENVIRONMENT+ or +~/.smplkit+.
447
+ # @param service [String, nil] Service name attached to discovery
448
+ # declarations. When omitted, resolved from +SMPLKIT_SERVICE+ or
449
+ # +~/.smplkit+. Optional.
450
+ # @param base_url [String, nil] Full logging-service base URL. Usually
451
+ # resolved from +base_domain+/+scheme+; supplied directly by the
452
+ # top-level clients which have already computed it.
453
+ # @param profile [String, nil] Named +~/.smplkit+ profile section.
454
+ # @param base_domain [String, nil] Base domain for API requests (default
455
+ # +"smplkit.com"+).
456
+ # @param scheme [String, nil] URL scheme (default +"https"+).
457
+ # @param debug [Boolean, nil] Enable SDK debug logging.
458
+ # @param extra_headers [Hash{String => String}, nil] Extra headers
459
+ # attached to every request.
460
+ # @param streaming [Boolean] Live updates over WebSocket (default
461
+ # +true+): +install+ opens a shared socket and server-side level
462
+ # changes stream in. Set +false+ for the stateless apply-once surface:
463
+ # +install+ still loads adapters, flushes discovery, and applies the
464
+ # server's levels — all blocking — but NO socket or background thread
465
+ # is ever created; +refresh+ re-fetches and re-applies on demand. The
466
+ # right shape for serverless and edge runtimes; note that live level
467
+ # changes then arrive only via +refresh+.
468
+ # @param parent [Smplkit::Client, nil] Internal — the owning client. Not
469
+ # for direct use.
470
+ # @param transport [Object, nil] Internal — a pre-built logging transport
471
+ # supplied by a top-level client so the logging surface shares one
472
+ # connection pool. Not for direct use.
473
+ # @param metrics [Object, nil] Internal — the parent's metrics reporter.
474
+ def initialize(api_key = nil, environment: nil, service: nil, base_url: nil, profile: nil,
427
475
  base_domain: nil, scheme: nil, debug: nil, extra_headers: nil,
428
- parent: nil, transport: nil, metrics: nil)
476
+ streaming: true, parent: nil, transport: nil, metrics: nil)
429
477
  @parent = parent
430
478
  @metrics = metrics
431
- @environment = parent.nil? ? environment : parent._environment
432
- @service = parent&._service
479
+ @streaming = streaming ? true : false
433
480
  @standalone_api_key = nil
434
481
  if transport.nil?
435
- @logging_http, _app_http, @app_base_url, @standalone_api_key = Logging.logging_transport(
436
- api_key: api_key, base_url: base_url, profile: profile,
437
- base_domain: base_domain, scheme: scheme, debug: debug, extra_headers: extra_headers
438
- )
482
+ # Standalone: resolve like Smplkit::Client — defaults → ~/.smplkit →
483
+ # SMPLKIT_* env vars constructor args — environment and service
484
+ # included.
485
+ @logging_http, _app_http, @app_base_url, @standalone_api_key, resolved_env, resolved_service =
486
+ Logging.logging_transport(
487
+ api_key: api_key, base_url: base_url, profile: profile,
488
+ base_domain: base_domain, scheme: scheme, environment: environment,
489
+ service: service, debug: debug, extra_headers: extra_headers
490
+ )
491
+ @environment = parent.nil? ? resolved_env : parent._environment
492
+ @service = parent.nil? ? resolved_service : parent._service
439
493
  else
440
494
  @logging_http = transport
441
495
  @app_base_url = nil
496
+ # Wired: the parent has already resolved environment/service once —
497
+ # its values win over both the raw kwargs and re-resolution.
498
+ @environment = parent.nil? ? environment : parent._environment
499
+ @service = parent.nil? ? service : parent._service
442
500
  end
443
501
 
444
502
  # Discovery buffer is owned by this client; the loggers sub-client shares
445
503
  # it so discovery and explicit registration drain together.
446
504
  @buffer = LoggerRegistrationBuffer.new
447
- @loggers = LoggersClient.new(@logging_http, buffer: @buffer)
505
+ @loggers = LoggersClient.new(@logging_http, buffer: @buffer, streaming: @streaming)
448
506
  @log_groups = LogGroupsClient.new(@logging_http)
449
507
 
450
508
  # Live-surface state.
@@ -542,9 +600,13 @@ module Smplkit
542
600
  "(logging: #{@logging_http&.config&.host}): #{e.class}: #{e.message}")
543
601
  end
544
602
 
545
- # 7. Register WebSocket event handlers for real-time level updates
546
- @ws_manager = ensure_ws
547
- ws_handlers.each { |event, handler| @ws_manager.on(event, &handler) }
603
+ # 7. Register WebSocket event handlers for real-time level updates.
604
+ # In stateless mode (+streaming: false+) no socket is ever created —
605
+ # level changes then arrive only via +refresh+.
606
+ if @streaming
607
+ @ws_manager = ensure_ws
608
+ ws_handlers.each { |event, handler| @ws_manager.on(event, &handler) }
609
+ end
548
610
 
549
611
  @connected = true
550
612
  self
@@ -15,10 +15,37 @@ module Smplkit
15
15
  #
16
16
  # @api private
17
17
  module Transport
18
- SDK_OWNED_HEADERS = %w[authorization content-type user-agent].freeze
18
+ # Headers the SDK always owns — +extra_headers+ cannot override these
19
+ # (authorization carries the credential; content-type is set per request
20
+ # by the generated client). User-Agent is deliberately NOT in this list:
21
+ # a caller-supplied User-Agent wins over the SDK default.
22
+ SDK_OWNED_HEADERS = %w[authorization content-type].freeze
19
23
 
20
24
  module_function
21
25
 
26
+ # Whether an extra-headers hash carries a caller-supplied User-Agent,
27
+ # under any representation the surface accepts — string keys in any
28
+ # casing ("User-Agent", "user-agent", …) and symbol keys (:"user-agent").
29
+ def user_agent_supplied?(extra_headers)
30
+ (extra_headers || {}).keys.any? { |k| k.to_s.casecmp("user-agent").zero? }
31
+ end
32
+
33
+ # Stamp the default headers onto a generated +ApiClient+.
34
+ #
35
+ # User-Agent precedence: caller-supplied via +extra_headers+ (any casing
36
+ # or symbol representation) > the SDK default (+Smplkit.user_agent+,
37
+ # +smplkit-sdk-ruby/<version>+) > the generated stack's own default
38
+ # (+OpenAPI-Generator/x.y.z/ruby+, removed here so exactly one
39
+ # User-Agent representation remains). Other +extra_headers+ entries are
40
+ # applied as-is unless SDK-owned.
41
+ def apply_headers(client, extra_headers)
42
+ client.default_headers.delete("User-Agent")
43
+ client.default_headers["User-Agent"] = Smplkit.user_agent unless user_agent_supplied?(extra_headers)
44
+ (extra_headers || {}).each do |k, v|
45
+ client.default_headers[k] = v unless SDK_OWNED_HEADERS.include?(k.to_s.downcase)
46
+ end
47
+ end
48
+
22
49
  # Project the runtime +ResolvedConfig+ down to the transport subset.
23
50
  #
24
51
  # The top-level client's resolved config is a superset of what the
@@ -92,11 +119,8 @@ module Smplkit
92
119
  configuration.debugging = cfg.debug
93
120
  HttpPool.configure(configuration)
94
121
  generated_module::ApiClient.new(configuration).tap do |client|
95
- client.default_headers["User-Agent"] = "smplkit-ruby-sdk/#{Smplkit::VERSION}"
96
122
  client.default_headers["Accept"] = accept if accept
97
- (cfg.extra_headers || {}).each do |k, v|
98
- client.default_headers[k] = v unless SDK_OWNED_HEADERS.include?(k.downcase)
99
- end
123
+ apply_headers(client, cfg.extra_headers)
100
124
  end
101
125
  end
102
126
  end
@@ -1,5 +1,41 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Smplkit
4
+ # Local-development fallback only. The canonical version of a published gem
5
+ # is stamped into the gem's build-time metadata from the release tag by the
6
+ # gemspec (see +smplkit.gemspec+); +Smplkit.gem_version+ reads that metadata
7
+ # at runtime. This constant is used when the SDK runs from a source
8
+ # checkout that has no installed gem to consult.
4
9
  VERSION = "0.0.0"
10
+
11
+ # The version of the smplkit gem actually loaded, read from RubyGems
12
+ # package metadata.
13
+ #
14
+ # The release workflow never rewrites +VERSION+ above — the published
15
+ # version exists only in the git tag and, via the gemspec's build-time
16
+ # derivation, in the built gem's serialized specification. An installed gem
17
+ # therefore reports the real release version through +Gem.loaded_specs+,
18
+ # while a plain source checkout falls back to +VERSION+. The +smplkit-sdk+
19
+ # name is the ADR-046 §2.1 fallback gem name.
20
+ #
21
+ # @api private
22
+ # @return [String] the loaded gem's version, or +VERSION+ when the SDK is
23
+ # not running from an installed gem.
24
+ def self.gem_version
25
+ spec = Gem.loaded_specs["smplkit"] || Gem.loaded_specs["smplkit-sdk"]
26
+ spec ? spec.version.to_s : VERSION
27
+ end
28
+
29
+ # The default User-Agent stamped on every outbound request (HTTP and the
30
+ # WebSocket handshake) when the caller has not supplied their own.
31
+ #
32
+ # The platform sits behind a WAF that rejects requests carrying no
33
+ # User-Agent, and an SDK-identifying value keeps support/telemetry able to
34
+ # attribute traffic to an SDK and version in access logs.
35
+ #
36
+ # @api private
37
+ # @return [String] +smplkit-sdk-ruby/<version>+
38
+ def self.user_agent
39
+ "smplkit-sdk-ruby/#{gem_version}"
40
+ end
5
41
  end
data/lib/smplkit/ws.rb CHANGED
@@ -34,7 +34,10 @@ module Smplkit
34
34
  class SharedWebSocket
35
35
  BACKOFF_SCHEDULE = [1, 2, 4, 8, 16, 32, 60].freeze
36
36
 
37
- USER_AGENT = "smplkit-ruby-sdk/#{Smplkit::VERSION}".freeze
37
+ # Sent on the WebSocket upgrade request — the platform WAF rejects
38
+ # handshakes that carry no User-Agent. There is no caller-supplied
39
+ # header surface on the WebSocket, so the SDK default always applies.
40
+ USER_AGENT = Smplkit.user_agent.freeze
38
41
 
39
42
  def initialize(app_base_url:, api_key:, metrics: nil)
40
43
  @app_base_url = app_base_url
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smplkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.129
4
+ version: 3.0.132
5
5
  platform: ruby
6
6
  authors:
7
7
  - Smpl Solutions LLC