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 =
30
DEFAULT_FLUSH_RATE =
10
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_key_id, opts = {}) ⇒ void

Create a client instance.

The client remains idle until #start is called.

Parameters:

  • project_client_key_id (String)

    project client key identifier 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
89
# File 'lib/liteguard/client.rb', line 54

def initialize(project_client_key_id, opts = {})
  @project_client_key_id = project_client_key_id
  @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
  @reported_unadopted_guards = {}
  @pending_unadopted_guards = {}
  @rate_limit_state = {}
end

Instance Method Details

#active_scopeScope

Return the active scope for the current thread.

Returns:

  • (Scope)

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



157
158
159
160
161
162
# File 'lib/liteguard/client.rb', line 157

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

  @default_scope
end

#add_properties(properties) ⇒ Scope

Replace the active scope with one that includes merged properties.

Parameters:

  • properties (Hash)

    properties to merge into the active scope

Returns:

  • (Scope)

    the derived active scope



217
218
219
# File 'lib/liteguard/client.rb', line 217

def add_properties(properties)
  replace_current_scope(active_scope.with_properties(properties))
end

#bind_protected_context(protected_context) ⇒ Scope

Replace the active scope with a protected-context-derived scope.

Parameters:

Returns:

  • (Scope)

    the derived active scope



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

def bind_protected_context(protected_context)
  replace_current_scope(active_scope.bind_protected_context(protected_context))
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



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

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



549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
# File 'lib/liteguard/client.rb', line 549

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



692
693
694
695
696
# File 'lib/liteguard/client.rb', line 692

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`



1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
# File 'lib/liteguard/client.rb', line 1182

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:



1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
# File 'lib/liteguard/client.rb', line 1061

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:



1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
# File 'lib/liteguard/client.rb', line 1082

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



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

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

#clear_properties(names) ⇒ Scope

Replace the active scope with one that omits the named properties.

Parameters:

  • names (Array<String, Symbol>)

    property names to remove

Returns:

  • (Scope)

    the derived active scope



225
226
227
# File 'lib/liteguard/client.rb', line 225

def clear_properties(names)
  replace_current_scope(active_scope.clear_properties(Array(names).map(&:to_s)))
end

#clear_protected_contextScope

Replace the active scope with one using the public bundle.

Returns:

  • (Scope)

    the derived active scope



247
248
249
# File 'lib/liteguard/client.rb', line 247

def clear_protected_context
  replace_current_scope(active_scope.clear_protected_context)
end

#contextHash

Return the current thread’s evaluation properties.

Returns:

  • (Hash)

    active scope properties



254
255
256
# File 'lib/liteguard/client.rb', line 254

def context
  active_scope.properties
end

#copy_protected_context(protected_context) ⇒ ProtectedContext?

Return a defensive copy of a protected context.

Parameters:

Returns:



1036
1037
1038
1039
1040
1041
1042
1043
# File 'lib/liteguard/client.rb', line 1036

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:



677
678
679
680
681
682
683
684
685
686
# File 'lib/liteguard/client.rb', line 677

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



150
151
152
# File 'lib/liteguard/client.rb', line 150

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



1278
1279
1280
# File 'lib/liteguard/client.rb', line 1278

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



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

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.



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

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

  fetch_guards_for_bundle(PUBLIC_BUNDLE_KEY)
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`



415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
# File 'lib/liteguard/client.rb', line 415

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

#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



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
392
393
# File 'lib/liteguard/client.rb', line 359

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



403
404
405
# File 'lib/liteguard/client.rb', line 403

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



744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
# File 'lib/liteguard/client.rb', line 744

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 = {
    projectClientKeyId: @project_client_key_id,
    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_key_id}"
  req["Content-Type"] = "application/json"
  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

#flush_loopvoid

This method returns an undefined value.

Background loop that periodically flushes buffered telemetry.



838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
# File 'lib/liteguard/client.rb', line 838

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



487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
# File 'lib/liteguard/client.rb', line 487

