liteguard 0.4.20260317 → 0.6.20260405

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: 64fb509c369cbd43217407216706597424220bd7143e03d460b3ad4428b4ff7a
4
+ data.tar.gz: b53c0c549c66353d3cb6f9aea811e5dffeb9e3b53f74f49a3cb315a17aa513e8
5
5
  SHA512:
6
- metadata.gz: 36ffdc869fa486befba275c7a7683879b6ec0014071ba17d6339f28bf173980d4d8d6755f6c19d825b32f215f486cd62f9cbe64887bf1a4fe2c13ddc5401f763
7
- data.tar.gz: 44cbf8a627c3edb19bbc1d0152d7670661279a728774dbf72def1e28329a6e6c8a7779b598eb37a23f12bf974bd956a4d778902af9b7d94e078b66efefeda12b
6
+ metadata.gz: 3488d12c50f5c9d3a6e48029ed450e2062f71efd24d06ebf217a88ee4742e0e3317c0731e4a2b13e38eebb33291bdc3b7745e8d5607451734c05a3378d58f383
7
+ data.tar.gz: e5a64bcf4c70b0692a88841666e1eb1358859073ac2d1d51fcae2a672ecf9e5dd6895b1524e1827e5a61995b6fbb4186621cf451ada5bf8cdd4410193ed7979e
data/README.md CHANGED
@@ -47,16 +47,29 @@ 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.
58
70
  - Prefer explicit client and scope usage.
59
71
  - Unadopted guards default open and emit no signals.
72
+ - 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
73
 
61
74
  ## Development
62
75
 
@@ -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,74 @@ 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
+ result = detailed.result
374
+ if result && guard.rate_limit_per_minute.to_i > 0
375
+ result = check_rate_limit(name.to_s, guard.rate_limit_per_minute, guard.rate_limit_properties, props)
376
+ end
377
+
378
+ reason = detailed.matched_rule_index >= 0 ? "matched_rule" : "default_value"
379
+ matched_idx = detailed.matched_rule_index >= 0 ? detailed.matched_rule_index : nil
380
+
381
+ buffer_signal(
382
+ name.to_s, result, props,
383
+ kind: "guard_check",
384
+ measurement: measurement_enabled?(guard, options) ? capture_guard_check_measurement : nil
385
+ )
386
+
387
+ GuardDecision.new(
388
+ name: name.to_s, is_open: result, adopted: guard.adopted,
389
+ reason: reason, matched_rule_index: matched_idx, properties: props.dup
390
+ )
391
+ end
392
+
350
393
  # Evaluate a guard and execute the block only when it resolves open.
351
394
  #
352
395
  # @param name [String] guard name to evaluate
@@ -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
@@ -105,6 +105,30 @@ module Liteguard
105
105
  @client.execute_if_open_in_scope(self, name, options, **legacy_options, &block)
106
106
  end
107
107
 
108
+ # Evaluate a guard and return a {GuardDecision} with full reasoning.
109
+ #
110
+ # Like {#is_open} but returns the full decision context including the
111
+ # reason, matched rule index, and the merged properties used. A
112
+ # +guard_check+ telemetry signal is buffered.
113
+ #
114
+ # @param name [String] guard name to evaluate
115
+ # @param options [Hash, nil] optional per-call overrides
116
+ # @return [GuardDecision] the evaluation decision
117
+ def evaluate(name, options = nil, **legacy_options)
118
+ @client.evaluate_in_scope(self, name, options, **legacy_options)
119
+ end
120
+
121
+ # Start a new execution correlation context.
122
+ #
123
+ # Returns a lightweight {Execution} handle that groups telemetry signals
124
+ # under a shared execution ID. Call {Execution#end_execution} when the
125
+ # logical execution boundary is complete.
126
+ #
127
+ # @return [Execution] a correlation handle
128
+ def start_execution
129
+ @client.start_execution
130
+ end
131
+
108
132
  # Bind this scope as the active scope for the duration of a block.
109
133
  #
110
134
  # @yield Runs with this scope bound as active
@@ -6,6 +6,8 @@ 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
+
9
11
  # A single rule condition within a Guard.
10
12
  Rule = Data.define(
11
13
  :property_name,
@@ -26,6 +28,16 @@ module Liteguard
26
28
  :disable_measurement
27
29
  )
28
30
 
31
+ # The detailed result of evaluating a guard.
32
+ GuardDecision = Data.define(
33
+ :name,
34
+ :is_open,
35
+ :adopted,
36
+ :reason,
37
+ :matched_rule_index,
38
+ :properties
39
+ )
40
+
29
41
  # Keys accepted by the options hash passed to {Liteguard::Client#is_open}.
30
42
  CHECK_OPTION_KEYS = %i[properties fallback disable_measurement].freeze
31
43
 
@@ -128,8 +140,8 @@ module Liteguard
128
140
  project_client_token: nil,
129
141
  environment: nil,
130
142
  fallback: false,
131
- refresh_rate_seconds: 30,
132
- flush_rate_seconds: 10,
143
+ refresh_rate_seconds: 60,
144
+ flush_rate_seconds: 60,
133
145
  flush_size: 500,
134
146
  backend_url: "https://api.liteguard.io",
135
147
  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.6.20260405
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-04-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec
@@ -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