Class: Liteguard::Client

Inherits:
Object
  • Object
show all
Defined in:
lib/liteguard/client.rb

Overview

Core Liteguard SDK client.

This class is the primary Ruby SDK entrypoint.

Defined Under Namespace

Classes: GuardBundle

Constant Summary collapse

DEFAULT_BACKEND_URL =
"https://api.liteguard.io"
DEFAULT_REFRESH_RATE =
60
DEFAULT_FLUSH_RATE =
60
DEFAULT_FLUSH_SIZE =
500
DEFAULT_HTTP_TIMEOUT =
4
DEFAULT_FLUSH_BUFFER_MULTIPLIER =
4
PUBLIC_BUNDLE_KEY =
"".freeze

Instance Method Summary collapse

Constructor Details

#initialize(project_client_token, opts = {}) ⇒ void

Create a client instance.

The client remains idle until #start is called.

Parameters:

  • project_client_token (String)

    project client token from the Liteguard control plane

  • opts (Hash) (defaults to: {})

    initialization options

Options Hash (opts):

  • :environment (String, nil)

    environment slug to send with API requests

  • :fallback (Boolean)

    result returned when a bundle is not yet ready

  • :refresh_rate_seconds (Integer)

    minimum refresh interval for guard bundles

  • :flush_rate_seconds (Integer)

    telemetry flush interval

  • :flush_size (Integer)

    number of signals buffered before an eager flush

  • :http_timeout_seconds (Integer)

    connect and read timeout for API calls

  • :flush_buffer_multiplier (Integer)

    multiplier used to cap the in-memory signal queue size

  • :backend_url (String)

    base Liteguard API URL

  • :quiet (Boolean)

    suppress warning output when ‘true`

  • :disable_measurement (Boolean)

    disable telemetry measurements



54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/liteguard/client.rb', line 54

def initialize(project_client_token, opts = {})
  @project_client_token = project_client_token
  @environment = opts.fetch(:environment, "").to_s
  @fallback = opts.fetch(:fallback, false)
  @refresh_rate = normalize_positive_option(opts[:refresh_rate_seconds], DEFAULT_REFRESH_RATE)
  @flush_rate = normalize_positive_option(opts[:flush_rate_seconds], DEFAULT_FLUSH_RATE)
  @flush_size = normalize_positive_option(opts[:flush_size], DEFAULT_FLUSH_SIZE)
  @http_timeout_seconds = normalize_positive_option(opts[:http_timeout_seconds], DEFAULT_HTTP_TIMEOUT)
  @flush_buffer_multiplier = normalize_positive_option(
    opts[:flush_buffer_multiplier],
    DEFAULT_FLUSH_BUFFER_MULTIPLIER
  )
  @backend_url = opts.fetch(:backend_url, DEFAULT_BACKEND_URL).to_s.chomp("/")
  @backend_url = DEFAULT_BACKEND_URL if @backend_url.empty?
  @quiet = opts.fetch(:quiet, true)
  @disable_measurement = opts.fetch(:disable_measurement, false)

  @monitor = Monitor.new
  @refresh_cond = @monitor.new_cond
  @flush_cond = @monitor.new_cond
  @stopped = false
  @refresh_wakeup_requested = false
  @flush_requested = false
  @async_flush_scheduled = false
  @current_refresh_rate = @refresh_rate

  @bundles = { PUBLIC_BUNDLE_KEY => create_empty_bundle(PUBLIC_BUNDLE_KEY, nil) }
  @default_scope = Scope.new(self, {}, PUBLIC_BUNDLE_KEY, nil)
  @active_scope_key = "liteguard_active_scope_#{object_id}"

  @signal_buffer = []
  @dropped_signals_pending = 0
  @pending_unadopted_guards = {}
  @rate_limit_state = {}
end

Instance Method Details

#active_scopeScope

Return the active scope for the current thread.

This method serves two roles:

  1. Developer convenience for retrieving the current request scope.

  2. Required infrastructure for auto-instrumentation. Instrumented third-party code calls this method to discover the evaluation context established by application-level middleware via #with_scope.

Returns:

  • (Scope)

    the active scope, or the default scope when none is bound



182
183
184
185
186
187
# File 'lib/liteguard/client.rb', line 182

def active_scope
  scope = Thread.current[@active_scope_key]
  return scope if scope.is_a?(Scope) && scope.belongs_to?(self)

  @default_scope
end

#bind_protected_context_to_scope(scope, protected_context) ⇒ Scope

Derive a scope bound to the bundle for the given protected context.

Parameters:

Returns:

  • (Scope)

    a derived scope



262
263
264
265
266
267
# File 'lib/liteguard/client.rb', line 262

def bind_protected_context_to_scope(scope, protected_context)
  resolve_scope(scope)
  normalized = normalize_protected_context(protected_context)
  bundle_key = ensure_bundle_for_protected_context(normalized)
  Scope.new(self, scope.properties, bundle_key, normalized)
end

#buffer_signal(guard_name, result, props, kind:, measurement: nil, parent_signal_id_override: nil) ⇒ Signal

Buffer a signal for asynchronous upload.

Parameters:

  • guard_name (String)

    guard name associated with the signal

  • result (Boolean)

    guard result associated with the signal

  • props (Hash)

    evaluation properties snapshot

  • kind (String)

    signal kind such as ‘guard_check` or `guard_execution`

  • measurement (SignalPerformance, nil) (defaults to: nil)

    optional measurement payload

  • parent_signal_id_override (String, nil) (defaults to: nil)

    explicit parent signal ID

Returns:

  • (Signal)

    buffered signal instance



601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
# File 'lib/liteguard/client.rb', line 601

def buffer_signal(guard_name, result, props, kind:, measurement: nil, parent_signal_id_override: nil)
   = (parent_signal_id_override)
  signal = Signal.new(
    guard_name: guard_name,
    result: result,
    properties: props.dup,
    timestamp_ms: (Time.now.to_f * 1000).to_i,
    trace: nil,
    signal_id: [:signal_id],
    execution_id: [:execution_id],
    parent_signal_id: [:parent_signal_id],
    sequence_number: [:sequence_number],
    callsite_id: capture_callsite_id,
    kind: kind,
    dropped_signals_since_last: take_dropped_signals,
    measurement: measurement
  )
  should_flush = false
  @monitor.synchronize do
    if @signal_buffer.size >= max_buffer_size
      @signal_buffer.shift
      @dropped_signals_pending += 1
    end
    @signal_buffer << signal
    should_flush = @signal_buffer.size >= @flush_size
  end
  schedule_async_flush if should_flush
  signal
end

#bundle_for_scope(scope) ⇒ GuardBundle

Look up the bundle associated with a scope.

Parameters:

  • scope (Scope)

    scope whose bundle should be resolved

Returns:

  • (GuardBundle)

    resolved bundle, or the public bundle fallback



744
745
746
747
748
# File 'lib/liteguard/client.rb', line 744

def bundle_for_scope(scope)
  @monitor.synchronize do
    @bundles[scope.bundle_key] || @bundles[PUBLIC_BUNDLE_KEY]
  end
