liteguard 0.4.20260317 → 0.7.20260603

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9c5c689f405afe521506111a670b47b74494ed547562a8e083f1c040bcf5b283
4
- data.tar.gz: a873c10263763a6700a3fb7ac1ea355d829c927a885cfbc048abf9221d66559a
3
+ metadata.gz: 674b74c3afa87054d07083210c6f894afad8bc53cb2b6ae8a4e8bd045b30c14c
4
+ data.tar.gz: 6cf6d9d1fceb2760404e65a0b26faa05ff3687318492d4e124f35eced843e9f5
5
5
  SHA512:
6
- metadata.gz: 36ffdc869fa486befba275c7a7683879b6ec0014071ba17d6339f28bf173980d4d8d6755f6c19d825b32f215f486cd62f9cbe64887bf1a4fe2c13ddc5401f763
7
- data.tar.gz: 44cbf8a627c3edb19bbc1d0152d7670661279a728774dbf72def1e28329a6e6c8a7779b598eb37a23f12bf974bd956a4d778902af9b7d94e078b66efefeda12b
6
+ metadata.gz: 51eec265fceb91ea7994cf97d64878afb7312ce5bf72abe7732d584da009a8b7ce82efa163144110054254d01ad935cbe22a77ba3ca9f8a01ca5963f5d28d0ef
7
+ data.tar.gz: d998303a8c1bfa5156aabee64afe9d8b8e174c7da199004491b5cf5e94376a157644d459605fca2c2bbde7d87f9e1381993265f8d2fe0c636057acb60e4d4dc3
data/README.md CHANGED
@@ -47,16 +47,30 @@ client.shutdown
47
47
  - `client.start` fetches the initial bundle and starts refresh and flush workers.
48
48
  - `client.create_scope(**properties)` creates an immutable scope.
49
49
  - `scope.open?(name, **options)` evaluates locally.
50
+ - `scope.evaluate(name, **options)` returns a `GuardDecision` with the full evaluation result.
50
51
  - `scope.execute_if_open(name, **options) { ... }` measures guarded work.
52
+ - `scope.start_execution` creates an execution correlation handle.
51
53
  - `scope.bind_protected_context(**protected_context)` derives a protected scope.
54
+ - `scope.properties` returns the current property bag for transport.
52
55
  - `client.flush` flushes buffered telemetry.
53
56
  - `client.shutdown` flushes and stops background work.
54
57
 
58
+ ## Convenience helpers
59
+
60
+ For server applications that want request-scoped context propagation:
61
+
62
+ - `client.with_scope(scope) { ... }` runs a block with `scope` as the active scope, isolated via `Thread.current`.
63
+ - `client.active_scope` returns the scope bound to the current thread.
64
+ - `client.with_properties(properties) { ... }` derives and activates a scope with additional properties.
65
+ - `client.with_protected_context(protected_context) { ... }` derives and activates a protected scope.
66
+
55
67
  ## Notes
56
68
 
57
69
  - Evaluation is local after the initial bundle fetch.
70
+ - Protected contexts use `properties`, `signature`, and optional `issued_at` / `expires_at` fields.
58
71
  - Prefer explicit client and scope usage.
59
72
  - Unadopted guards default open and emit no signals.
