vectra-client 0.3.0 → 0.3.2

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.
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vectra
4
+ # Audit logging for security and compliance
5
+ #
6
+ # Provides structured audit logs for security-sensitive operations,
7
+ # including authentication, authorization, and data access.
8
+ #
9
+ # @example Basic usage
10
+ # audit = Vectra::AuditLog.new(output: "log/audit.json.log")
11
+ #
12
+ # audit.log_access(
13
+ # user_id: "user123",
14
+ # operation: "query",
15
+ # index: "sensitive-data",
16
+ # result_count: 10
17
+ # )
18
+ #
19
+ class AuditLog
20
+ # Audit event types
21
+ EVENT_TYPES = %i[
22
+ access
23
+ authentication
24
+ authorization
25
+ configuration_change
26
+ credential_rotation
27
+ data_modification
28
+ error
29
+ ].freeze
30
+
31
+ attr_reader :logger, :enabled
32
+
33
+ # Initialize audit logger
34
+ #
35
+ # @param output [IO, String] Log output destination
36
+ # @param enabled [Boolean] Enable/disable audit logging
37
+ # @param metadata [Hash] Default metadata for all events
38
+ def initialize(output: $stdout, enabled: true, **metadata)
39
+ @enabled = enabled
40
+ @logger = enabled ? Vectra::JsonLogger.new(output, **metadata) : nil
41
+ end
42
+
43
+ # Log access event
44
+ #
45
+ # @param user_id [String, nil] User identifier
46
+ # @param operation [Symbol, String] Operation type
47
+ # @param index [String] Index accessed
48
+ # @param result_count [Integer, nil] Number of results
49
+ # @param metadata [Hash] Additional metadata
50
+ def log_access(operation:, index: nil, result_count: nil, user_id: nil, **metadata)
51
+ log_event(
52
+ type: :access,
53
+ user_id: user_id,
54
+ operation: operation.to_s,
55
+ resource: index,
56
+ result_count: result_count,
57
+ **metadata
58
+ )
59
+ end
60
+
61
+ # Log authentication event
62
+ #
63
+ # @param user_id [String, nil] User identifier
64
+ # @param success [Boolean] Authentication success
65
+ # @param provider [String, nil] Provider name
66
+ # @param metadata [Hash] Additional metadata
67
+ def log_authentication(success:, provider: nil, user_id: nil, **metadata)
68
+ log_event(
69
+ type: :authentication,
70
+ user_id: user_id,
71
+ success: success,
72
+ provider: provider,
73
+ **metadata
74
+ )
75
+ end
76
+
77
+ # Log authorization event
78
+ #
79
+ # @param user_id [String] User identifier
80
+ # @param resource [String] Resource accessed
81
+ # @param allowed [Boolean] Authorization result
82
+ # @param reason [String, nil] Reason if denied
83
+ def log_authorization(user_id:, resource:, allowed:, reason: nil)
84
+ log_event(
85
+ type: :authorization,
86
+ user_id: user_id,
87
+ resource: resource,
88
+ allowed: allowed,
89
+ reason: reason
90
+ )
91
+ end
92
+
93
+ # Log configuration change
94
+ #
95
+ # @param user_id [String] User who made change
96
+ # @param change_type [String] Type of change
97
+ # @param old_value [Object, nil] Previous value
98
+ # @param new_value [Object, nil] New value
99
+ def log_configuration_change(user_id:, change_type:, old_value: nil, new_value: nil)
100
+ log_event(
101
+ type: :configuration_change,
102
+ user_id: user_id,
103
+ change_type: change_type,
104
+ old_value: sanitize_value(old_value),
105
+ new_value: sanitize_value(new_value)
106
+ )
107
+ end
108
+
109
+ # Log credential rotation
110
+ #
111
+ # @param provider [String] Provider name
112
+ # @param success [Boolean] Rotation success
113
+ # @param rotated_by [String, nil] User who initiated rotation
114
+ def log_credential_rotation(provider:, success:, rotated_by: nil)
115
+ log_event(
116
+ type: :credential_rotation,
117
+ provider: provider,
118
+ success: success,
119
+ rotated_by: rotated_by
120
+ )
121
+ end
122
+
123
+ # Log data modification
124
+ #
125
+ # @param user_id [String, nil] User identifier
126
+ # @param operation [String] Operation (upsert, delete, update)
127
+ # @param index [String] Index modified
128
+ # @param record_count [Integer] Number of records affected
129
+ def log_data_modification(operation:, index:, record_count:, user_id: nil)
130
+ log_event(
131
+ type: :data_modification,
132
+ user_id: user_id,
133
+ operation: operation.to_s,
134
+ resource: index,
135
+ record_count: record_count
136
+ )
137
+ end
138
+
139
+ # Log error event
140
+ #
141
+ # @param error [Exception] Error that occurred
142
+ # @param context [Hash] Error context
143
+ def log_error(error:, **context)
144
+ log_event(
145
+ type: :error,
146
+ error_class: error.class.name,
147
+ error_message: error.message,
148
+ severity: error_severity(error),
149
+ **context
150
+ )
151
+ end
152
+
153
+ private
154
+
155
+ def log_event(type:, **data)
156
+ return unless @enabled && @logger
157
+
158
+ @logger.info(
159
+ "audit.#{type}",
160
+ event_type: type.to_s,
161
+ timestamp: Time.now.utc.iso8601(3),
162
+ **data
163
+ )
164
+ end
165
+
166
+ def sanitize_value(value)
167
+ case value
168
+ when String
169
+ # Mask sensitive values: keep a short prefix and suffix, hide the middle
170
+ return value unless value.length >= 10
171
+
172
+ prefix = value[0, 9]
173
+ suffix = value[-4, 4]
174
+ "#{prefix}...#{suffix}"
175
+ when Hash
176
+ value.transform_values { |v| sanitize_value(v) }
177
+ else
178
+ value
179
+ end
180
+ end
181
+
182
+ def error_severity(error)
183
+ case error
184
+ when Vectra::AuthenticationError
185
+ "critical"
186
+ when Vectra::ServerError, Vectra::ConnectionError
187
+ "high"
188
+ when Vectra::RateLimitError
189
+ "medium"
190
+ else
191
+ "low"
192
+ end
193
+ end
194
+ end
195
+
196
+ # Global audit log instance
197
+ module AuditLogging
198
+ class << self
199
+ attr_accessor :audit_log
200
+
201
+ # Setup global audit logging
202
+ #
203
+ # @param output [IO, String] Log output
204
+ # @param enabled [Boolean] Enable audit logging
205
+ # @param metadata [Hash] Default metadata
206
+ # @return [AuditLog]
207
+ def setup!(output: "log/audit.json.log", enabled: true, **metadata)
208
+ @audit_log = AuditLog.new(output: output, enabled: enabled, **metadata)
209
+ end
210
+
211
+ # Log audit event
212
+ #
213
+ # @param type [Symbol] Event type
214
+ # @param data [Hash] Event data
215
+ def log(type, **data)
216
+ return unless @audit_log
217
+
218
+ @audit_log.public_send("log_#{type}", **data)
219
+ rescue NoMethodError
220
+ # Event type not supported
221
+ @audit_log.instance_variable_get(:@logger)&.info("audit.#{type}", **data)
222
+ end
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,336 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vectra
4
+ # Circuit Breaker pattern for handling provider failures
5
+ #
6
+ # Prevents cascading failures by temporarily stopping requests to a failing provider.
7
+ # The circuit has three states:
8
+ # - :closed - Normal operation, requests pass through
9
+ # - :open - Requests fail immediately without calling provider
10
+ # - :half_open - Limited requests allowed to test if provider recovered
11
+ #
12
+ # @example Basic usage
13
+ # breaker = Vectra::CircuitBreaker.new(
14
+ # failure_threshold: 5,
15
+ # recovery_timeout: 30
16
+ # )
17
+ #
18
+ # breaker.call do
19
+ # client.query(index: "my-index", vector: vec, top_k: 10)
20
+ # end
21
+ #
22
+ # @example With fallback
23
+ # breaker.call(fallback: -> { cached_results }) do
24
+ # client.query(...)
25
+ # end
26
+ #
27
+ # @example Per-provider circuit breakers
28
+ # breakers = {
29
+ # pinecone: Vectra::CircuitBreaker.new(name: "pinecone"),
30
+ # qdrant: Vectra::CircuitBreaker.new(name: "qdrant")
31
+ # }
32
+ #
33
+ class CircuitBreaker
34
+ STATES = [:closed, :open, :half_open].freeze
35
+
36
+ # Error raised when circuit is open
37
+ class OpenCircuitError < Vectra::Error
38
+ attr_reader :circuit_name, :failures, :opened_at
39
+
40
+ def initialize(circuit_name:, failures:, opened_at:)
41
+ @circuit_name = circuit_name
42
+ @failures = failures
43
+ @opened_at = opened_at
44
+ super("Circuit '#{circuit_name}' is open after #{failures} failures")
45
+ end
46
+ end
47
+
48
+ attr_reader :name, :state, :failure_count, :success_count,
49
+ :last_failure_at, :opened_at
50
+
51
+ # Initialize a new circuit breaker
52
+ #
53
+ # @param name [String] Circuit name for logging/metrics
54
+ # @param failure_threshold [Integer] Failures before opening circuit (default: 5)
55
+ # @param success_threshold [Integer] Successes in half-open to close (default: 3)
56
+ # @param recovery_timeout [Integer] Seconds before trying half-open (default: 30)
57
+ # @param monitored_errors [Array<Class>] Errors that count as failures
58
+ def initialize(
59
+ name: "default",
60
+ failure_threshold: 5,
61
+ success_threshold: 3,
62
+ recovery_timeout: 30,
63
+ monitored_errors: nil
64
+ )
65
+ @name = name
66
+ @failure_threshold = failure_threshold
67
+ @success_threshold = success_threshold
68
+ @recovery_timeout = recovery_timeout
69
+ @monitored_errors = monitored_errors || default_monitored_errors
70
+
71
+ @state = :closed
72
+ @failure_count = 0
73
+ @success_count = 0
74
+ @last_failure_at = nil
75
+ @opened_at = nil
76
+ @mutex = Mutex.new
77
+ end
78
+
79
+ # Execute block through circuit breaker
80
+ #
81
+ # @param fallback [Proc, nil] Fallback to call when circuit is open
82
+ # @yield The operation to execute
83
+ # @return [Object] Result of block or fallback
84
+ # @raise [OpenCircuitError] If circuit is open and no fallback provided
85
+ def call(fallback: nil, &)
86
+ check_state!
87
+
88
+ if open?
89
+ return handle_open_circuit(fallback)
90
+ end
91
+
92
+ execute_with_monitoring(&)
93
+ rescue *@monitored_errors => e
94
+ record_failure(e)
95
+ raise
96
+ end
97
+
98
+ # Force circuit to closed state (manual reset)
99
+ #
100
+ # @return [void]
101
+ def reset!
102
+ @mutex.synchronize do
103
+ transition_to(:closed)
104
+ @failure_count = 0
105
+ @success_count = 0
106
+ @last_failure_at = nil
107
+ @opened_at = nil
108
+ end
109
+ end
110
+
111
+ # Force circuit to open state (manual trip)
112
+ #
113
+ # @return [void]
114
+ def trip!
115
+ @mutex.synchronize do
116
+ transition_to(:open)
117
+ @opened_at = Time.now
118
+ end
119
+ end
120
+
121
+ # Check if circuit is closed (normal operation)
122
+ #
123
+ # @return [Boolean]
124
+ def closed?
125
+ state == :closed
126
+ end
127
+
128
+ # Check if circuit is open (blocking requests)
129
+ #
130
+ # @return [Boolean]
131
+ def open?
132
+ state == :open
133
+ end
134
+
135
+ # Check if circuit is half-open (testing recovery)
136
+ #
137
+ # @return [Boolean]
138
+ def half_open?
139
+ state == :half_open
140
+ end
141
+
142
+ # Get circuit statistics
143
+ #
144
+ # @return [Hash]
145
+ def stats
146
+ {
147
+ name: name,
148
+ state: state,
149
+ failure_count: failure_count,
150
+ success_count: success_count,
151
+ failure_threshold: @failure_threshold,
152
+ success_threshold: @success_threshold,
153
+ recovery_timeout: @recovery_timeout,
154
+ last_failure_at: last_failure_at,
155
+ opened_at: opened_at
156
+ }
157
+ end
158
+
159
+ private
160
+
161
+ def default_monitored_errors
162
+ [
163
+ Vectra::ServerError,
164
+ Vectra::ConnectionError,
165
+ Vectra::TimeoutError
166
+ ]
167
+ end
168
+
169
+ def check_state!
170
+ @mutex.synchronize do
171
+ # Check if we should transition from open to half-open
172
+ if open? && recovery_timeout_elapsed?
173
+ transition_to(:half_open)
174
+ @success_count = 0
175
+ end
176
+ end
177
+ end
178
+
179
+ def recovery_timeout_elapsed?
180
+ return false unless opened_at
181
+
182
+ Time.now - opened_at >= @recovery_timeout
183
+ end
184
+
185
+ def handle_open_circuit(fallback)
186
+ if fallback
187
+ log_fallback
188
+ fallback.call
189
+ else
190
+ raise OpenCircuitError.new(
191
+ circuit_name: name,
192
+ failures: failure_count,
193
+ opened_at: opened_at
194
+ )
195
+ end
196
+ end
197
+
198
+ def execute_with_monitoring
199
+ result = yield
200
+ record_success
201
+ result
202
+ end
203
+
204
+ def record_success
205
+ @mutex.synchronize do
206
+ @success_count += 1
207
+
208
+ # In half-open, check if we should close
209
+ if half_open? && @success_count >= @success_threshold
210
+ transition_to(:closed)
211
+ @failure_count = 0
212
+ log_circuit_closed
213
+ end
214
+ end
215
+ end
216
+
217
+ def record_failure(error)
218
+ @mutex.synchronize do
219
+ @failure_count += 1
220
+ @last_failure_at = Time.now
221
+
222
+ # In half-open, immediately open again
223
+ if half_open?
224
+ transition_to(:open)
225
+ @opened_at = Time.now
226
+ log_circuit_reopened(error)
227
+ return
228
+ end
229
+
230
+ # In closed, check threshold
231
+ if closed? && @failure_count >= @failure_threshold
232
+ transition_to(:open)
233
+ @opened_at = Time.now
234
+ log_circuit_opened(error)
235
+ end
236
+ end
237
+ end
238
+
239
+ def transition_to(new_state)
240
+ @state = new_state
241
+ end
242
+
243
+ def log_circuit_opened(error)
244
+ logger&.error(
245
+ "[Vectra::CircuitBreaker] Circuit '#{name}' opened after #{failure_count} failures. " \
246
+ "Last error: #{error.class} - #{error.message}"
247
+ )
248
+ end
249
+
250
+ def log_circuit_closed
251
+ logger&.info(
252
+ "[Vectra::CircuitBreaker] Circuit '#{name}' closed after #{success_count} successes"
253
+ )
254
+ end
255
+
256
+ def log_circuit_reopened(error)
257
+ logger&.warn(
258
+ "[Vectra::CircuitBreaker] Circuit '#{name}' reopened. " \
259
+ "Recovery failed: #{error.class} - #{error.message}"
260
+ )
261
+ end
262
+
263
+ def log_fallback
264
+ logger&.info(
265
+ "[Vectra::CircuitBreaker] Circuit '#{name}' open, using fallback"
266
+ )
267
+ end
268
+
269
+ def logger
270
+ Vectra.configuration.logger
271
+ end
272
+ end
273
+
274
+ # Circuit breaker registry for managing multiple circuits
275
+ #
276
+ # @example
277
+ # Vectra::CircuitBreakerRegistry.register(:pinecone, failure_threshold: 3)
278
+ # Vectra::CircuitBreakerRegistry.register(:qdrant, failure_threshold: 5)
279
+ #
280
+ # Vectra::CircuitBreakerRegistry[:pinecone].call { ... }
281
+ #
282
+ module CircuitBreakerRegistry
283
+ class << self
284
+ # Get or create a circuit breaker
285
+ #
286
+ # @param name [Symbol, String] Circuit name
287
+ # @return [CircuitBreaker]
288
+ def [](name)
289
+ circuits[name.to_sym]
290
+ end
291
+
292
+ # Register a new circuit breaker
293
+ #
294
+ # @param name [Symbol, String] Circuit name
295
+ # @param options [Hash] CircuitBreaker options
296
+ # @return [CircuitBreaker]
297
+ def register(name, **options)
298
+ circuits[name.to_sym] = CircuitBreaker.new(name: name.to_s, **options)
299
+ end
300
+
301
+ # Get all registered circuits
302
+ #
303
+ # @return [Hash<Symbol, CircuitBreaker>]
304
+ def all
305
+ circuits.dup
306
+ end
307
+
308
+ # Reset all circuits
309
+ #
310
+ # @return [void]
311
+ def reset_all!
312
+ circuits.each_value(&:reset!)
313
+ end
314
+
315
+ # Get stats for all circuits
316
+ #
317
+ # @return [Hash<Symbol, Hash>]
318
+ def stats
319
+ circuits.transform_values(&:stats)
320
+ end
321
+
322
+ # Clear all registered circuits
323
+ #
324
+ # @return [void]
325
+ def clear!
326
+ @circuits = {}
327
+ end
328
+
329
+ private
330
+
331
+ def circuits
332
+ @circuits ||= {}
333
+ end
334
+ end
335
+ end
336
+ end
data/lib/vectra/client.rb CHANGED
@@ -25,6 +25,8 @@ module Vectra
25
25
  # )
26
26
  #
27
27
  class Client
28
+ include HealthCheck
29
+
28
30
  attr_reader :config, :provider
29
31
 
30
32
  # Initialize a new Client