end

#capture_callsite_idString

Capture a stable callsite identifier outside of Liteguard internals.

Returns:

  • (String)

    ‘path:line` identifier, or `unknown`



1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
# File 'lib/liteguard/client.rb', line 1280

def capture_callsite_id
  frame = caller_locations.find do |location|
    path = location.absolute_path.to_s.tr("\\", "/")
    !path.end_with?("/sdk/ruby/lib/liteguard/client.rb") &&
      !path.end_with?("/sdk/ruby/lib/liteguard/scope.rb") &&
      !path.end_with?("/sdk/ruby/lib/liteguard.rb")
  end
  return "unknown" unless frame

  "#{frame.absolute_path || frame.path}:#{frame.lineno}"
end

#capture_guard_check_measurementSignalPerformance

Capture process metrics for a guard-check signal.

Returns:



1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
# File 'lib/liteguard/client.rb', line 1114

def capture_guard_check_measurement
  gc_stats = GC.stat
  SignalPerformance.new(
    guard_check: GuardCheckPerformance.new(
      rss_bytes: nil,
      heap_used_bytes: gc_slot_bytes(gc_stats, :heap_live_slots),
      heap_total_bytes: gc_slot_bytes(gc_stats, :heap_available_slots),
      cpu_time_ns: Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID, :nanosecond),
      gc_count: gc_stat_value(gc_stats, :count),
      thread_count: Thread.list.length
    ),
    guard_execution: nil
  )
end

#capture_guard_execution_measurement(started_at, completed, error = nil) ⇒ SignalPerformance

Capture process metrics for a guard-execution signal.

Parameters:

  • started_at (Integer)

    monotonic start time in nanoseconds

  • completed (Boolean)

    whether the guarded block completed normally

  • error (Exception, nil) (defaults to: nil)

    exception raised by the guarded block

Returns:



1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
# File 'lib/liteguard/client.rb', line 1135

def capture_guard_execution_measurement(started_at, completed, error = nil)
  gc_stats = GC.stat
  SignalPerformance.new(
    guard_check: nil,
    guard_execution: GuardExecutionPerformance.new(
      duration_ns: Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond) - started_at,
      rss_end_bytes: nil,
      heap_used_end_bytes: gc_slot_bytes(gc_stats, :heap_live_slots),
      heap_total_end_bytes: gc_slot_bytes(gc_stats, :heap_available_slots),
      cpu_time_end_ns: Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID, :nanosecond),
      gc_count_end: gc_stat_value(gc_stats, :count),
      thread_count_end: Thread.list.length,
      completed: completed,
      error_class: error&.class&.name
    )
  )
end

#check_rate_limit(name, limit_per_minute, rate_limit_properties, props) ⇒ Boolean

Consume a rate-limit slot for an open guard evaluation.

Parameters:

  • name (String)

    guard name

  • limit_per_minute (Integer)

    allowed evaluations per minute

  • rate_limit_properties (Array<String>)

    properties contributing to the bucket key

  • props (Hash)

    evaluation properties

Returns:

  • (Boolean)

    ‘true` when the evaluation is within the limit



973
974
975
976
977
978
979
980
981
982
983
984
985
986
# File 'lib/liteguard/client.rb', line 973

def check_rate_limit(name, limit_per_minute, rate_limit_properties, props)
  now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  key = rate_limit_bucket_key(name, rate_limit_properties, props)
  @monitor.synchronize do
    entry = @rate_limit_state[key] || { window_start: now, count: 0 }
    entry = { window_start: now, count: 0 } if now - entry[:window_start] >= 60.0
    if entry[:count] >= limit_per_minute
      @rate_limit_state[key] = entry
      return false
    end
    @rate_limit_state[key] = { window_start: entry[:window_start], count: entry[:count] + 1 }
  end
  true
end

#copy_protected_context(protected_context) ⇒ ProtectedContext?

Return a defensive copy of a protected context.

Parameters:

Returns:



1089
1090
1091
1092
1093
1094
1095
1096
# File 'lib/liteguard/client.rb', line 1089

def copy_protected_context(protected_context)
  return nil if protected_context.nil?

  ProtectedContext.new(
    properties: protected_context.properties.dup,
    signature: protected_context.signature.dup
  )
end

#create_empty_bundle(bundle_key, protected_context) ⇒ GuardBundle

Create a placeholder bundle entry before the first fetch completes.

Parameters:

  • bundle_key (String)

    cache key for the bundle

  • protected_context (ProtectedContext, nil)

    protected context backing the bundle

Returns:



729
730
731
732
733
734
735
736
737
738
# File 'lib/liteguard/client.rb', line 729

def create_empty_bundle(bundle_key, protected_context)
  GuardBundle.new(
    key: bundle_key,
    guards: {},
    ready: false,
    etag: "",
    protected_context: protected_context ? copy_protected_context(protected_context) : nil,
    refresh_rate_seconds: @refresh_rate
  )
end

#create_scope(properties = {}) ⇒ Scope

Create an immutable request scope for explicit evaluation.

Parameters:

  • properties (Hash) (defaults to: {})

    request-scoped properties to attach

Returns:

  • (Scope)

    a new immutable scope



168
169
170
# File 'lib/liteguard/client.rb', line 168

def create_scope(properties = {})
  Scope.new(self, normalize_properties(properties), PUBLIC_BUNDLE_KEY, nil)
end

#current_refresh_rate_for_testingInteger

Return the current refresh rate for tests.

Returns:

  • (Integer)

    current refresh interval in seconds



1380
1381
1382
# File 'lib/liteguard/client.rb', line 1380

def current_refresh_rate_for_testing
  @monitor.synchronize { @current_refresh_rate }
end

#ensure_bundle_for_protected_context(protected_context) ⇒ String

Ensure a bundle exists for the given protected context and fetch it if needed.

Parameters:

Returns:

  • (String)

    cache key for the bundle



755
756
757
758
759
760
761
762
763
764
765
# File 'lib/liteguard/client.rb', line 755

def ensure_bundle_for_protected_context(protected_context)
  bundle_key = protected_context_cache_key(protected_context)
  ready = @monitor.synchronize do
    @bundles[bundle_key] ||= create_empty_bundle(bundle_key, protected_context)
    @bundles[bundle_key].ready
  end
  return bundle_key if ready

  fetch_guards_for_bundle(bundle_key)
  bundle_key
end

#ensure_public_bundle_readyvoid

This method returns an undefined value.

Ensure the public bundle is available before returning.



272
273
274
275
276
277
# File 'lib/liteguard/client.rb', line 272

def ensure_public_bundle_ready
  bundle = @monitor.synchronize { @bundles[PUBLIC_BUNDLE_KEY] }
  return if bundle&.ready

  fetch_guards_for_bundle(PUBLIC_BUNDLE_KEY)
end

#estimate_checks_per_minute(first_seen_ms, last_seen_ms, check_count) ⇒ Object



1270
1271
1272
1273
1274
1275
# File 'lib/liteguard/client.rb', line 1270