73
+ - The convenience helpers above also serve as infrastructure for auto-instrumentation of third-party dependencies. See the [Liteguard CLI](https://github.com/liteguard/liteguard/tree/main/cli) for build-time instrumentation.
60
74
 
61
75
  ## Development
62
76
 
@@ -10,8 +10,8 @@ module Liteguard
10
10
  # This class is the primary Ruby SDK entrypoint.
11
11
  class Client
12
12
  DEFAULT_BACKEND_URL = "https://api.liteguard.io"
13
- DEFAULT_REFRESH_RATE = 30
14
- DEFAULT_FLUSH_RATE = 10
13
+ DEFAULT_REFRESH_RATE = 60
14
+ DEFAULT_FLUSH_RATE = 60
15
15
  DEFAULT_FLUSH_SIZE = 500
16
16
  DEFAULT_HTTP_TIMEOUT = 4
17
17
  DEFAULT_FLUSH_BUFFER_MULTIPLIER = 4
@@ -138,6 +138,25 @@ module Liteguard
138
138
  end
139
139
  end
140
140
 
141
+ # Start a new execution correlation context and return a handle.
142
+ #
143
+ # Returns a lightweight {Execution} handle that groups telemetry signals
144
+ # under a shared execution ID. Call {Execution#end_execution} when done.
145
+ # For block-based usage, prefer {#with_execution} instead.
146
+ #
147
+ # @return [Execution] a correlation handle
148
+ def start_execution
149
+ exec_id = next_signal_id
150
+ previous = Thread.current[:liteguard_execution_state]
151
+ Thread.current[:liteguard_execution_state] = {
152
+ execution_id: exec_id,
153
+ sequence_number: 0,
154
+ last_signal_id: nil,
155
+ }
156
+ cleanup = -> { Thread.current[:liteguard_execution_state] = previous }
157
+ Execution.new(exec_id, cleanup: cleanup)
158
+ end
159
+
141
160
  # ---------------------------------------------------------------------
142
161
  # Scope API
143
162
  # ---------------------------------------------------------------------
@@ -152,6 +171,13 @@ module Liteguard
152
171
 
153
172
  # Return the active scope for the current thread.
154
173
  #
174
+ # This method serves two roles:
175
+ # 1. Developer convenience for retrieving the current request scope.
176
+ # 2. Required infrastructure for auto-instrumentation. Instrumented
177
+ # third-party code calls this method to discover the evaluation
178
+ # context established by application-level middleware via
179
+ # {#with_scope}.
180
+ #
155
181
  # @return [Scope] the active scope, or the default scope when none is bound
156
182
  def active_scope
157
183
  scope = Thread.current[@active_scope_key]
@@ -162,6 +188,13 @@ module Liteguard
162
188
 
163
189
  # Bind a scope for the duration of a block.
164
190
  #
191
+ # This method serves two roles:
192
+ # 1. Developer convenience for scoping guard evaluation to a request.
193
+ # 2. Required infrastructure for auto-instrumentation. When application
194
+ # middleware activates a scope with this method, instrumented
195
+ # third-party code running inside the block can discover the scope
196
+ # via {#active_scope}.
197
+ #
165
198
  # @param scope [Scope, nil] scope to bind, or `nil` to reuse the current
166
199
  # scope
167
200
  # @yield Runs with the resolved scope bound as active
@@ -209,64 +242,6 @@ module Liteguard
209
242
  with_scope(active_scope.bind_protected_context(protected_context)) { yield }
210
243
  end
211
244
 
212
- # Replace the active scope with one that includes merged properties.
213
- #
214
- # @param properties [Hash] properties to merge into the active scope
215
- # @return [Scope] the derived active scope
216
- def add_properties(properties)
217
- replace_current_scope(active_scope.with_properties(properties))
218
- end
219
-
220
- # Replace the active scope with one that omits the named properties.
221
- #
222
- # @param names [Array<String, Symbol>] property names to remove
223
- # @return [Scope] the derived active scope
224
- def clear_properties(names)
225
- replace_current_scope(active_scope.clear_properties(Array(names).map(&:to_s)))
226
- end
227
-
228
- # Replace the active scope with an empty property scope.
229
- #
230
- # @return [Scope] the derived active scope
231
- def reset_properties
232
- replace_current_scope(active_scope.reset_properties)
233
- end
234
-
235
- # Replace the active scope with a protected-context-derived scope.
236
- #
237
- # @param protected_context [ProtectedContext, Hash] signed protected context
238
- # @return [Scope] the derived active scope
239
- def bind_protected_context(protected_context)
240
- replace_current_scope(active_scope.bind_protected_context(protected_context))
241
- end
242
-
243
- # Replace the active scope with one using the public bundle.
244
- #
245
- # @return [Scope] the derived active scope
246
- def clear_protected_context
247
- replace_current_scope(active_scope.clear_protected_context)
248
- end
249
-
250
- # Return the current thread's evaluation properties.
251
- #
252
- # @return [Hash] active scope properties
253
- def context
254
- active_scope.properties
255
- end
256
-
257
- # Replace the current thread's active scope.
258
- #
259
- # Mutation helpers are intentionally thread-local so request-scoped data
260
- # cannot leak through the process-wide default scope.
261
- #
262
- # @param scope [Scope] scope to install
263
- # @return [Scope] the resolved scope
264
- def replace_current_scope(scope)
265
- resolved = resolve_scope(scope)
266
- Thread.current[@active_scope_key] = resolved
267
- resolved
268
- end
269
-
270
245
  # Resolve a scope argument and verify that it belongs to this client.
271
246
  #
272
247
  # @param scope [Scope, nil] candidate scope
@@ -347,6 +322,73 @@ module Liteguard
347
322
  evaluate_guard_in_scope(scope, name.to_s, options, emit_signal: false)[:result]
348
323
  end
349
324
 
325
+ # Evaluate a guard and return a {GuardDecision} with full reasoning.
326
+ #
327
+ # @param name [String] guard name to evaluate
328
+ # @param options [Hash, nil] optional per-call overrides
329
+ # @return [GuardDecision] the evaluation decision
330
+ def evaluate(name, options = nil, **legacy_options)
331
+ evaluate_in_scope(active_scope, name, options, **legacy_options)
332
+ end
333
+
334
+ # Evaluate a guard in the provided scope and return a {GuardDecision}.
335
+ #
336
+ # @param scope [Scope] scope to evaluate against
337
+ # @param name [String] guard name to evaluate
338
+ # @param options [Hash, nil] optional per-call overrides
339
+ # @return [GuardDecision] the evaluation decision
340
+ def evaluate_in_scope(scope, name, options = nil, **legacy_options)
341
+ options = normalize_is_open_options(options, legacy_options)
342
+ resolved_scope = resolve_scope(scope)
343
+ bundle = bundle_for_scope(resolved_scope)
344
+ effective_fallback = options[:fallback].nil? ? @fallback : options[:fallback]
345
+
346
+ unless bundle.ready
347
+ return GuardDecision.new(
348
+ name: name.to_s, is_open: effective_fallback, adopted: false,
349
+ reason: "fallback", matched_rule_index: nil, properties: {}
350
+ )
351
+ end
352
+
353
+ guard = bundle.guards[name.to_s]
354
+ if guard.nil?
355
+ record_unadopted_guard(name.to_s)
356
+ return GuardDecision.new(
357
+ name: name.to_s, is_open: true, adopted: false,
358
+ reason: "unadopted", matched_rule_index: nil, properties: {}
359
+ )
360
+ end
361
+ unless guard.adopted
362
+ record_unadopted_guard(name.to_s)
363
+ return GuardDecision.new(
364
+ name: name.to_s, is_open: true, adopted: false,
365
+ reason: "unadopted", matched_rule_index: nil, properties: {}
366
+ )
367
+ end
368
+
369
+ props = resolved_scope.properties
370
+ props = props.merge(options[:properties]) if options[:properties]
371
+
372
+ detailed = Evaluation.evaluate_guard_detailed(guard, props)
373
+ applied_rate_limit = apply_rate_limit(guard, name.to_s, detailed.result, props, emit_signal: true)
374
+ result = applied_rate_limit[:result]
375
+
376
+ reason = detailed.matched_rule_index >= 0 ? "matched_rule" : "default_value"
377
+ matched_idx = detailed.matched_rule_index >= 0 ? detailed.matched_rule_index : nil
378
+
379
+ buffer_signal(
380
+ name.to_s, result, props,
381
+ kind: "guard_check",
382
+ measurement: measurement_enabled?(guard, options) ? capture_guard_check_measurement : nil,
383
+ rate_limit_decisions: applied_rate_limit[:rate_limit_decisions]
384
+ )
385
+
386
+ GuardDecision.new(
387
+ name: name.to_s, is_open: result, adopted: guard.adopted,
388
+ reason: reason, matched_rule_index: matched_idx, properties: props.dup
389
+ )
390
+ end
391
+
350
392
  # Evaluate a guard and execute the block only when it resolves open.
351
393
  #
352
394
  # @param name [String] guard name to evaluate
@@ -432,14 +474,8 @@ module Liteguard
432
474
  props = props.merge(options[:properties])
433
475
  end
434
476
 
435
- result = Evaluation.evaluate_guard(guard, props)
436
- if result && guard.rate_limit_per_minute.to_i > 0
437
- result = if emit_signal
438
- check_rate_limit(name, guard.rate_limit_per_minute, guard.rate_limit_properties, props)
439
- else
440
- would_pass_rate_limit(name, guard.rate_limit_per_minute, guard.rate_limit_properties, props)
441
- end
442
- end
477
+ applied_rate_limit = apply_rate_limit(guard, name, Evaluation.evaluate_guard(guard, props), props, emit_signal: emit_signal)
478
+ result = applied_rate_limit[:result]
443
479
 
444
480
  signal = nil
445
481
  if emit_signal
@@ -448,7 +484,8 @@ module Liteguard
448
484
  result,
449
485
  props,
450
486
  kind: "guard_check",
451
- measurement: measurement_enabled?(guard, options) ? capture_guard_check_measurement : nil
487
+ measurement: measurement_enabled?(guard, options) ? capture_guard_check_measurement : nil,
488
+ rate_limit_decisions: applied_rate_limit[:rate_limit_decisions]
452
489
  )
