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

#apply_rate_limit(guard, name, initial_result, props, emit_signal:) ⇒ Object



1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
# File 'lib/liteguard/client.rb', line 1059

def apply_rate_limit(guard, name, initial_result, props, emit_signal:)
  rate_limit_decisions = []
  return { result: initial_result, rate_limit_decisions: rate_limit_decisions } unless initial_result

  result = initial_result
  rate_limit = guard.rate_limit
  if rate_limit && rate_limit.requests_per_minute.to_i > 0
    evaluation = if emit_signal
      consume_rate_limit(name, :active, nil, rate_limit.requests_per_minute, rate_limit.partition_properties, props)
    else
      peek_rate_limit(name, :active, nil, rate_limit.requests_per_minute, rate_limit.partition_properties, props)
    end
    result = evaluation[:allowed]
    if emit_signal
      rate_limit_decisions << build_rate_limit_decision(
        :active,
        nil,
        evaluation[:allowed] ? :within_limit : :limited,
        rate_limit.requests_per_minute,
        rate_limit.partition_properties,
        props,
        evaluation[:count_in_window]
      )
    end
  end

  dry_run_rate_limit = guard.dry_run_rate_limit
  if emit_signal && dry_run_rate_limit && dry_run_rate_limit.requests_per_minute.to_i > 0
    evaluation = consume_rate_limit(
      name,
      :dry_run,
      dry_run_rate_limit.dry_run_id,
      dry_run_rate_limit.requests_per_minute,
      dry_run_rate_limit.partition_properties,
      props
    )
    rate_limit_decisions << build_rate_limit_decision(
      :dry_run,
      dry_run_rate_limit.dry_run_id,
      evaluation[:allowed] ? :within_limit : :would_limit,
      dry_run_rate_limit.requests_per_minute,
      dry_run_rate_limit.partition_properties,
      props,
      evaluation[:count_in_window]
    )
  end

  { result: result, rate_limit_decisions: rate_limit_decisions }
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, rate_limit_decisions: 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



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
630
631
# File 'lib/liteguard/client.rb', line 602

def buffer_signal(guard_name, result, props, kind:, measurement: nil, parent_signal_id_override: nil, rate_limit_decisions: 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,
    rate_limit_decisions: Array(rate_limit_decisions).dup
  )
  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

#build_rate_limit_decision(evaluation_target, dry_run_id, outcome, requests_per_minute, rate_limit_properties, props, count_in_window) ⇒ Object



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

def build_rate_limit_decision(evaluation_target, dry_run_id, outcome, requests_per_minute, rate_limit_properties, props, count_in_window)
  RateLimitDecision.new(
    evaluation_target: evaluation_target,
    dry_run_id: dry_run_id,
    outcome: outcome,
    requests_per_minute: requests_per_minute,
    partition_properties: Array(rate_limit_properties).dup,
    partition_values: partition_values(rate_limit_properties, props),
    count_in_window: count_in_window
  )
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



746
747
748
749
750
# File 'lib/liteguard/client.rb', line 746

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`



1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
# File 'lib/liteguard/client.rb', line 1443

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:



1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
# File 'lib/liteguard/client.rb', line 1265

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:



1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
# File 'lib/liteguard/client.rb', line 1286

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) ⇒ Object



1109
1110
1111
# File 'lib/liteguard/client.rb', line 1109

def check_rate_limit(name, limit_per_minute, rate_limit_properties, props)
  consume_rate_limit(name, :active, nil, limit_per_minute, rate_limit_properties, props)[:allowed]
end

#consume_rate_limit(name, evaluation_target, dry_run_id, limit_per_minute, rate_limit_properties, props) ⇒ Object



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

def consume_rate_limit(name, evaluation_target, dry_run_id, limit_per_minute, rate_limit_properties, props)
  evaluate_rate_limit(
    rate_limit_slot_key(name, evaluation_target, dry_run_id),
    limit_per_minute,
    rate_limit_properties,
    props,
    consume: true
  )
end

#copy_protected_context(protected_context) ⇒ ProtectedContext?

Return a defensive copy of a protected context.

Parameters:

Returns:



1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
# File 'lib/liteguard/client.rb', line 1238

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

  ProtectedContext.new(
    properties: protected_context.properties.dup,
    signature: protected_context.signature.dup,
    issued_at: protected_context.issued_at,
    expires_at: protected_context.expires_at
  )
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:



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

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



1543
1544
1545
# File 'lib/liteguard/client.rb', line 1543

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



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

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



1433
1434
1435
1436
1437
1438
# File 'lib/liteguard/client.rb', line 1433

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`



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

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

  applied_rate_limit = apply_rate_limit(guard, name, Evaluation.evaluate_guard(guard, props), props, emit_signal: emit_signal)
  result = applied_rate_limit[:result]

  signal = nil
  if emit_signal
    signal = buffer_signal(
      name,
      result,
      props,
      kind: "guard_check",
      measurement: measurement_enabled?(guard, options) ? capture_guard_check_measurement : nil,
      rate_limit_decisions: applied_rate_limit[:rate_limit_decisions]
    )
  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