def estimate_checks_per_minute(first_seen_ms, last_seen_ms, check_count)
  return 0.0 if check_count.to_i <= 0

  window_ms = [last_seen_ms - first_seen_ms, 1000].max
  (check_count * 60_000.0) / window_ms
end

#evaluate(name, options = nil, **legacy_options) ⇒ GuardDecision

Evaluate a guard and return a GuardDecision with full reasoning.

Parameters:

  • name (String)

    guard name to evaluate

  • options (Hash, nil) (defaults to: nil)

    optional per-call overrides

Returns:



330
331
332
# File 'lib/liteguard/client.rb', line 330

def evaluate(name, options = nil, **legacy_options)
  evaluate_in_scope(active_scope, name, options, **legacy_options)
end

#evaluate_guard_in_scope(scope, name, options, emit_signal:) ⇒ Hash

Evaluate a guard within a resolved scope and optionally emit telemetry.

Parameters:

  • scope (Scope)

    scope to evaluate against

  • name (String)

    guard name to evaluate

  • options (Hash)

    normalized evaluation options

  • emit_signal (Boolean)

    whether to buffer a ‘guard_check` signal

Returns:

  • (Hash)

    evaluation result metadata including ‘:result`, `:guard`, `:props`, and `:signal`



457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
# File 'lib/liteguard/client.rb', line 457

def evaluate_guard_in_scope(scope, name, options, emit_signal:)
  resolved_scope = resolve_scope(scope)
  bundle = bundle_for_scope(resolved_scope)
  effective_fallback = options[:fallback].nil? ? @fallback : options[:fallback]
  return { result: effective_fallback, guard: nil, props: nil, signal: nil } unless bundle.ready

  guard = bundle.guards[name]
  if guard.nil?
    record_unadopted_guard(name)
    return { result: true, guard: nil, props: nil, signal: nil }
  end
  unless guard.adopted
    record_unadopted_guard(name)
    return { result: true, guard: guard, props: nil, signal: nil }
  end

  props = resolved_scope.properties
  if options[:properties]
    props = props.merge(options[:properties])
  end

  result = Evaluation.evaluate_guard(guard, props)
  if result && guard.rate_limit_per_minute.to_i > 0
    result = if emit_signal
      check_rate_limit(name, guard.rate_limit_per_minute, guard.rate_limit_properties, props)
    else
      would_pass_rate_limit(name, guard.rate_limit_per_minute, guard.rate_limit_properties, props)
    end
  end

  signal = nil
  if emit_signal
    signal = buffer_signal(
      name,
      result,
      props,
      kind: "guard_check",
      measurement: measurement_enabled?(guard, options) ? capture_guard_check_measurement : nil
    )
  end

  { result: result, guard: guard, props: props, signal: signal }
end

#evaluate_in_scope(scope, name, options = nil, **legacy_options) ⇒ GuardDecision

Evaluate a guard in the provided scope and return a GuardDecision.

Parameters:

  • scope (Scope)

    scope to evaluate against

  • name (String)

    guard name to evaluate

  • options (Hash, nil) (defaults to: nil)

    optional per-call overrides

Returns:



340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
# File 'lib/liteguard/client.rb', line 340

def evaluate_in_scope(scope, name, options = nil, **legacy_options)
  options = normalize_is_open_options(options, legacy_options)
  resolved_scope = resolve_scope(scope)
  bundle = bundle_for_scope(resolved_scope)
  effective_fallback = options[:fallback].nil? ? @fallback : options[:fallback]

  unless bundle.ready
    return GuardDecision.new(
      name: name.to_s, is_open: effective_fallback, adopted: false,
      reason: "fallback", matched_rule_index: nil, properties: {}
    )
  end

  guard = bundle.guards[name.to_s]
  if guard.nil?
    record_unadopted_guard(name.to_s)
    return GuardDecision.new(
      name: name.to_s, is_open: true, adopted: false,
      reason: "unadopted", matched_rule_index: nil, properties: {}
    )
  end
  unless guard.adopted
    record_unadopted_guard(name.to_s)
    return GuardDecision.new(
      name: name.to_s, is_open: true, adopted: false,
      reason: "unadopted", matched_rule_index: nil, properties: {}
    )
  end

  props = resolved_scope.properties
  props = props.merge(options[:properties]) if options[:properties]

  detailed = Evaluation.evaluate_guard_detailed(guard, props)
  result = detailed.result
  if result && guard.rate_limit_per_minute.to_i > 0
    result = check_rate_limit(name.to_s, guard.rate_limit_per_minute, guard.rate_limit_properties, props)
  end

  reason = detailed.matched_rule_index >= 0 ? "matched_rule" : "default_value"
  matched_idx = detailed.matched_rule_index >= 0 ? detailed.matched_rule_index : nil

  buffer_signal(
    name.to_s, result, props,
    kind: "guard_check",
    measurement: measurement_enabled?(guard, options) ? capture_guard_check_measurement : nil
  )

  GuardDecision.new(
    name: name.to_s, is_open: result, adopted: guard.adopted,
    reason: reason, matched_rule_index: matched_idx, properties: props.dup
  )
end

#execute_if_open(name, options = nil, **legacy_options) { ... } ⇒ Object?

Evaluate a guard and execute the block only when it resolves open.

Parameters:

  • name (String)

    guard name to evaluate

  • options (Hash, nil) (defaults to: nil)

    optional per-call overrides

Yields:

  • Runs only when the guard resolves open

Returns:

  • (Object, nil)

    the block return value, or ‘nil` when the guard is closed

Raises:

  • (ArgumentError)

    if no block is given



401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
# File 'lib/liteguard/client.rb', line 401

def execute_if_open(name, options = nil, **legacy_options)
  raise ArgumentError, "execute_if_open requires a block" unless block_given?

  with_execution do
    normalized_options = normalize_is_open_options(options, legacy_options)
    evaluation = evaluate_guard_in_scope(active_scope, name.to_s, normalized_options, emit_signal: true)
    return nil unless evaluation[:result]
    return yield if evaluation[:signal].nil?

    measurement_enabled = measurement_enabled?(evaluation[:guard], normalized_options)
    started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
    begin
      value = yield
      buffer_signal(
        name.to_s,
        true,
        evaluation[:props],
        kind: "guard_execution",
        measurement: measurement_enabled ? capture_guard_execution_measurement(started_at, true) : nil,
        parent_signal_id_override: evaluation[:signal].signal_id
      )
      value
    rescue Exception => e # rubocop:disable Lint/RescueException
      buffer_signal(
        name.to_s,
        true,
        evaluation[:props],
        kind: "guard_execution",
        measurement: measurement_enabled ? capture_guard_execution_measurement(started_at, false, e) : nil,
        parent_signal_id_override: evaluation[:signal].signal_id
      )
      raise
    end
  end
end

#execute_if_open_in_scope(scope, name, options = nil, **legacy_options) { ... } ⇒ Object?

Scope-aware wrapper for #execute_if_open.

Parameters:

  • scope (Scope)

    scope to evaluate against

  • name (String)

    guard name to evaluate

  • options (Hash, nil) (defaults to: nil)

    optional per-call overrides

Yields:

  • Runs only when the guard resolves open

Returns:

  • (Object, nil)

    the block return value, or ‘nil` when the guard is closed