453
490
  end
454
491
 
@@ -490,6 +527,12 @@ module Liteguard
490
527
  projectClientToken: @project_client_token,
491
528
  environment: @environment,
492
529
  signals: batch.map do |signal|
530
+ rate_limit_decisions_payload = if signal.rate_limit_decisions&.any?
531
+ { rateLimitDecisions: signal.rate_limit_decisions.map { |decision| rate_limit_decision_payload(decision) } }
532
+ else
533
+ {}
534
+ end
535
+
493
536
  {
494
537
  guardName: signal.guard_name,
495
538
  result: signal.result,
@@ -502,7 +545,8 @@ module Liteguard
502
545
  kind: signal.kind,
503
546
  droppedSignalsSinceLast: signal.dropped_signals_since_last,
504
547
  **(signal.parent_signal_id ? { parentSignalId: signal.parent_signal_id } : {}),
505
- **(signal.measurement ? { measurement: signal_measurement_payload(signal.measurement) } : {})
548
+ **(signal.measurement ? { measurement: signal_measurement_payload(signal.measurement) } : {}),
549
+ **rate_limit_decisions_payload
506
550
  }
507
551
  end
508
552
  )
@@ -555,7 +599,7 @@ module Liteguard
555
599
  # @param measurement [SignalPerformance, nil] optional measurement payload
