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,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vectra
4
+ # Credential rotation helper for seamless API key updates
5
+ #
6
+ # Provides utilities for rotating API keys without downtime by supporting
7
+ # multiple credentials and gradual migration.
8
+ #
9
+ # @example Basic rotation
10
+ # rotator = Vectra::CredentialRotator.new(
11
+ # primary_key: ENV['PINECONE_API_KEY'],
12
+ # secondary_key: ENV['PINECONE_API_KEY_NEW']
13
+ # )
14
+ #
15
+ # # Test new key before switching
16
+ # if rotator.test_secondary
17
+ # rotator.switch_to_secondary
18
+ # end
19
+ #
20
+ class CredentialRotator
21
+ attr_reader :primary_key, :secondary_key, :current_key
22
+
23
+ # Initialize credential rotator
24
+ #
25
+ # @param primary_key [String] Current active API key
26
+ # @param secondary_key [String, nil] New API key to rotate to
27
+ # @param provider [Symbol] Provider name
28
+ # @param test_client [Client, nil] Client instance for testing
29
+ def initialize(primary_key:, secondary_key: nil, provider: nil, test_client: nil)
30
+ @primary_key = primary_key
31
+ @secondary_key = secondary_key
32
+ @provider = provider
33
+ @test_client = test_client
34
+ @current_key = primary_key
35
+ @rotation_complete = false
36
+ end
37
+
38
+ # Test if secondary key is valid
39
+ #
40
+ # @param timeout [Float] Test timeout in seconds
41
+ # @return [Boolean] true if secondary key works
42
+ def test_secondary(timeout: 5)
43
+ return false if secondary_key.nil? || secondary_key.empty?
44
+
45
+ client = build_test_client(secondary_key)
46
+ client.healthy?(timeout: timeout)
47
+ rescue StandardError
48
+ false
49
+ end
50
+
51
+ # Switch to secondary key
52
+ #
53
+ # @param validate [Boolean] Validate key before switching
54
+ # @return [Boolean] true if switched successfully
55
+ # rubocop:disable Naming/PredicateMethod
56
+ def switch_to_secondary(validate: true)
57
+ return false if secondary_key.nil? || secondary_key.empty?
58
+
59
+ if validate && !test_secondary
60
+ raise CredentialRotationError, "Secondary key validation failed"
61
+ end
62
+
63
+ @current_key = secondary_key
64
+ @rotation_complete = true
65
+ true
66
+ end
67
+ # rubocop:enable Naming/PredicateMethod
68
+
69
+ # Rollback to primary key
70
+ #
71
+ # @return [void]
72
+ def rollback
73
+ @current_key = primary_key
74
+ @rotation_complete = false
75
+ end
76
+
77
+ # Check if rotation is complete
78
+ #
79
+ # @return [Boolean]
80
+ def rotation_complete?
81
+ @rotation_complete
82
+ end
83
+
84
+ # Get current active key
85
+ #
86
+ # @return [String]
87
+ def active_key
88
+ @current_key
89
+ end
90
+
91
+ private
92
+
93
+ def build_test_client(key)
94
+ return @test_client if @test_client
95
+
96
+ config = Vectra::Configuration.new
97
+ config.provider = @provider || Vectra.configuration.provider
98
+ config.api_key = key
99
+ config.host = Vectra.configuration.host
100
+ config.environment = Vectra.configuration.environment
101
+
102
+ Vectra::Client.new(
103
+ provider: config.provider,
104
+ api_key: key,
105
+ host: config.host,
106
+ environment: config.environment
107
+ )
108
+ end
109
+ end
110
+
111
+ # Error raised during credential rotation
112
+ class CredentialRotationError < Vectra::Error; end
113
+
114
+ # Credential rotation manager for multiple providers
115
+ #
116
+ # @example
117
+ # manager = Vectra::CredentialRotationManager.new
118
+ #
119
+ # manager.register(:pinecone,
120
+ # primary: ENV['PINECONE_API_KEY'],
121
+ # secondary: ENV['PINECONE_API_KEY_NEW']
122
+ # )
123
+ #
124
+ # manager.rotate_all
125
+ #
126
+ module CredentialRotationManager
127
+ class << self
128
+ # Register a credential rotator
129
+ #
130
+ # @param provider [Symbol] Provider name
131
+ # @param primary [String] Primary API key
132
+ # @param secondary [String, nil] Secondary API key
133
+ # @return [CredentialRotator]
134
+ def register(provider, primary:, secondary: nil)
135
+ rotators[provider] = CredentialRotator.new(
136
+ primary_key: primary,
137
+ secondary_key: secondary,
138
+ provider: provider
139
+ )
140
+ end
141
+
142
+ # Get rotator for provider
143
+ #
144
+ # @param provider [Symbol] Provider name
145
+ # @return [CredentialRotator, nil]
146
+ def [](provider)
147
+ rotators[provider.to_sym]
148
+ end
149
+
150
+ # Test all secondary keys
151
+ #
152
+ # @return [Hash<Symbol, Boolean>] Test results
153
+ def test_all_secondary
154
+ rotators.transform_values(&:test_secondary)
155
+ end
156
+
157
+ # Rotate all providers
158
+ #
159
+ # @param validate [Boolean] Validate before rotating
160
+ # @return [Hash<Symbol, Boolean>] Rotation results
161
+ def rotate_all(validate: true)
162
+ rotators.transform_values { |r| r.switch_to_secondary(validate: validate) }
163
+ end
164
+
165
+ # Rollback all rotations
166
+ #
167
+ # @return [void]
168
+ def rollback_all
169
+ rotators.each_value(&:rollback)
170
+ end
171
+
172
+ # Get rotation status
173
+ #
174
+ # @return [Hash<Symbol, Hash>]
175
+ def status
176
+ rotators.transform_values do |r|
177
+ {
178
+ rotation_complete: r.rotation_complete?,
179
+ has_secondary: !r.secondary_key.nil?,
180
+ active_key: "#{r.active_key[0, 8]}..." # First 8 chars only
181
+ }
182
+ end
183
+ end
184
+
185
+ # Clear all rotators
186
+ #
187
+ # @return [void]
188
+ def clear!
189
+ @rotators = {}
190
+ end
191
+
192
+ private
193
+
194
+ def rotators
195
+ @rotators ||= {}
196
+ end
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module Vectra
6
+ # Health check functionality for Vectra clients
7
+ #
8
+ # Provides health check methods to verify connectivity and status
9
+ # of vector database providers.
10
+ #
11
+ # @example Basic health check
12
+ # client = Vectra::Client.new(provider: :pinecone, ...)
13
+ # result = client.health_check
14
+ # puts result[:healthy] # => true/false
15
+ #
16
+ # @example Detailed health check
17
+ # result = client.health_check(
18
+ # index: "my-index",
19
+ # include_stats: true
20
+ # )
21
+ #
22
+ module HealthCheck
23
+ # Perform health check on the provider
24
+ #
25
+ # @param index [String, nil] Index to check (uses first available if nil)
26
+ # @param include_stats [Boolean] Include index statistics
27
+ # @param timeout [Float] Health check timeout in seconds
28
+ # @return [HealthCheckResult]
29
+ def health_check(index: nil, include_stats: false, timeout: 5)
30
+ start_time = Time.now
31
+
32
+ indexes = with_timeout(timeout) { list_indexes }
33
+ index_name = index || indexes.first&.dig(:name)
34
+
35
+ result = base_result(start_time, indexes)
36
+ add_index_stats(result, index_name, include_stats, timeout)
37
+ add_pool_stats(result)
38
+
39
+ HealthCheckResult.new(**result)
40
+ rescue StandardError => e
41
+ failure_result(start_time, e)
42
+ end
43
+
44
+ # Quick health check - just tests connectivity
45
+ #
46
+ # @param timeout [Float] Timeout in seconds
47
+ # @return [Boolean] true if healthy
48
+ def healthy?(timeout: 5)
49
+ health_check(timeout: timeout).healthy?
50
+ end
51
+
52
+ private
53
+
54
+ def with_timeout(seconds, &)
55
+ Timeout.timeout(seconds, &)
56
+ rescue Timeout::Error
57
+ raise Vectra::TimeoutError, "Health check timed out after #{seconds}s"
58
+ end
59
+
60
+ def base_result(start_time, indexes)
61
+ {
62
+ healthy: true,
63
+ provider: provider_name,
64
+ latency_ms: latency_since(start_time),
65
+ indexes_available: indexes.size,
66
+ checked_at: current_time_iso
67
+ }
68
+ end
69
+
70
+ def add_index_stats(result, index_name, include_stats, timeout)
71
+ return unless include_stats && index_name
72
+
73
+ stats = with_timeout(timeout) { stats(index: index_name) }
74
+ result[:index] = index_name
75
+ result[:stats] = {
76
+ vector_count: stats[:total_vector_count],
77
+ dimension: stats[:dimension]
78
+ }.compact
79
+ end
80
+
81
+ def add_pool_stats(result)
82
+ return unless provider.respond_to?(:pool_stats)
83
+
84
+ pool = provider.pool_stats
85
+ result[:pool] = pool unless pool[:status] == "not_initialized"
86
+ end
87
+
88
+ def failure_result(start_time, error)
89
+ HealthCheckResult.new(
90
+ healthy: false,
91
+ provider: provider_name,
92
+ latency_ms: latency_since(start_time),
93
+ error: error.class.name,
94
+ error_message: error.message,
95
+ checked_at: current_time_iso
96
+ )
97
+ end
98
+
99
+ def latency_since(start_time)
100
+ ((Time.now - start_time) * 1000).round(2)
101
+ end
102
+
103
+ def current_time_iso
104
+ Time.now.utc.iso8601
105
+ end
106
+ end
107
+
108
+ # Health check result object
109
+ #
110
+ # @example
111
+ # result = client.health_check
112
+ # if result.healthy?
113
+ # puts "All good! Latency: #{result.latency_ms}ms"
114
+ # else
115
+ # puts "Error: #{result.error_message}"
116
+ # end
117
+ #
118
+ class HealthCheckResult
119
+ attr_reader :provider, :latency_ms, :indexes_available, :checked_at,
120
+ :index, :stats, :pool, :error, :error_message
121
+
122
+ def initialize(healthy:, provider:, latency_ms:, checked_at:,
123
+ indexes_available: nil, index: nil, stats: nil,
124
+ pool: nil, error: nil, error_message: nil)
125
+ @healthy = healthy
126
+ @provider = provider
127
+ @latency_ms = latency_ms
128
+ @checked_at = checked_at
129
+ @indexes_available = indexes_available
130
+ @index = index
131
+ @stats = stats
132
+ @pool = pool
133
+ @error = error
134
+ @error_message = error_message
135
+ end
136
+
137
+ # Check if the health check passed
138
+ #
139
+ # @return [Boolean]
140
+ def healthy?
141
+ @healthy
142
+ end
143
+
144
+ # Check if the health check failed
145
+ #
146
+ # @return [Boolean]
147
+ def unhealthy?
148
+ !@healthy
149
+ end
150
+
151
+ # Convert to hash
152
+ #
153
+ # @return [Hash]
154
+ def to_h
155
+ {
156
+ healthy: @healthy,
157
+ provider: provider,
158
+ latency_ms: latency_ms,
159
+ checked_at: checked_at,
160
+ indexes_available: indexes_available,
161
+ index: index,
162
+ stats: stats,
163
+ pool: pool,
164
+ error: error,
165
+ error_message: error_message
166
+ }.compact
167
+ end
168
+
169
+ # Convert to JSON
170
+ #
171
+ # @return [String]
172
+ def to_json(*)
173
+ JSON.generate(to_h)
174
+ end
175
+ end
176
+
177
+ # Aggregate health checker for multiple providers
178
+ #
179
+ # @example
180
+ # checker = Vectra::AggregateHealthCheck.new(
181
+ # pinecone: pinecone_client,
182
+ # qdrant: qdrant_client,
183
+ # pgvector: pgvector_client
184
+ # )
185
+ #
186
+ # result = checker.check_all
187
+ # puts result[:overall_healthy]
188
+ #
189
+ class AggregateHealthCheck
190
+ attr_reader :clients
191
+
192
+ # Initialize aggregate health checker
193
+ #
194
+ # @param clients [Hash<Symbol, Client>] Named clients to check
195
+ def initialize(**clients)
196
+ @clients = clients
197
+ end
198
+
199
+ # Check health of all clients
200
+ #
201
+ # @param parallel [Boolean] Run checks in parallel
202
+ # @param timeout [Float] Timeout per check
203
+ # @return [Hash] Aggregate results
204
+ def check_all(parallel: true, timeout: 5)
205
+ start_time = Time.now
206
+
207
+ results = if parallel
208
+ check_parallel(timeout)
209
+ else
210
+ check_sequential(timeout)
211
+ end
212
+
213
+ healthy_count = results.count { |_, r| r.healthy? }
214
+ all_healthy = healthy_count == results.size
215
+
216
+ {
217
+ overall_healthy: all_healthy,
218
+ healthy_count: healthy_count,
219
+ total_count: results.size,
220
+ total_latency_ms: ((Time.now - start_time) * 1000).round(2),
221
+ checked_at: Time.now.utc.iso8601,
222
+ results: results.transform_values(&:to_h)
223
+ }
224
+ end
225
+
226
+ # Check if all providers are healthy
227
+ #
228
+ # @return [Boolean]
229
+ def all_healthy?(timeout: 5)
230
+ check_all(timeout: timeout)[:overall_healthy]
231
+ end
232
+
233
+ # Check if any provider is healthy
234
+ #
235
+ # @return [Boolean]
236
+ def any_healthy?(timeout: 5)
237
+ check_all(timeout: timeout)[:healthy_count].positive?
238
+ end
239
+
240
+ private
241
+
242
+ def check_parallel(timeout)
243
+ threads = clients.map do |name, client|
244
+ Thread.new { [name, client.health_check(timeout: timeout)] }
245
+ end
246
+
247
+ threads.to_h(&:value)
248
+ end
249
+
250
+ def check_sequential(timeout)
251
+ clients.transform_values { |client| client.health_check(timeout: timeout) }
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vectra
4
+ module Instrumentation
5
+ # Honeybadger error tracking adapter
6
+ #
7
+ # Automatically reports Vectra errors to Honeybadger with context.
8
+ #
9
+ # @example Enable Honeybadger instrumentation
10
+ # # config/initializers/vectra.rb
11
+ # require 'vectra/instrumentation/honeybadger'
12
+ #
13
+ # Vectra.configure do |config|
14
+ # config.instrumentation = true
15
+ # end
16
+ #
17
+ # Vectra::Instrumentation::Honeybadger.setup!
18
+ #
19
+ module Honeybadger
20
+ class << self
21
+ # Setup Honeybadger instrumentation
22
+ #
23
+ # @param notify_on_rate_limit [Boolean] Report rate limit errors (default: false)
24
+ # @param notify_on_validation [Boolean] Report validation errors (default: false)
25
+ # @return [void]
26
+ def setup!(notify_on_rate_limit: false, notify_on_validation: false)
27
+ @notify_on_rate_limit = notify_on_rate_limit
28
+ @notify_on_validation = notify_on_validation
29
+
30
+ unless defined?(::Honeybadger)
31
+ warn "Honeybadger gem not found. Install with: gem 'honeybadger'"
32
+ return
33
+ end
34
+
35
+ Vectra::Instrumentation.on_operation do |event|
36
+ add_breadcrumb(event)
37
+ notify_error(event) if should_notify?(event)
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ # Add breadcrumb for operation tracing
44
+ def add_breadcrumb(event)
45
+ ::Honeybadger.add_breadcrumb(
46
+ "Vectra #{event.operation}",
47
+ category: "vectra",
48
+ metadata: {
49
+ provider: event.provider.to_s,
50
+ operation: event.operation.to_s,
51
+ index: event.index,
52
+ duration_ms: event.duration,
53
+ success: event.success?,
54
+ vector_count: event.metadata[:vector_count],
55
+ result_count: event.metadata[:result_count]
56
+ }.compact
57
+ )
58
+ end
59
+
60
+ # Check if error should be reported
61
+ def should_notify?(event)
62
+ return false if event.success?
63
+
64
+ case event.error
65
+ when Vectra::RateLimitError
66
+ @notify_on_rate_limit
67
+ when Vectra::ValidationError
68
+ @notify_on_validation
69
+ else
70
+ true
71
+ end
72
+ end
73
+
74
+ # Notify Honeybadger of error
75
+ def notify_error(event)
76
+ ::Honeybadger.notify(
77
+ event.error,
78
+ context: {
79
+ vectra: {
80
+ provider: event.provider.to_s,
81
+ operation: event.operation.to_s,
82
+ index: event.index,
83
+ duration_ms: event.duration,
84
+ metadata: event.metadata
85
+ }
86
+ },
87
+ tags: build_tags(event),
88
+ fingerprint: build_fingerprint(event)
89
+ )
90
+ end
91
+
92
+ # Build tags for error grouping
93
+ def build_tags(event)
94
+ [
95
+ "vectra",
96
+ "provider:#{event.provider}",
97
+ "operation:#{event.operation}",
98
+ error_severity(event.error)
99
+ ]
100
+ end
101
+
102
+ # Build fingerprint for error grouping
103
+ def build_fingerprint(event)
104
+ [
105
+ "vectra",
106
+ event.provider.to_s,
107
+ event.operation.to_s,
108
+ event.error.class.name
109
+ ].join("-")
110
+ end
111
+
112
+ # Determine severity tag
113
+ def error_severity(error)
114
+ case error
115
+ when Vectra::AuthenticationError
116
+ "severity:critical"
117
+ when Vectra::ServerError
118
+ "severity:high"
119
+ when Vectra::RateLimitError
120
+ "severity:medium"
121
+ else
122
+ "severity:low"
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vectra
4
+ module Instrumentation
5
+ # Sentry error tracking adapter
6
+ #
7
+ # Automatically reports Vectra errors to Sentry with context.
8
+ #
9
+ # @example Enable Sentry instrumentation
10
+ # # config/initializers/vectra.rb
11
+ # require 'vectra/instrumentation/sentry'
12
+ #
13
+ # Vectra.configure do |config|
14
+ # config.instrumentation = true
15
+ # end
16
+ #
17
+ # Vectra::Instrumentation::Sentry.setup!
18
+ #
19
+ module Sentry
20
+ class << self
21
+ # Setup Sentry instrumentation
22
+ #
23
+ # @param capture_all_errors [Boolean] Capture all errors, not just failures (default: false)
24
+ # @param fingerprint_by_operation [Boolean] Group errors by operation (default: true)
25
+ # @return [void]
26
+ def setup!(capture_all_errors: false, fingerprint_by_operation: true)
27
+ @capture_all_errors = capture_all_errors
28
+ @fingerprint_by_operation = fingerprint_by_operation
29
+
30
+ unless defined?(::Sentry)
31
+ warn "Sentry gem not found. Install with: gem 'sentry-ruby'"
32
+ return
33
+ end
34
+
35
+ Vectra::Instrumentation.on_operation do |event|
36
+ record_breadcrumb(event)
37
+ capture_error(event) if event.failure?
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ # Add breadcrumb for operation tracing
44
+ def record_breadcrumb(event)
45
+ ::Sentry.add_breadcrumb(
46
+ ::Sentry::Breadcrumb.new(
47
+ category: "vectra",
48
+ message: "#{event.operation} on #{event.index}",
49
+ level: event.success? ? "info" : "error",
50
+ data: {
51
+ provider: event.provider.to_s,
52
+ operation: event.operation.to_s,
53
+ index: event.index,
54
+ duration_ms: event.duration,
55
+ vector_count: event.metadata[:vector_count],
56
+ result_count: event.metadata[:result_count]
57
+ }.compact
58
+ )
59
+ )
60
+ end
61
+
62
+ # Capture error with context
63
+ def capture_error(event)
64
+ ::Sentry.with_scope do |scope|
65
+ scope.set_tags(
66
+ vectra_provider: event.provider.to_s,
67
+ vectra_operation: event.operation.to_s,
68
+ vectra_index: event.index
69
+ )
70
+
71
+ scope.set_context("vectra", build_context(event))
72
+
73
+ # Custom fingerprint to group similar errors
74
+ if @fingerprint_by_operation
75
+ scope.set_fingerprint(build_fingerprint(event))
76
+ end
77
+
78
+ # Set error level based on error type
79
+ scope.set_level(error_level(event.error))
80
+
81
+ ::Sentry.capture_exception(event.error)
82
+ end
83
+ end
84
+
85
+ # Build context hash for Sentry
86
+ def build_context(event)
87
+ {
88
+ provider: event.provider.to_s,
89
+ operation: event.operation.to_s,
90
+ index: event.index,
91
+ duration_ms: event.duration,
92
+ metadata: event.metadata
93
+ }
94
+ end
95
+
96
+ # Build fingerprint array for error grouping
97
+ def build_fingerprint(event)
98
+ ["vectra", event.provider.to_s, event.operation.to_s, event.error.class.name]
99
+ end
100
+
101
+ # Determine error level based on error type
102
+ def error_level(error)
103
+ case error
104
+ when Vectra::RateLimitError
105
+ :warning
106
+ when Vectra::ValidationError
107
+ :info
108
+ when Vectra::ServerError, Vectra::ConnectionError, Vectra::TimeoutError
109
+ :error
110
+ when Vectra::AuthenticationError
111
+ :fatal
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end