445
446
447
# File 'lib/liteguard/client.rb', line 445

def execute_if_open_in_scope(scope, name, options = nil, **legacy_options, &block)
  with_scope(scope) { execute_if_open(name, options, **legacy_options, &block) }
end

#fetch_guards_for_bundle(bundle_key) ⇒ void

This method returns an undefined value.

Fetch guard data for a bundle from the Liteguard backend.

Parameters:

  • bundle_key (String)

    bundle cache key to refresh



796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
# File 'lib/liteguard/client.rb', line 796

def fetch_guards_for_bundle(bundle_key)
  bundle = @monitor.synchronize { @bundles[bundle_key] ||= create_empty_bundle(bundle_key, nil) }
  protected_context = bundle.protected_context ? copy_protected_context(bundle.protected_context) : nil

  payload = {
    projectClientToken: @project_client_token,
    environment: @environment
  }
  if protected_context
    payload[:protectedContext] = {
      properties: protected_context.properties,
      signature: protected_context.signature
    }
  end

  uri = URI("#{@backend_url}/api/v1/guards")
  req = Net::HTTP::Post.new(uri)
  req["Authorization"] = "Bearer #{@project_client_token}"
  req["Content-Type"] = "application/json"
  req["X-Liteguard-Environment"] = @environment unless @environment.empty?
  req["If-None-Match"] = bundle.etag unless bundle.etag.to_s.empty?
  req.body = JSON.generate(payload)

  response = Net::HTTP.start(
    uri.host,
    uri.port,
    use_ssl: uri.scheme == "https",
    open_timeout: @http_timeout_seconds,
    read_timeout: @http_timeout_seconds
  ) { |http| http.request(req) }

  return if response.code == "304"
  return log("[liteguard] guard fetch returned #{response.code}") unless response.code == "200"

  body = JSON.parse(response.body)
  server_refresh_rate = body["refreshRateSeconds"].to_i
  effective_refresh_rate = if server_refresh_rate.positive?
    [@refresh_rate, server_refresh_rate].max
  else
    @refresh_rate
  end
  guards = (body["guards"] || []).map { |raw_guard| parse_guard(raw_guard) }
  refresh_rate_changed = false
  @monitor.synchronize do
    previous_refresh_rate = @current_refresh_rate
    @bundles[bundle_key] = GuardBundle.new(
      key: bundle_key,
      guards: guards.each_with_object({}) { |guard, acc| acc[guard.name] = guard },
      ready: true,
      etag: body["etag"] || "",
      protected_context: protected_context,
      refresh_rate_seconds: effective_refresh_rate
    )
    recompute_refresh_interval_locked
    refresh_rate_changed = @current_refresh_rate != previous_refresh_rate
  end
  request_refresh_reschedule if refresh_rate_changed
rescue => e
  log "[liteguard] guard fetch error: #{e}"
end

#finalize_unadopted_observation(observation) ⇒ Object



1244
1245
1246
1247
1248
1249
1250
1251
1252
# File 'lib/liteguard/client.rb', line 1244

def finalize_unadopted_observation(observation)
  observation.with(
    estimated_checks_per_minute: estimate_checks_per_minute(
      observation.first_seen_ms,
      observation.last_seen_ms,
      observation.check_count
    )
  )
end

#flush_loopvoid

This method returns an undefined value.

Background loop that periodically flushes buffered telemetry.



891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
# File 'lib/liteguard/client.rb', line 891

def flush_loop
  @monitor.synchronize do
    until @stopped
      @flush_cond.wait(@flush_rate)
      break if @stopped
      @flush_requested = false
      @monitor.mon_exit
      begin
        flush_signals
      ensure
        @monitor.mon_enter
      end
    end
  end
end

#flush_signal_batch(batch) ⇒ void

This method returns an undefined value.

Upload a batch of buffered signals.

Failed uploads are returned to the in-memory queue subject to buffer limits.

Parameters:

  • batch (Array<Signal>)

    signal batch to upload



531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
# File 'lib/liteguard/client.rb', line 531

def flush_signal_batch(batch)
  payload = JSON.generate(
    projectClientToken: @project_client_token,
    environment: @environment,
    signals: batch.map do |signal|
      {
        guardName: signal.guard_name,
        result: signal.result,
        properties: signal.properties,
        timestampMs: signal.timestamp_ms,
        signalId: signal.signal_id,
        executionId: signal.execution_id,
        sequenceNumber: signal.sequence_number,
        callsiteId: signal.callsite_id,
        kind: signal.kind,
        droppedSignalsSinceLast: signal.dropped_signals_since_last,
        **(signal.parent_signal_id ? { parentSignalId: signal.parent_signal_id } : {}),
        **(signal.measurement ? { measurement: signal_measurement_payload(signal.measurement) } : {})
      }
    end
  )
  post_json("/api/v1/signals", payload)
rescue => e
  log "[liteguard] signal flush failed: #{e}"
  @monitor.synchronize do
    @signal_buffer.unshift(*batch)
    max_buf = max_buffer_size
    if @signal_buffer.size > max_buf
      @dropped_signals_pending += @signal_buffer.size - max_buf
      @signal_buffer.pop(@signal_buffer.size - max_buf)
    end
  end
end

#flush_signalsvoid

This method returns an undefined value.

Flush all buffered telemetry and unadopted guard observations.



508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
# File 'lib/liteguard/client.rb', line 508

def flush_signals
  batch, unadopted_observations = @monitor.synchronize do
    buffered = @signal_buffer.dup
    @signal_buffer.clear
    observations = @pending_unadopted_guards.keys.sort.map do |name|
      finalize_unadopted_observation(@pending_unadopted_guards[name])
    end
    @pending_unadopted_guards.clear
    [buffered, observations]
  end
  return if batch.empty? && unadopted_observations.empty?

  flush_signal_batch(batch) unless batch.empty?
  flush_unadopted_guards(unadopted_observations) unless unadopted_observations.empty?
end

#flush_unadopted_guards(unadopted_observations) ⇒ void

This method returns an undefined value.

Upload unadopted guard observations discovered during evaluation.

Parameters:



569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
# File 'lib/liteguard/client.rb', line 569

def flush_unadopted_guards(unadopted_observations)
  payload = JSON.generate(
    projectClientToken: @project_client_token,
    environment: @environment,
    observations: unadopted_observations.map do |observation|
      {
        guardName: observation.guard_name,
        firstSeenMs: observation.first_seen_ms,
        lastSeenMs: observation.last_seen_ms,
        checkCount: observation.check_count,
        estimatedChecksPerMinute: observation.estimated_checks_per_minute,
      }
    end
  )
  post_json("/api/v1/unadopted-guards", payload)
rescue => e
  log "[liteguard] unadopted guard flush failed: #{e}"
  @monitor.synchronize do
    unadopted_observations.each { |observation| merge_pending_unadopted_observation(observation) }
  end