556
600
  # @param parent_signal_id_override [String, nil] explicit parent signal ID
557
601
  # @return [Signal] buffered signal instance
558
- def buffer_signal(guard_name, result, props, kind:, measurement: nil, parent_signal_id_override: nil)
602
+ def buffer_signal(guard_name, result, props, kind:, measurement: nil, parent_signal_id_override: nil, rate_limit_decisions: nil)
559
603
  metadata = next_signal_metadata(parent_signal_id_override)
560
604
  signal = Signal.new(
561
605
  guard_name: guard_name,
@@ -570,7 +614,8 @@ module Liteguard
570
614
  callsite_id: capture_callsite_id,
571
615
  kind: kind,
572
616
  dropped_signals_since_last: take_dropped_signals,
573
- measurement: measurement
617
+ measurement: measurement,
618
+ rate_limit_decisions: Array(rate_limit_decisions).dup
574
619
  )
575
620
  should_flush = false
576
621
  @monitor.synchronize do
@@ -729,10 +774,13 @@ module Liteguard
729
774
  return PUBLIC_BUNDLE_KEY if protected_context.nil?
730
775
 
731
776
  keys = protected_context.properties.keys.sort
732
- parts = [protected_context.signature, ""]
777
+ parts = [protected_context.signature, "properties"]
733
778
  keys.each do |key|
734
779
  parts << "#{key}=#{protected_context.properties[key]}"
735
780
  end
781
+ parts << ""
782
+ parts << "issuedAt=#{protected_context.issued_at || ''}"
783
+ parts << "expiresAt=#{protected_context.expires_at || ''}"
736
784
  parts.join("\x00")
737
785
  end
738
786
 
@@ -763,6 +811,8 @@ module Liteguard
763
811
  properties: protected_context.properties,
764
812
  signature: protected_context.signature
765
813
  }
814
+ payload[:protectedContext][:issuedAt] = protected_context.issued_at unless protected_context.issued_at.nil?
815
+ payload[:protectedContext][:expiresAt] = protected_context.expires_at unless protected_context.expires_at.nil?
766
816
  end
767
817
 
768
818
  uri = URI("#{@backend_url}/api/v1/guards")
@@ -899,12 +949,31 @@ module Liteguard
899
949
  rules: (raw["rules"] || []).map { |rule| parse_rule(rule) },
900
950
  default_value: !!raw["defaultValue"],
901
951
  adopted: !!raw["adopted"],
902
- rate_limit_per_minute: (raw["rateLimitPerMinute"] || 0).to_i,
903
- rate_limit_properties: Array(raw["rateLimitProperties"]),
952
+ rate_limit: parse_rate_limit(raw["rateLimit"]),
953
+ dry_run_rate_limit: parse_dry_run_rate_limit(raw["dryRunRateLimit"]),
904
954
  disable_measurement: raw.key?("disableMeasurement") ? raw["disableMeasurement"] : nil
905
955
  )
906
956
  end
907
957
 
958
+ def parse_rate_limit(raw)
959
+ return nil unless raw.is_a?(Hash)
960
+
961
+ GuardRateLimitConfig.new(
962
+ requests_per_minute: (raw["requestsPerMinute"] || 0).to_i,
963
+ partition_properties: Array(raw["partitionProperties"])
964
+ )
965
+ end
966
+
967
+ def parse_dry_run_rate_limit(raw)
968
+ return nil unless raw.is_a?(Hash)
969
+
970
+ GuardDryRunRateLimit.new(
971
+ dry_run_id: raw["dryRunId"].to_s,
972
+ requests_per_minute: (raw["requestsPerMinute"] || 0).to_i,
973
+ partition_properties: Array(raw["partitionProperties"])
974
+ )
975
+ end
976
+
908
977
  # Parse a rule payload returned by the backend.
909
978
  #
910
979
  # @param raw [Hash] decoded rule payload
@@ -927,19 +996,118 @@ module Liteguard
927
996
  # the bucket key
928
997
  # @param props [Hash] evaluation properties
929
998
  # @return [Boolean] `true` when the evaluation is within the limit
930
- def check_rate_limit(name, limit_per_minute, rate_limit_properties, props)
999
+ def evaluate_rate_limit(slot_key, limit_per_minute, rate_limit_properties, props, consume:)
931
1000
  now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
932
- key = rate_limit_bucket_key(name, rate_limit_properties, props)
1001
+ key = rate_limit_bucket_key(slot_key, rate_limit_properties, props)
933
1002
  @monitor.synchronize do
934
1003
  entry = @rate_limit_state[key] || { window_start: now, count: 0 }
935
1004
  entry = { window_start: now, count: 0 } if now - entry[:window_start] >= 60.0