# 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)
  applied_rate_limit = apply_rate_limit(guard, name.to_s, detailed.result, props, emit_signal: true)
  result = applied_rate_limit[:result]

  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,
    rate_limit_decisions: applied_rate_limit[:rate_limit_decisions]
  )

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

#evaluate_rate_limit(slot_key, limit_per_minute, rate_limit_properties, props, consume:) ⇒ 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



999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
# File 'lib/liteguard/client.rb', line 999

def evaluate_rate_limit(slot_key, limit_per_minute, rate_limit_properties, props, consume:)
  now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  key = rate_limit_bucket_key(slot_key, 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
    count_in_window = entry[:count] + 1
    allowed = entry[:count] < limit_per_minute
    if consume
      entry = { window_start: entry[:window_start], count: count_in_window } if allowed
      @rate_limit_state[key] = entry
    end
    { allowed: allowed, count_in_window: count_in_window }
  end
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



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

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



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

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



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
856
857
858
859
860
861
862
# File 'lib/liteguard/client.rb', line 801

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
    }
    payload[:protectedContext][:issuedAt] = protected_context.issued_at unless protected_context.issued_at.nil?
    payload[:protectedContext][:expiresAt] = protected_context.expires_at unless protected_context.expires_at.nil?
  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



1407
1408
1409
1410
1411
1412
1413
1414
1415
# File 'lib/liteguard/client.rb', line 1407

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.



898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
# File 'lib/liteguard/client.rb', line 898

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



525
526
527
528
529
530
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
564
# File 'lib/liteguard/client.rb', line 525

def flush_signal_batch(batch)
  payload = JSON.generate(
    projectClientToken: @project_client_token,
    environment: @environment,
    signals: batch.map do |signal|
      rate_limit_decisions_payload = if signal.rate_limit_decisions&.any?
        { rateLimitDecisions: signal.rate_limit_decisions.map { |decision| rate_limit_decision_payload(decision) } }
      else
        {}
      end

      {
        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) } : {}),
        **rate_limit_decisions_payload
      }
    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.



502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
# File 'lib/liteguard/client.rb', line 502

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:



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

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