end

#gc_slot_bytes(stats, key) ⇒ Integer?

Convert a GC slot count into bytes using the current Ruby slot size.

Parameters:

  • stats (Hash)

    GC statistics hash

  • key (Symbol)

    desired slot-count stat key

Returns:

  • (Integer, nil)

    byte count or ‘nil` when unavailable



1168
1169
1170
1171
1172
1173
1174
# File 'lib/liteguard/client.rb', line 1168

def gc_slot_bytes(stats, key)
  slots = gc_stat_value(stats, key)
  slot_size = ruby_internal_constant(:RVALUE_SIZE)
  return nil if slots.nil? || slot_size.nil?

  slots * slot_size
end

#gc_stat_value(stats, key) ⇒ Integer?

Safely extract an integer GC stat from the current runtime.

Parameters:

  • stats (Hash)

    GC statistics hash

  • key (Symbol)

    desired stat key

Returns:

  • (Integer, nil)

    integer stat value or ‘nil`



1158
1159
1160
1161
# File 'lib/liteguard/client.rb', line 1158

def gc_stat_value(stats, key)
  value = stats[key]
  value.is_a?(Numeric) ? value.to_i : nil
end

#is_open(name, options = nil, **legacy_options) ⇒ Boolean

Evaluate a guard in the active scope and emit telemetry.

Parameters:

  • name (String)

    guard name to evaluate

  • options (Hash, nil) (defaults to: nil)

    optional per-call overrides

Returns:

  • (Boolean)

    ‘true` when the guard resolves open



288
289
290
291
# File 'lib/liteguard/client.rb', line 288

def is_open(name, options = nil, **legacy_options)
  options = normalize_is_open_options(options, legacy_options)
  evaluate_guard_in_scope(active_scope, name.to_s, options, emit_signal: true)[:result]
end

#is_open_in_scope(scope, name, options = nil, **legacy_options) ⇒ Boolean

Evaluate a guard in the provided scope and emit telemetry.

Parameters:

  • scope (Scope)

    scope to evaluate against

  • name (String)

    guard name to evaluate

  • options (Hash, nil) (defaults to: nil)

    optional per-call overrides

Returns:

  • (Boolean)

    ‘true` when the guard resolves open



299
300
301
302
# File 'lib/liteguard/client.rb', line 299

def is_open_in_scope(scope, name, options = nil, **legacy_options)
  options = normalize_is_open_options(options, legacy_options)
  evaluate_guard_in_scope(scope, name.to_s, options, emit_signal: true)[:result]
end

#known_bundle_count_for_testingInteger

Return the number of cached bundles for tests.

Returns:

  • (Integer)

    number of known bundles



1373
1374
1375
# File 'lib/liteguard/client.rb', line 1373

def known_bundle_count_for_testing
  @monitor.synchronize { @bundles.size }
end

#log(message) ⇒ void

This method returns an undefined value.

Emit a warning message unless quiet mode is enabled.

Parameters:

  • message (String)

    log message



1296
1297
1298
# File 'lib/liteguard/client.rb', line 1296

def log(message)
  warn message unless @quiet
end

#max_buffer_sizeInteger

Return the maximum in-memory signal buffer size.

Returns:

  • (Integer)

    maximum signal count retained before dropping oldest



715
716
717
# File 'lib/liteguard/client.rb', line 715

def max_buffer_size
  @flush_size * @flush_buffer_multiplier
end

#measurement_enabled?(guard, options) ⇒ Boolean

Determine whether measurement capture is enabled for an evaluation.

Parameters:

  • guard (Guard, nil)

    guard being evaluated

  • options (Hash)

    normalized evaluation options

Returns:

  • (Boolean)

    ‘true` when measurements should be captured



1103
1104
1105
1106
1107
1108
1109
# File 'lib/liteguard/client.rb', line 1103

def measurement_enabled?(guard, options)
  return false if @disable_measurement
  return false if options[:disable_measurement]
  return false if guard&.disable_measurement

  true
end

#merge_pending_unadopted_observation(observation) ⇒ Object



1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
# File 'lib/liteguard/client.rb', line 1254

def merge_pending_unadopted_observation(observation)
  existing = @pending_unadopted_guards[observation.guard_name]
  if existing
    @pending_unadopted_guards[observation.guard_name] = existing.with(
      first_seen_ms: [existing.first_seen_ms, observation.first_seen_ms].min,
      last_seen_ms: [existing.last_seen_ms, observation.last_seen_ms].max,
      check_count: existing.check_count + observation.check_count,
      estimated_checks_per_minute: 0.0
    )
  else
    @pending_unadopted_guards[observation.guard_name] = observation.with(
      estimated_checks_per_minute: 0.0
    )
  end
end

#next_signal_idString

Generate a unique signal identifier.

Returns:

  • (String)

    unique signal ID



695
696
697
698
699
# File 'lib/liteguard/client.rb', line 695

def next_signal_id
  @signal_counter ||= 0
  @signal_counter += 1
  "#{Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond).to_s(16)}-#{@signal_counter.to_s(16)}"
end

#next_signal_metadata(parent_signal_id_override = nil) ⇒ Hash

Build correlation metadata for the next signal.

Parameters:

  • parent_signal_id_override (String, nil) (defaults to: nil)

    explicit parent signal ID

Returns:

  • (Hash)

    signal correlation metadata



669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
# File 'lib/liteguard/client.rb', line 669

def (parent_signal_id_override = nil)
  signal_id = next_signal_id
  state = Thread.current[:liteguard_execution_state]
  unless state
    return {
      signal_id: signal_id,
      execution_id: next_signal_id,
      parent_signal_id: nil,
      sequence_number: 1
    }
  end

  state[:sequence_number] += 1
  parent_signal_id = parent_signal_id_override || state[:last_signal_id]
  state[:last_signal_id] = signal_id
  {
    signal_id: signal_id,
    execution_id: state[:execution_id],
    parent_signal_id: parent_signal_id,
    sequence_number: state[:sequence_number]
  }
end

#normalize_is_open_options(options, legacy_options = {}) ⇒ Hash

Normalize guard-evaluation options into a canonical hash.

Parameters:

  • options (Hash, nil)

    primary options hash

  • legacy_options (Hash) (defaults to: {})

    keyword arguments merged on top

Returns:

  • (Hash)

    normalized option hash

Raises:

  • (ArgumentError)

    when options are invalid or contain unknown keys



1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
# File 'lib/liteguard/client.rb', line 1039

def normalize_is_open_options(options, legacy_options = {})
  raw = if options.nil?
    legacy_options
  elsif options.is_a?(Hash)
    options.merge(legacy_options)
  else
    raise ArgumentError, "is_open options must be a Hash"
  end

  unknown_keys = raw.keys.map(&:to_sym) - CHECK_OPTION_KEYS
  raise ArgumentError, "unknown is_open option(s): #{unknown_keys.join(', ')}" unless unknown_keys.empty?

  properties = raw[:properties] || raw["properties"]
  {
    properties: properties ? normalize_properties(properties) : nil,
    fallback: raw.key?(:fallback) ? raw[:fallback] : raw["fallback"],
    disable_measurement: raw.key?(:disable_measurement) ? raw[:disable_measurement] : raw["disable_measurement"] || false
  }
end

#normalize_positive_option(value, default) ⇒ Integer

Normalize a positive integer option, falling back to a default when the provided value is blank, zero, or negative.

Parameters:

  • value (Object)

    raw option value

  • default (Integer)

    default to use when normalization fails

Returns:

  • (Integer)

    normalized positive integer



1028
1029
1030
1031
# File 'lib/liteguard/client.rb', line 1028

def normalize_positive_option(value, default)
  normalized = value.nil? ? default : value.to_i
  normalized.positive? ? normalized : default
end

#normalize_properties(properties) ⇒ Hash

Normalize property keys to strings.

Parameters:

  • properties (Hash, nil)

    raw property hash

Returns:

  • (Hash)

    normalized property hash



1063
1064
1065
1066
1067
# File 'lib/liteguard/client.rb', line 1063

def normalize_properties(properties)
  return {} if properties.nil?

  properties.each_with_object({}) { |(key, value), acc| acc[key.to_s] = value }
end

#normalize_protected_context(protected_context) ⇒ ProtectedContext

Normalize a protected-context payload into a ‘ProtectedContext` object.