936
- if entry[:count] >= limit_per_minute
1005
+ count_in_window = entry[:count] + 1
1006
+ allowed = entry[:count] < limit_per_minute
1007
+ if consume
1008
+ entry = { window_start: entry[:window_start], count: count_in_window } if allowed
937
1009
  @rate_limit_state[key] = entry
938
- return false
939
1010
  end
940
- @rate_limit_state[key] = { window_start: entry[:window_start], count: entry[:count] + 1 }
1011
+ { allowed: allowed, count_in_window: count_in_window }
941
1012
  end
942
- true
1013
+ end
1014
+
1015
+ def consume_rate_limit(name, evaluation_target, dry_run_id, limit_per_minute, rate_limit_properties, props)
1016
+ evaluate_rate_limit(
1017
+ rate_limit_slot_key(name, evaluation_target, dry_run_id),
1018
+ limit_per_minute,
1019
+ rate_limit_properties,
1020
+ props,
1021
+ consume: true
1022
+ )
1023
+ end
1024
+
1025
+ def peek_rate_limit(name, evaluation_target, dry_run_id, limit_per_minute, rate_limit_properties, props)
1026
+ evaluate_rate_limit(
1027
+ rate_limit_slot_key(name, evaluation_target, dry_run_id),
1028
+ limit_per_minute,
1029
+ rate_limit_properties,
1030
+ props,
1031
+ consume: false
1032
+ )
1033
+ end
1034
+
1035
+ def rate_limit_slot_key(name, evaluation_target, dry_run_id)
1036
+ return "#{name}\x00active" if evaluation_target == :active
1037
+
1038
+ "#{name}\x00dry_run=#{dry_run_id}"
1039
+ end
1040
+
1041
+ def partition_values(rate_limit_properties, props)
1042
+ Array(rate_limit_properties).each_with_object({}) do |property, values|
1043
+ values[property] = props[property] if props.key?(property)
1044
+ end
1045
+ end
1046
+
1047
+ def build_rate_limit_decision(evaluation_target, dry_run_id, outcome, requests_per_minute, rate_limit_properties, props, count_in_window)
1048
+ RateLimitDecision.new(
1049
+ evaluation_target: evaluation_target,
1050
+ dry_run_id: dry_run_id,
1051
+ outcome: outcome,
1052
+ requests_per_minute: requests_per_minute,
1053
+ partition_properties: Array(rate_limit_properties).dup,
1054
+ partition_values: partition_values(rate_limit_properties, props),
1055
+ count_in_window: count_in_window
1056
+ )
1057
+ end
1058
+
1059
+ def apply_rate_limit(guard, name, initial_result, props, emit_signal:)
1060
+ rate_limit_decisions = []
1061
+ return { result: initial_result, rate_limit_decisions: rate_limit_decisions } unless initial_result
1062
+
1063
+ result = initial_result
1064
+ rate_limit = guard.rate_limit
1065
+ if rate_limit && rate_limit.requests_per_minute.to_i > 0
1066
+ evaluation = if emit_signal
1067
+ consume_rate_limit(name, :active, nil, rate_limit.requests_per_minute, rate_limit.partition_properties, props)
1068
+ else
1069
+ peek_rate_limit(name, :active, nil, rate_limit.requests_per_minute, rate_limit.partition_properties, props)
1070
+ end
1071
+ result = evaluation[:allowed]
1072
+ if emit_signal
1073
+ rate_limit_decisions << build_rate_limit_decision(
1074
+ :active,
1075
+ nil,
1076
+ evaluation[:allowed] ? :within_limit : :limited,
1077
+ rate_limit.requests_per_minute,
1078
+ rate_limit.partition_properties,
1079
+ props,
1080
+ evaluation[:count_in_window]
1081
+ )
1082
+ end
1083
+ end
1084
+
1085
+ dry_run_rate_limit = guard.dry_run_rate_limit
1086
+ if emit_signal && dry_run_rate_limit && dry_run_rate_limit.requests_per_minute.to_i > 0
1087
+ evaluation = consume_rate_limit(
1088
+ name,
1089
+ :dry_run,
1090
+ dry_run_rate_limit.dry_run_id,
1091
+ dry_run_rate_limit.requests_per_minute,
1092
+ dry_run_rate_limit.partition_properties,
1093
+ props
1094
+ )
1095
+ rate_limit_decisions << build_rate_limit_decision(
1096
+ :dry_run,
1097
+ dry_run_rate_limit.dry_run_id,
1098
+ evaluation[:allowed] ? :within_limit : :would_limit,
1099
+ dry_run_rate_limit.requests_per_minute,
1100
+ dry_run_rate_limit.partition_properties,
1101
+ props,
1102
+ evaluation[:count_in_window]
1103
+ )
1104
+ end
1105
+
1106
+ { result: result, rate_limit_decisions: rate_limit_decisions }
1107
+ end
1108
+
1109
+ def check_rate_limit(name, limit_per_minute, rate_limit_properties, props)
1110
+ consume_rate_limit(name, :active, nil, limit_per_minute, rate_limit_properties, props)[:allowed]
943
1111
  end