def flush_signal_batch(batch)
  payload = JSON.generate(
    projectClientKeyId: @project_client_key_id,
    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 reports.



466
467
468
469
470
471
472
473
474
475
476
477
478
# File 'lib/liteguard/client.rb', line 466

def flush_signals
  batch, unadopted_guard_names = @monitor.synchronize do
    buffered = @signal_buffer.dup
    @signal_buffer.clear
    names = @pending_unadopted_guards.keys.sort
    @pending_unadopted_guards.clear
    [buffered, names]
  end
  return if batch.empty? && unadopted_guard_names.empty?

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

#flush_unadopted_guards(unadopted_guard_names) ⇒ void

This method returns an undefined value.

Upload unadopted guard names discovered during evaluation.

Parameters:

  • unadopted_guard_names (Array<String>)

    guard names to report



525
526
527
528
529
530
531
532
533
534
535
536
537
# File 'lib/liteguard/client.rb', line 525

def flush_unadopted_guards(unadopted_guard_names)
  payload = JSON.generate(
    projectClientKeyId: @project_client_key_id,
    environment: @environment,
    guardNames: unadopted_guard_names
  )
  post_json("/api/v1/unadopted-guards", payload)
rescue => e
  log "[liteguard] unadopted guard flush failed: #{e}"
  @monitor.synchronize do
    unadopted_guard_names.each { |name| @pending_unadopted_guards[name] = true }
  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



1115
1116
1117
1118
1119
1120
1121
# File 'lib/liteguard/client.rb', line 1115

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`



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

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



314
315
316
317
# File 'lib/liteguard/client.rb', line 314

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



325
326
327
328
# File 'lib/liteguard/client.rb', line 325

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



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

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



1198
1199
1200
# File 'lib/liteguard/client.rb', line 1198

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



663
664
665
# File 'lib/liteguard/client.rb', line 663

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



1050
1051
1052
1053
1054
1055
1056
# File 'lib/liteguard/client.rb', line 1050

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

  true
end

#next_signal_idString

Generate a unique signal identifier.

Returns:

  • (String)

    unique signal ID



643
644
645
646
647
# File 'lib/liteguard/client.rb', line 643

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



617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
# File 'lib/liteguard/client.rb', line 617

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



986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
# File 'lib/liteguard/client.rb', line 986

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



975
976
977
978
# File 'lib/liteguard/client.rb', line 975

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



1010
1011
1012
1013
1014
# File 'lib/liteguard/client.rb', line 1010

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:



1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
# File 'lib/liteguard/client.rb', line 1020

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



886
887
888
889
890
891
892
893
894
895
896
# File 'lib/liteguard/client.rb', line 886

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



902
903
904
905
906
907
908
909
910
# File 'lib/liteguard/client.rb', line 902

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



335
336
337
338
# File 'lib/liteguard/client.rb', line 335

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



346
347
348
349
# File 'lib/liteguard/client.rb', line 346

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<String>

Return pending unadopted-guard names for tests.

Returns:

  • (Array<String>)

    pending unadopted guard names



1264
1265
1266
# File 'lib/liteguard/client.rb', line 1264

def pending_unadopted_guards_for_testing
  @monitor.synchronize { @pending_unadopted_guards.keys.sort }
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



864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
# File 'lib/liteguard/client.rb', line 864

def post_json(path, payload)
  uri = URI("#{@backend_url}#{path}")
  req = Net::HTTP::Post.new(uri)
  req["Authorization"] = "Bearer #{@project_client_key_id}"
  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



719
720
721
722
723
724
725
726
727
728
# File 'lib/liteguard/client.rb', line 719

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



962
963
964
965
966
967
# File 'lib/liteguard/client.rb', line 962

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.



807
808
809
810
# File 'lib/liteguard/client.rb', line 807

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 once.

Parameters:

  • name (String)

    guard name to record



1170
1171
1172
1173
1174
1175
1176
1177
# File 'lib/liteguard/client.rb', line 1170

def record_unadopted_guard(name)
  @monitor.synchronize do
    return if @reported_unadopted_guards[name]

    @reported_unadopted_guards[name] = true
    @pending_unadopted_guards[name] = true
  end
end

#refresh_loopvoid

This method returns an undefined value.

Background loop that periodically refreshes guard bundles.



815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
# File 'lib/liteguard/client.rb', line 815

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

#replace_current_scope(scope) ⇒ Scope

Replace the current thread’s active scope.

Mutation helpers are intentionally thread-local so request-scoped data cannot leak through the process-wide default scope.

Parameters:

  • scope (Scope)

    scope to install

Returns:

  • (Scope)

    the resolved scope



265
266
267
268
269
# File 'lib/liteguard/client.rb', line 265

def replace_current_scope(scope)
  resolved = resolve_scope(scope)
  Thread.current[@active_scope_key] = resolved
  resolved
end

#request_refresh_reschedulevoid

This method returns an undefined value.

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



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

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

#reset_propertiesScope

Replace the active scope with an empty property scope.

Returns:

  • (Scope)

    the derived active scope



232
233
234
# File 'lib/liteguard/client.rb', line 232

def reset_properties
  replace_current_scope(active_scope.reset_properties)
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



1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
# File 'lib/liteguard/client.rb', line 1249

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



276
277
278
279
280
281
# File 'lib/liteguard/client.rb', line 276

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`



1127
1128
1129
1130
1131
1132
# File 'lib/liteguard/client.rb', line 1127

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.



582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
# File 'lib/liteguard/client.rb', line 582

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



1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
# File 'lib/liteguard/client.rb', line 1210

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



1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
# File 'lib/liteguard/client.rb', line 1229

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.



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

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



1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
# File 'lib/liteguard/client.rb', line 1138

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.



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

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

#take_dropped_signalsInteger

Consume and reset the dropped-signal counter.

Returns:

  • (Integer)

    number of dropped signals since the last emitted signal



652
653
654
655
656
657
658
# File 'lib/liteguard/client.rb', line 652

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



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

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



195
196
197
198
199
# File 'lib/liteguard/client.rb', line 195

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



207
208
209
210
211
# File 'lib/liteguard/client.rb', line 207

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.

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



172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
# File 'lib/liteguard/client.rb', line 172

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



944
945
946
947
948
949
950
951
952
953
954
# File 'lib/liteguard/client.rb', line 944

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