Parameters:

Returns:



1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
# File 'lib/liteguard/client.rb', line 1073

def normalize_protected_context(protected_context)
  raw = if protected_context.is_a?(ProtectedContext)
    { properties: protected_context.properties, signature: protected_context.signature }
  else
    protected_context
  end
  ProtectedContext.new(
    properties: normalize_properties(raw[:properties] || raw["properties"] || {}),
    signature: (raw[:signature] || raw["signature"]).to_s
  )
end

#parse_guard(raw) ⇒ Guard

Parse a guard payload returned by the backend.

Parameters:

  • raw (Hash)

    decoded guard payload

Returns:

  • (Guard)

    parsed guard record



939
940
941
942
943
944
945
946
947
948
949
# File 'lib/liteguard/client.rb', line 939

def parse_guard(raw)
  Guard.new(
    name: raw["name"],
    rules: (raw["rules"] || []).map { |rule| parse_rule(rule) },
    default_value: !!raw["defaultValue"],
    adopted: !!raw["adopted"],
    rate_limit_per_minute: (raw["rateLimitPerMinute"] || 0).to_i,
    rate_limit_properties: Array(raw["rateLimitProperties"]),
    disable_measurement: raw.key?("disableMeasurement") ? raw["disableMeasurement"] : nil
  )
end

#parse_rule(raw) ⇒ Rule

Parse a rule payload returned by the backend.

Parameters:

  • raw (Hash)

    decoded rule payload

Returns:

  • (Rule)

    parsed rule record



955
956
957
958
959
960
961
962
963
# File 'lib/liteguard/client.rb', line 955

def parse_rule(raw)
  Rule.new(
    property_name: raw["propertyName"],
    operator: raw["operator"].downcase.to_sym,
    values: raw["values"] || [],
    result: !!raw["result"],
    enabled: raw.fetch("enabled", true)
  )
end

#peek_is_open(name, options = nil, **legacy_options) ⇒ Boolean

Evaluate a guard in the active scope without emitting telemetry.

Parameters:

  • name (String)

    guard name to evaluate

  • options (Hash, nil) (defaults to: nil)

    optional per-call overrides

Returns:

  • (Boolean)

    ‘true` when the guard resolves open



309
310
311
312
# File 'lib/liteguard/client.rb', line 309

def peek_is_open(name, options = nil, **legacy_options)
  options = normalize_is_open_options(options, legacy_options)
  evaluate_guard_in_scope(active_scope, name.to_s, options, emit_signal: false)[:result]
end

#peek_is_open_in_scope(scope, name, options = nil, **legacy_options) ⇒ Boolean

Evaluate a guard in the provided scope without emitting telemetry.

Parameters:

  • scope (Scope)

    scope to evaluate against

  • name (String)

    guard name to evaluate

  • options (Hash, nil) (defaults to: nil)

    optional per-call overrides

Returns:

  • (Boolean)

    ‘true` when the guard resolves open



320
321
322
323
# File 'lib/liteguard/client.rb', line 320

def peek_is_open_in_scope(scope, name, options = nil, **legacy_options)
  options = normalize_is_open_options(options, legacy_options)
  evaluate_guard_in_scope(scope, name.to_s, options, emit_signal: false)[:result]
end

#pending_unadopted_guards_for_testingArray<UnadoptedGuardObservation>

Return pending unadopted-guard observations for tests.

Returns:



1362
1363
1364
1365
1366
1367
1368
# File 'lib/liteguard/client.rb', line 1362

def pending_unadopted_guards_for_testing
  @monitor.synchronize do
    @pending_unadopted_guards.keys.sort.map do |name|
      finalize_unadopted_observation(@pending_unadopted_guards[name])
    end
  end
end

#post_json(path, payload) ⇒ void

This method returns an undefined value.

POST a JSON payload to the Liteguard backend.