944
1112
 
945
1113
  # Check whether an evaluation would pass rate limiting without consuming a
@@ -952,15 +1120,7 @@ module Liteguard
952
1120
  # @param props [Hash] evaluation properties
953
1121
  # @return [Boolean] `true` when the evaluation would pass the limit
954
1122
  def would_pass_rate_limit(name, limit_per_minute, rate_limit_properties, props)
955
- now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
956
- key = rate_limit_bucket_key(name, rate_limit_properties, props)
957
- @monitor.synchronize do
958
- entry = @rate_limit_state[key]
959
- return true if entry.nil?
960
- return true if now - entry[:window_start] >= 60.0
961
-
962
- entry[:count] < limit_per_minute
963
- end
1123
+ peek_rate_limit(name, :active, nil, limit_per_minute, rate_limit_properties, props)[:allowed]
964
1124
  end
965
1125
 
966
1126
  # Build the cache key used for rate-limit buckets.
@@ -1023,19 +1183,51 @@ module Liteguard
1023
1183
  properties.each_with_object({}) { |(key, value), acc| acc[key.to_s] = value }
1024
1184
  end
1025
1185
 
1186
+ # Normalize protected-context property keys and values to strings.
1187
+ #
1188
+ # @param properties [Hash, nil] raw protected property hash
1189
+ # @return [Hash] normalized protected property hash
1190
+ def normalize_protected_context_properties(properties)
1191
+ return {} if properties.nil?
1192
+
1193
+ properties.each_with_object({}) { |(key, value), acc| acc[key.to_s] = value.to_s }
1194
+ end
1195
+
1196
+ # Normalize an optional integer field from symbol or string keyed hashes.
1197
+ #
1198
+ # @param raw [Hash] source payload
1199
+ # @param snake_key [Symbol] snake_case key
1200
+ # @param camel_key [String] camelCase key
1201
+ # @return [Integer, nil] normalized integer value
1202
+ def normalize_optional_integer(raw, snake_key, camel_key)
1203
+ value = raw[snake_key] || raw[camel_key]
1204
+ return nil if value.nil?
1205
+
1206
+ Integer(value)
1207
+ end
1208
+
1026
1209
  # Normalize a protected-context payload into a `ProtectedContext` object.
1027
1210
  #
1028
1211
  # @param protected_context [ProtectedContext, Hash] raw protected context
1029
1212
  # @return [ProtectedContext] normalized protected context
1030
1213
  def normalize_protected_context(protected_context)
1031
1214
  raw = if protected_context.is_a?(ProtectedContext)
1032
- { properties: protected_context.properties, signature: protected_context.signature }
1215
+ {
1216
+ properties: protected_context.properties,
1217
+ signature: protected_context.signature,
1218
+ issued_at: protected_context.issued_at,
1219
+ expires_at: protected_context.expires_at
1220
+ }
1033
1221
  else
1034
1222
  protected_context
1035
1223
  end
1036
1224
  ProtectedContext.new(
1037
- properties: normalize_properties(raw[:properties] || raw["properties"] || {}),
1038
- signature: (raw[:signature] || raw["signature"]).to_s
1225
+ properties: normalize_protected_context_properties(
1226
+ raw[:properties] || raw["properties"] || {}
1227
+ ),
1228
+ signature: (raw[:signature] || raw["signature"]).to_s,
1229
+ issued_at: normalize_optional_integer(raw, :issued_at, "issuedAt"),
1230
+ expires_at: normalize_optional_integer(raw, :expires_at, "expiresAt")
1039
1231
  )
1040
1232
  end
1041
1233
 
@@ -1048,7 +1240,9 @@ module Liteguard
1048
1240
 
1049
1241
  ProtectedContext.new(
1050
1242
  properties: protected_context.properties.dup,
1051
- signature: protected_context.signature.dup
1243
+ signature: protected_context.signature.dup,
1244
+ issued_at: protected_context.issued_at,
1245
+ expires_at: protected_context.expires_at
1052
1246
  )
1053
1247
  end
1054
1248
 
@@ -1173,6 +1367,18 @@ module Liteguard
1173
1367
  payload
1174
1368
  end
1175
1369
 
1370
+ def rate_limit_decision_payload(decision)
1371
+ {
1372
+ evaluationTarget: decision.evaluation_target.to_s,
1373
+ **(decision.dry_run_id ? { dryRunId: decision.dry_run_id } : {}),
1374
+ outcome: decision.outcome.to_s,
1375
+ requestsPerMinute: decision.requests_per_minute,
1376
+ partitionProperties: decision.partition_properties,
1377
+ partitionValues: decision.partition_values,
1378
+ countInWindow: decision.count_in_window,
1379
+ }
1380
+ end
1381
+
1176
1382
  # Track an unadopted guard so it can be reported with observation metadata.
1177
1383
  #