1319
1320
1321
1322
1323
1324
1325
# File 'lib/liteguard/client.rb', line 1319

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`



1309
1310
1311
1312
# File 'lib/liteguard/client.rb', line 1309

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



1536
1537
1538
# File 'lib/liteguard/client.rb', line 1536

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



1459
1460
1461
# File 'lib/liteguard/client.rb', line 1459

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



717
718
719
# File 'lib/liteguard/client.rb', line 717

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



1254
1255
1256
1257
1258
1259
1260
# File 'lib/liteguard/client.rb', line 1254

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



1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
# File 'lib/liteguard/client.rb', line 1417

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



697
698
699
700
701
# File 'lib/liteguard/client.rb', line 697

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



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

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



1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
# File 'lib/liteguard/client.rb', line 1156

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_optional_integer(raw, snake_key, camel_key) ⇒ Integer?

Normalize an optional integer field from symbol or string keyed hashes.

Parameters:

  • raw (Hash)

    source payload

  • snake_key (Symbol)

    snake_case key

  • camel_key (String)

    camelCase key

Returns:

  • (Integer, nil)

    normalized integer value



1202
1203
1204
1205
1206
1207
# File 'lib/liteguard/client.rb', line 1202

def normalize_optional_integer(raw, snake_key, camel_key)
  value = raw[snake_key] || raw[camel_key]
  return nil if value.nil?

  Integer(value)
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



1145
1146
1147
1148
# File 'lib/liteguard/client.rb', line 1145

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



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

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:



1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
# File 'lib/liteguard/client.rb', line 1213

def normalize_protected_context(protected_context)
  raw = if protected_context.is_a?(ProtectedContext)
    {
      properties: protected_context.properties,
      signature: protected_context.signature,
      issued_at: protected_context.issued_at,
      expires_at: protected_context.expires_at
    }
  else
    protected_context
  end
  ProtectedContext.new(
    properties: normalize_protected_context_properties(
      raw[:properties] || raw["properties"] || {}
    ),
    signature: (raw[:signature] || raw["signature"]).to_s,
    issued_at: normalize_optional_integer(raw, :issued_at, "issuedAt"),
    expires_at: normalize_optional_integer(raw, :expires_at, "expiresAt")
  )
end

#normalize_protected_context_properties(properties) ⇒ Hash

Normalize protected-context property keys and values to strings.

Parameters:

  • properties (Hash, nil)

    raw protected property hash

Returns:

  • (Hash)

    normalized protected property hash



1190
1191
1192
1193
1194
# File 'lib/liteguard/client.rb', line 1190

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

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

#parse_dry_run_rate_limit(raw) ⇒ Object



967
968
969
970
971
972
973
974
975
# File 'lib/liteguard/client.rb', line 967

def parse_dry_run_rate_limit(raw)
  return nil unless raw.is_a?(Hash)

  GuardDryRunRateLimit.new(
    dry_run_id: raw["dryRunId"].to_s,
    requests_per_minute: (raw["requestsPerMinute"] || 0).to_i,
    partition_properties: Array(raw["partitionProperties"])
  )
end

#parse_guard(raw) ⇒ Guard

Parse a guard payload returned by the backend.

Parameters:

  • raw (Hash)

    decoded guard payload

Returns:

  • (Guard)

    parsed guard record



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

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: parse_rate_limit(raw["rateLimit"]),
    dry_run_rate_limit: parse_dry_run_rate_limit(raw["dryRunRateLimit"]),
    disable_measurement: raw.key?("disableMeasurement") ? raw["disableMeasurement"] : nil
  )
end

#parse_rate_limit(raw) ⇒ Object



958
959
960
961
962
963
964
965
# File 'lib/liteguard/client.rb', line 958

def parse_rate_limit(raw)
  return nil unless raw.is_a?(Hash)

  GuardRateLimitConfig.new(
    requests_per_minute: (raw["requestsPerMinute"] || 0).to_i,
    partition_properties: Array(raw["partitionProperties"])
  )
end

#parse_rule(raw) ⇒ Rule

Parse a rule payload returned by the backend.

Parameters:

  • raw (Hash)

    decoded rule payload

Returns:

  • (Rule)

    parsed rule record



981
982
983
984
985
986
987
988
989
# File 'lib/liteguard/client.rb', line 981

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

#partition_values(rate_limit_properties, props) ⇒ Object



1041
1042
1043
1044
1045
# File 'lib/liteguard/client.rb', line 1041

def partition_values(rate_limit_properties, props)
  Array(rate_limit_properties).each_with_object({}) do |property, values|
    values[property] = props[property] if props.key?(property)
  end
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

#peek_rate_limit(name, evaluation_target, dry_run_id, limit_per_minute, rate_limit_properties, props) ⇒ Object



1025
1026
1027
1028
1029
1030
1031
1032
1033
# File 'lib/liteguard/client.rb', line 1025

def peek_rate_limit(name, evaluation_target, dry_run_id, limit_per_minute, rate_limit_properties, props)
  evaluate_rate_limit(
    rate_limit_slot_key(name, evaluation_target, dry_run_id),
    limit_per_minute,
    rate_limit_properties,
    props,
    consume: false
  )
end

#pending_unadopted_guards_for_testingArray<UnadoptedGuardObservation>

Return pending unadopted-guard observations for tests.

Returns:



1525
1526
1527
1528
1529
1530
1531
# File 'lib/liteguard/client.rb', line 1525

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



924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
# File 'lib/liteguard/client.rb', line 924

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



773
774
775
776
777
778
779
780
781
782
783
784
785
# File 'lib/liteguard/client.rb', line 773

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, "properties"]
  keys.each do |key|
    parts << "#{key}=#{protected_context.properties[key]}"
  end
  parts << ""
  parts << "issuedAt=#{protected_context.issued_at || ''}"
  parts << "expiresAt=#{protected_context.expires_at || ''}"
  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



1132
1133
1134
1135
1136
1137
# File 'lib/liteguard/client.rb', line 1132

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

#rate_limit_decision_payload(decision) ⇒ Object



1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
# File 'lib/liteguard/client.rb', line 1370

def rate_limit_decision_payload(decision)
  {
    evaluationTarget: decision.evaluation_target.to_s,
    **(decision.dry_run_id ? { dryRunId: decision.dry_run_id } : {}),
    outcome: decision.outcome.to_s,
    requestsPerMinute: decision.requests_per_minute,
    partitionProperties: decision.partition_properties,
    partitionValues: decision.partition_values,
    countInWindow: decision.count_in_window,
  }
end

#rate_limit_slot_key(name, evaluation_target, dry_run_id) ⇒ Object



1035
1036
1037
1038
1039
# File 'lib/liteguard/client.rb', line 1035

def rate_limit_slot_key(name, evaluation_target, dry_run_id)
  return "#{name}\x00active" if evaluation_target == :active

  "#{name}\x00dry_run=#{dry_run_id}"
end

#recompute_refresh_interval_lockedvoid

This method returns an undefined value.

Recompute the shortest refresh interval across all known bundles.



867
868
869
870
# File 'lib/liteguard/client.rb', line 867

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



1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
# File 'lib/liteguard/client.rb', line 1386

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.



875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
# File 'lib/liteguard/client.rb', line 875

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.



790
791
792
793
794
795
# File 'lib/liteguard/client.rb', line 790

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



1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
# File 'lib/liteguard/client.rb', line 1510

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`



1331
1332
1333
1334
1335
1336
# File 'lib/liteguard/client.rb', line 1331

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.



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
664
665
# File 'lib/liteguard/client.rb', line 636

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



1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
# File 'lib/liteguard/client.rb', line 1471

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



1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
# File 'lib/liteguard/client.rb', line 1490

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



1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
# File 'lib/liteguard/client.rb', line 1342

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



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

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



1122
1123
1124
# File 'lib/liteguard/client.rb', line 1122

def would_pass_rate_limit(name, limit_per_minute, rate_limit_properties, props)
  peek_rate_limit(name, :active, nil, limit_per_minute, rate_limit_properties, props)[:allowed]
end