Parameters:

  • path (String)

    request path relative to ‘backend_url`

  • payload (String)

    pre-encoded JSON payload

Raises:

  • (RuntimeError)

    when the response is not successful



917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
# File 'lib/liteguard/client.rb', line 917

def post_json(path, payload)
  uri = URI("#{@backend_url}#{path}")
  req = Net::HTTP::Post.new(uri)
  req["Authorization"] = "Bearer #{@project_client_token}"
  req["Content-Type"] = "application/json"
  req["X-Liteguard-Environment"] = @environment unless @environment.empty?
  req.body = payload

  response = Net::HTTP.start(
    uri.host,
    uri.port,
    use_ssl: uri.scheme == "https",
    open_timeout: @http_timeout_seconds,
    read_timeout: @http_timeout_seconds
  ) { |http| http.request(req) }
  raise "request returned #{response.code}" unless response.is_a?(Net::HTTPSuccess)
end

#protected_context_cache_key(protected_context) ⇒ String

Build a stable cache key for a protected context.

Parameters:

Returns:

  • (String)

    bundle cache key



771
772
773
774
775
776
777
778
779
780
# File 'lib/liteguard/client.rb', line 771

def protected_context_cache_key(protected_context)
  return PUBLIC_BUNDLE_KEY if protected_context.nil?

  keys = protected_context.properties.keys.sort
  parts = [protected_context.signature, ""]
  keys.each do |key|
    parts << "#{key}=#{protected_context.properties[key]}"
  end
  parts.join("\x00")
end

#rate_limit_bucket_key(name, rate_limit_properties, props) ⇒ String

Build the cache key used for rate-limit buckets.

Parameters:

  • name (String)

    guard name

  • rate_limit_properties (Array<String>, nil)

    bucket property names

  • props (Hash)

    evaluation properties

Returns:

  • (String)

    rate-limit bucket key



1015
1016
1017
1018
1019
1020
# File 'lib/liteguard/client.rb', line 1015

def rate_limit_bucket_key(name, rate_limit_properties, props)
  return name if rate_limit_properties.nil? || rate_limit_properties.empty?

  parts = rate_limit_properties.map { |property| "#{property}=#{props[property] || ''}" }
  "#{name}\x00#{parts.join("\x00")}"
end

#recompute_refresh_interval_lockedvoid

This method returns an undefined value.

Recompute the shortest refresh interval across all known bundles.



860
861
862
863
# File 'lib/liteguard/client.rb', line 860

def recompute_refresh_interval_locked
  next_refresh_rate = @bundles.values.map(&:refresh_rate_seconds).select(&:positive?).min
  @current_refresh_rate = next_refresh_rate || @refresh_rate
end

#record_unadopted_guard(name) ⇒ void

This method returns an undefined value.

Track an unadopted guard so it can be reported with observation metadata.

Parameters:

  • name (String)

    guard name to record



1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
# File 'lib/liteguard/client.rb', line 1223

def record_unadopted_guard(name)
  @monitor.synchronize do
    now = (Time.now.to_f * 1000).to_i
    observation = @pending_unadopted_guards[name]
    if observation
      @pending_unadopted_guards[name] = observation.with(
        last_seen_ms: now,
        check_count: observation.check_count + 1
      )
    else
      @pending_unadopted_guards[name] = UnadoptedGuardObservation.new(
        guard_name: name,
        first_seen_ms: now,
        last_seen_ms: now,
        check_count: 1,
        estimated_checks_per_minute: 0.0
      )
    end
  end
end

#refresh_loopvoid

This method returns an undefined value.

Background loop that periodically refreshes guard bundles.



868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
# File 'lib/liteguard/client.rb', line 868

def refresh_loop
  @monitor.synchronize do
    until @stopped
      @refresh_cond.wait(@current_refresh_rate)
      break if @stopped
      if @refresh_wakeup_requested
        @refresh_wakeup_requested = false
        next
      end
      bundle_keys = @bundles.keys.sort
      @monitor.mon_exit
      begin
        bundle_keys.each { |bundle_key| fetch_guards_for_bundle(bundle_key) }
      ensure
        @monitor.mon_enter
      end
    end
  end
end

#request_refresh_reschedulevoid

This method returns an undefined value.

Wake the refresh loop so it can recompute its next wait interval.



785
786
787
788
789
790
# File 'lib/liteguard/client.rb', line 785

def request_refresh_reschedule
  @monitor.synchronize do
    @refresh_wakeup_requested = true
    @refresh_cond.signal
  end
end

#reset_rate_limit_state(name = nil) ⇒ void

This method returns an undefined value.

Clear rate-limit state for one guard or for all guards.

Parameters:

  • name (String, nil) (defaults to: nil)

    optional guard name to clear



1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
# File 'lib/liteguard/client.rb', line 1347

def reset_rate_limit_state(name = nil)
  @monitor.synchronize do
    if name
      @rate_limit_state.delete(name)
      prefix = "#{name}\x00"
      @rate_limit_state.delete_if { |key, _| key.start_with?(prefix) }
    else
      @rate_limit_state.clear
    end
  end
end

#resolve_scope(scope) ⇒ Scope

Resolve a scope argument and verify that it belongs to this client.

Parameters:

  • scope (Scope, nil)

    candidate scope

Returns:

  • (Scope)

    resolved scope

Raises:

  • (ArgumentError)

    if the scope belongs to a different client



250
251
252
253
254
255
# File 'lib/liteguard/client.rb', line 250

def resolve_scope(scope)
  resolved = scope || active_scope
  raise ArgumentError, "[liteguard] scope belongs to a different client" unless resolved.belongs_to?(self)

  resolved
end

#ruby_internal_constant(key) ⇒ Integer?

Safely read a Ruby VM internal constant.

Parameters:

  • key (Symbol)

    desired constant name

Returns:

  • (Integer, nil)

    integer constant or ‘nil`



1180
1181
1182
1183
1184
1185
# File 'lib/liteguard/client.rb', line 1180

def ruby_internal_constant(key)
  return nil unless defined?(GC::INTERNAL_CONSTANTS)

  value = GC::INTERNAL_CONSTANTS[key]
  value.is_a?(Numeric) ? value.to_i : nil
end

#schedule_async_flushvoid

This method returns an undefined value.

Request a background flush without doing network I/O on the caller path.



634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
# File 'lib/liteguard/client.rb', line 634

def schedule_async_flush
  spawn_worker = false

  @monitor.synchronize do
    if @flush_thread&.alive?
      @flush_requested = true
      @flush_cond.broadcast
    elsif !@async_flush_scheduled
      @async_flush_scheduled = true
      spawn_worker = true
    end
  end

  return unless spawn_worker

  worker = Thread.new do
    begin
      flush_signals
    ensure
      reschedule = false
      @monitor.synchronize do
        @async_flush_scheduled = false
        reschedule = !@stopped && @signal_buffer.size >= @flush_size
      end
      schedule_async_flush if reschedule
    end
  end
  worker.abort_on_exception = false
  worker.report_on_exception = false if worker.respond_to?(:report_on_exception=)
end

#set_guards(guards) ⇒ void

This method returns an undefined value.

Replace the public bundle with test data.

Parameters:

  • guards (Array<Guard>)

    guards to install in the public bundle



1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
# File 'lib/liteguard/client.rb', line 1308

def set_guards(guards)
  @monitor.synchronize do
    @bundles[PUBLIC_BUNDLE_KEY] = GuardBundle.new(
      key: PUBLIC_BUNDLE_KEY,
      guards: guards.each_with_object({}) { |guard, acc| acc[guard.name] = guard },
      ready: true,
      etag: "",
      protected_context: nil,
      refresh_rate_seconds: @refresh_rate
    )
    recompute_refresh_interval_locked
  end
end

#set_protected_guards(protected_context, guards) ⇒ void

This method returns an undefined value.

Install a protected bundle for testing.

Parameters:

  • protected_context (ProtectedContext, Hash)

    protected context key

  • guards (Array<Guard>)

    guards to install for that bundle



1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
# File 'lib/liteguard/client.rb', line 1327

def set_protected_guards(protected_context, guards)
  normalized = normalize_protected_context(protected_context)
  bundle_key = protected_context_cache_key(normalized)
  @monitor.synchronize do
    @bundles[bundle_key] = GuardBundle.new(
      key: bundle_key,
      guards: guards.each_with_object({}) { |guard, acc| acc[guard.name] = guard },
      ready: true,
      etag: "",
      protected_context: normalized,
      refresh_rate_seconds: @refresh_rate
    )
    recompute_refresh_interval_locked
  end
end

#shutdownvoid

This method returns an undefined value.

Stop background workers and flush remaining telemetry.



104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/liteguard/client.rb', line 104