1178
1384
  # @param name [String] guard name to record
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Liteguard
4
+ # Internal result from {Evaluation.evaluate_guard_detailed}.
5
+ DetailedEvalResult = Data.define(:result, :matched_rule_index)
6
+
7
+ # A lightweight execution correlation handle.
8
+ #
9
+ # Groups telemetry signals under a shared execution ID. Created by
10
+ # {Scope#start_execution} or {Client#start_execution}.
11
+ # Call {#end_execution} when the logical execution boundary is complete.
12
+ class Execution
13
+ # @return [String] the unique identifier for this execution
14
+ attr_reader :execution_id
15
+
16
+ # @api private
17
+ def initialize(execution_id, cleanup: nil)
18
+ @execution_id = execution_id
19
+ @cleanup = cleanup
20
+ end
21
+
22
+ # End this execution.
23
+ #
24
+ # After calling +end_execution+, subsequent guard checks will no longer be
25
+ # correlated with this execution.
26
+ # @return [void]
27
+ def end_execution
28
+ if @cleanup
29
+ @cleanup.call
30
+ @cleanup = nil
31
+ end
32
+ end
33
+ end
34
+ end
@@ -18,6 +18,21 @@ module Liteguard
18
18
  guard.default_value
19
19
  end
20
20
 
21
+ # Evaluate a guard and return both the result and the matched rule index.
22
+ #
23
+ # @param guard [Guard] guard definition to evaluate
24
+ # @param properties [Hash{String => Object}] normalized evaluation properties
25
+ # @return [DetailedEvalResult] result with the matched rule index (-1 when default)
26
+ def self.evaluate_guard_detailed(guard, properties)
27
+ guard.rules.each_with_index do |rule, i|
28
+ next unless rule.enabled
29
+ if matches_rule?(rule, properties)
30
+ return DetailedEvalResult.new(result: rule.result, matched_rule_index: i)
31
+ end
32
+ end
33
+ DetailedEvalResult.new(result: guard.default_value, matched_rule_index: -1)
34
+ end
35
+
21
36
  # Determine whether a single rule matches the provided properties.
22
37
  #
23
38
  # @param rule [Rule] rule to evaluate
@@ -21,7 +21,9 @@ module Liteguard
21
21
  @bundle_key = bundle_key
22
22
  @protected_context = protected_context ? ProtectedContext.new(
23
23
  properties: protected_context.properties.dup,
24
- signature: protected_context.signature.dup
24
+ signature: protected_context.signature.dup,
25
+ issued_at: protected_context.issued_at,
26
+ expires_at: protected_context.expires_at
25
27
  ) : nil
26
28
  end
27
29
 
@@ -105,6 +107,30 @@ module Liteguard
105
107
  @client.execute_if_open_in_scope(self, name, options, **legacy_options, &block)
106
108
  end
107
109
 
110
+ # Evaluate a guard and return a {GuardDecision} with full reasoning.
111
+ #
112
+ # Like {#is_open} but returns the full decision context including the
113
+ # reason, matched rule index, and the merged properties used. A
114
+ # +guard_check+ telemetry signal is buffered.
115
+ #
116
+ # @param name [String] guard name to evaluate
117
+ # @param options [Hash, nil] optional per-call overrides
118
+ # @return [GuardDecision] the evaluation decision
119
+ def evaluate(name, options = nil, **legacy_options)
120
+ @client.evaluate_in_scope(self, name, options, **legacy_options)
121
+ end
122
+
123
+ # Start a new execution correlation context.
124
+ #
125
+ # Returns a lightweight {Execution} handle that groups telemetry signals
126
+ # under a shared execution ID. Call {Execution#end_execution} when the
127
+ # logical execution boundary is complete.
128
+ #
129
+ # @return [Execution] a correlation handle
130
+ def start_execution
131
+ @client.start_execution
132
+ end
133
+
108
134
  # Bind this scope as the active scope for the duration of a block.
109
135
  #
110
136
  # @yield Runs with this scope bound as active
@@ -136,7 +162,9 @@ module Liteguard
136
162
 
137
163
  ProtectedContext.new(
138
164
  properties: @protected_context.properties.dup,
139
- signature: @protected_context.signature.dup
165
+ signature: @protected_context.signature.dup,
166
+ issued_at: @protected_context.issued_at,
167
+ expires_at: @protected_context.expires_at
140
168
  )
141
169
  end
142
170
 
@@ -6,6 +6,12 @@ module Liteguard
6
6
 
7
7
  OPERATORS = %i[equals not_equals in not_in regex gt gte lt lte].freeze
8
8
 
9
+ GUARD_DECISION_REASONS = %i[matched_rule default_value unadopted fallback].freeze
10
+
11
+ RATE_LIMIT_EVALUATION_TARGETS = %i[active dry_run].freeze
12
+
13
+ RATE_LIMIT_DECISION_OUTCOMES = %i[within_limit limited would_limit].freeze
14
+
9
15
  # A single rule condition within a Guard.