def shutdown
  @monitor.synchronize do
    @stopped = true
    @flush_requested = true
    @refresh_cond.broadcast
    @flush_cond.broadcast
  end
  shutdown_timeout = @http_timeout_seconds
  @refresh_thread&.join(shutdown_timeout)
  @flush_thread&.join(shutdown_timeout)
  flush_signals
end

#signal_measurement_payload(measurement) ⇒ Hash

Convert an internal measurement struct into the API payload shape.

Parameters:

Returns:

  • (Hash)

    JSON-ready measurement payload



1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
# File 'lib/liteguard/client.rb', line 1191

def signal_measurement_payload(measurement)
  payload = {}
  if measurement.guard_check
    payload[:guardCheck] = {
      rssBytes: measurement.guard_check.rss_bytes,
      heapUsedBytes: measurement.guard_check.heap_used_bytes,
      heapTotalBytes: measurement.guard_check.heap_total_bytes,
      cpuTimeNs: measurement.guard_check.cpu_time_ns,
      gcCount: measurement.guard_check.gc_count,
      threadCount: measurement.guard_check.thread_count,
    }
  end
  if measurement.guard_execution
    payload[:guardExecution] = {
      durationNs: measurement.guard_execution.duration_ns,
      rssEndBytes: measurement.guard_execution.rss_end_bytes,
      heapUsedEndBytes: measurement.guard_execution.heap_used_end_bytes,
      heapTotalEndBytes: measurement.guard_execution.heap_total_end_bytes,
      cpuTimeEndNs: measurement.guard_execution.cpu_time_end_ns,
      gcCountEnd: measurement.guard_execution.gc_count_end,
      threadCountEnd: measurement.guard_execution.thread_count_end,
      completed: measurement.guard_execution.completed,
      errorClass: measurement.guard_execution.error_class,
    }
  end
  payload
end

#startvoid

This method returns an undefined value.

Perform the initial bundle fetch and start background worker threads.



93
94
95
96
97
98
99
# File 'lib/liteguard/client.rb', line 93

def start
  fetch_guards_for_bundle(PUBLIC_BUNDLE_KEY)
  @refresh_thread = Thread.new { refresh_loop }
  @flush_thread = Thread.new { flush_loop }
  @refresh_thread.abort_on_exception = false
  @flush_thread.abort_on_exception = false
end

#start_executionExecution

Start a new execution correlation context and return a handle.

Returns a lightweight Execution handle that groups telemetry signals under a shared execution ID. Call Execution#end_execution when done. For block-based usage, prefer #with_execution instead.

Returns:



148
149
150
151
152
153
154
155
156
157
158
# File 'lib/liteguard/client.rb', line 148

def start_execution
  exec_id = next_signal_id
  previous = Thread.current[:liteguard_execution_state]
  Thread.current[:liteguard_execution_state] = {
    execution_id: exec_id,
    sequence_number: 0,
    last_signal_id: nil,
  }
  cleanup = -> { Thread.current[:liteguard_execution_state] = previous }
  Execution.new(exec_id, cleanup: cleanup)
end

#take_dropped_signalsInteger

Consume and reset the dropped-signal counter.

Returns:

  • (Integer)

    number of dropped signals since the last emitted signal



704
705
706
707
708
709
710
# File 'lib/liteguard/client.rb', line 704

def take_dropped_signals
  @monitor.synchronize do
    dropped = @dropped_signals_pending
    @dropped_signals_pending = 0
    dropped
  end
end

#with_execution { ... } ⇒ Object

Run a block in a correlated execution scope.

Signals emitted inside the block share an execution identifier and parent relationships so that related checks and executions can be stitched together server-side.

Yields:

  • Runs inside a correlated execution scope

Returns:

  • (Object)

    the block return value



125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
# File 'lib/liteguard/client.rb', line 125

def with_execution
  existing = Thread.current[:liteguard_execution_state]
  return yield if existing

  Thread.current[:liteguard_execution_state] = {
    execution_id: next_signal_id,
    sequence_number: 0,
    last_signal_id: nil,
  }
  begin
    yield
  ensure
    Thread.current[:liteguard_execution_state] = nil
  end
end

#with_properties(properties) { ... } ⇒ Object

Merge properties over the current scope for the duration of a block.

Parameters:

  • properties (Hash)

    properties to merge

Yields:

  • Runs with a derived scope bound as active

Returns:

  • (Object)

    the block return value

Raises:

  • (ArgumentError)

    if no block is given



227
228
229
230
231
# File 'lib/liteguard/client.rb', line 227

def with_properties(properties)
  raise ArgumentError, "with_properties requires a block" unless block_given?

  with_scope(active_scope.with_properties(properties)) { yield }
end

#with_protected_context(protected_context) { ... } ⇒ Object

Bind a protected context for the duration of a block.

Parameters:

Yields:

  • Runs with a derived protected-context scope bound as active

Returns:

  • (Object)

    the block return value

Raises:

  • (ArgumentError)

    if no block is given



239
240
241
242
243
# File 'lib/liteguard/client.rb', line 239

def with_protected_context(protected_context)
  raise ArgumentError, "with_protected_context requires a block" unless block_given?

  with_scope(active_scope.bind_protected_context(protected_context)) { yield }
end

#with_scope(scope) { ... } ⇒ Object

Bind a scope for the duration of a block.

This method serves two roles:

  1. Developer convenience for scoping guard evaluation to a request.

  2. Required infrastructure for auto-instrumentation. When application middleware activates a scope with this method, instrumented third-party code running inside the block can discover the scope via #active_scope.

Parameters:

  • scope (Scope, nil)

    scope to bind, or ‘nil` to reuse the current scope

Yields:

  • Runs with the resolved scope bound as active

Returns:

  • (Object)

    the block return value

Raises:

  • (ArgumentError)

    if no block is given or the scope belongs to a different client



204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
# File 'lib/liteguard/client.rb', line 204

def with_scope(scope)
  raise ArgumentError, "with_scope requires a block" unless block_given?

  resolved = resolve_scope(scope)
  previous = Thread.current[@active_scope_key]
  Thread.current[@active_scope_key] = resolved
  begin
    yield
  ensure
    if previous.nil?
      Thread.current[@active_scope_key] = nil
    else
      Thread.current[@active_scope_key] = previous
    end
  end
end

#would_pass_rate_limit(name, limit_per_minute, rate_limit_properties, props) ⇒ Boolean

Check whether an evaluation would pass rate limiting without consuming a slot.

Parameters:

  • name (String)

    guard name

  • limit_per_minute (Integer)

    allowed evaluations per minute

  • rate_limit_properties (Array<String>)

    properties contributing to the bucket key

  • props (Hash)

    evaluation properties

Returns:

  • (Boolean)

    ‘true` when the evaluation would pass the limit



997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
# File 'lib/liteguard/client.rb', line 997

def would_pass_rate_limit(name, limit_per_minute, rate_limit_properties, props)
  now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  key = rate_limit_bucket_key(name, rate_limit_properties, props)
  @monitor.synchronize do
    entry = @rate_limit_state[key]
    return true if entry.nil?
    return true if now - entry[:window_start] >= 60.0

    entry[:count] < limit_per_minute
  end
end