10
16
  Rule = Data.define(
11
17
  :property_name,
@@ -15,24 +21,49 @@ module Liteguard
15
21
  :enabled
16
22
  )
17
23
 
24
+ # GuardRateLimitConfig (mirrors proto message).
25
+ GuardRateLimitConfig = Data.define(
26
+ :requests_per_minute,
27
+ :partition_properties
28
+ )
29
+
30
+ # GuardDryRunRateLimit (mirrors proto message).
31
+ GuardDryRunRateLimit = Data.define(
32
+ :dry_run_id,
33
+ :requests_per_minute,
34
+ :partition_properties
35
+ )
36
+
18
37
  # A named feature guard with an ordered rule set.
19
38
  Guard = Data.define(
20
39
  :name,
21
40
  :rules,
22
41
  :default_value,
23
42
  :adopted,
24
- :rate_limit_per_minute,
25
- :rate_limit_properties,
43
+ :rate_limit,
44
+ :dry_run_rate_limit,
26
45
  :disable_measurement
27
46
  )
28
47
 
48
+ # The detailed result of evaluating a guard.
49
+ GuardDecision = Data.define(
50
+ :name,
51
+ :is_open,
52
+ :adopted,
53
+ :reason,
54
+ :matched_rule_index,
55
+ :properties
56
+ )
57
+
29
58
  # Keys accepted by the options hash passed to {Liteguard::Client#is_open}.
30
59
  CHECK_OPTION_KEYS = %i[properties fallback disable_measurement].freeze
31
60
 
32
61
  # ProtectedContext (mirrors proto message).
33
62
  ProtectedContext = Data.define(
34
63
  :properties,
35
- :signature
64
+ :signature,
65
+ :issued_at,
66
+ :expires_at
36
67
  )
37
68
 
38
69
  # GetGuardsRequest (mirrors proto message).
@@ -45,6 +76,7 @@ module Liteguard
45
76
  # Parsed response from the guards endpoint.
46
77
  GuardsResponse = Data.define(
47
78
  :guards,
79
+ :omitted_protected_property_names,
48
80
  :refresh_rate_seconds,
49
81
  :etag
50
82
  )
@@ -85,6 +117,17 @@ module Liteguard
85
117
  :guard_execution
86
118
  )
87
119
 
120
+ # RateLimitDecision (mirrors proto message).
121
+ RateLimitDecision = Data.define(
122
+ :evaluation_target,
123
+ :dry_run_id,
124
+ :outcome,
125
+ :requests_per_minute,
126
+ :partition_properties,
127
+ :partition_values,
128
+ :count_in_window
129
+ )
130
+
88
131
  # A buffered guard-check record for upload to the data plane.
89
132
  Signal = Data.define(
90
133
  :guard_name,
@@ -99,7 +142,8 @@ module Liteguard
99
142
  :callsite_id,
100
143
  :kind,
101
144
  :dropped_signals_since_last,
102
- :measurement
145
+ :measurement,
146
+ :rate_limit_decisions
103
147
  )
104
148
 
105
149
  # UnadoptedGuardObservation (mirrors proto message).
@@ -128,8 +172,8 @@ module Liteguard
128
172
  project_client_token: nil,
129
173
  environment: nil,
130
174
  fallback: false,
131
- refresh_rate_seconds: 30,
132
- flush_rate_seconds: 10,
175
+ refresh_rate_seconds: 60,
176
+ flush_rate_seconds: 60,
133
177
  flush_size: 500,
134
178
  backend_url: "https://api.liteguard.io",
135
179
  quiet: true,
data/lib/liteguard.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require_relative "liteguard/types"
2
+ require_relative "liteguard/decision"
2
3
  require_relative "liteguard/evaluation"
3
4
  require_relative "liteguard/scope"
4
5
  require_relative "liteguard/client"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: liteguard
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.20260317
4
+ version: 0.7.20260603
5
5
  platform: ruby
6
6
  authors:
7
7
  - Liteguard
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-18 00:00:00.000000000 Z
11
+ date: 2026-06-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec
@@ -16,28 +16,28 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '3.13'
19
+ version: 3.13.2
20
20
  type: :development
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '3.13'
26
+ version: 3.13.2
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: webmock
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: '3.23'
33
+ version: 3.26.2
34
34
  type: :development
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
- version: '3.23'
40
+ version: 3.26.2
41
41
  description: Liteguard gives you feature guards, observability, and CVE auto-disable
42
42
  in one gem. Guards are evaluated entirely in-process against rules fetched once
43
43
  at startup. Every open? check is sub-millisecond with no network round-trip.
@@ -50,6 +50,7 @@ files:
50
50
  - README.md
51
51
  - lib/liteguard.rb
52
52
  - lib/liteguard/client.rb
53
+ - lib/liteguard/decision.rb
53
54
  - lib/liteguard/evaluation.rb
54
55
  - lib/liteguard/scope.rb
55
56
  - lib/liteguard/types.rb