vectra-client 1.0.7 → 1.1.0

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,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vectra
4
+ module Middleware
5
+ # Cost tracking middleware for monitoring API usage costs
6
+ #
7
+ # Tracks estimated costs per operation based on provider pricing.
8
+ # Costs are stored in response metadata and can be aggregated.
9
+ #
10
+ # @example With default pricing
11
+ # Vectra::Client.use Vectra::Middleware::CostTracker
12
+ #
13
+ # @example With custom pricing
14
+ # custom_pricing = {
15
+ # pinecone: { read: 0.0001, write: 0.0002 },
16
+ # qdrant: { read: 0.00005, write: 0.0001 }
17
+ # }
18
+ # Vectra::Client.use Vectra::Middleware::CostTracker, pricing: custom_pricing
19
+ #
20
+ # @example With cost callback
21
+ # Vectra::Client.use Vectra::Middleware::CostTracker, on_cost: ->(event) {
22
+ # puts "Cost: $#{event[:cost_usd]} for #{event[:operation]}"
23
+ # }
24
+ #
25
+ class CostTracker < Base
26
+ # Default pricing per operation (in USD)
27
+ # These are estimated values - check provider pricing for actual costs
28
+ DEFAULT_PRICING = {
29
+ pinecone: { read: 0.0001, write: 0.0002 },
30
+ qdrant: { read: 0.00005, write: 0.0001 },
31
+ weaviate: { read: 0.00008, write: 0.00015 },
32
+ pgvector: { read: 0.0, write: 0.0 }, # Self-hosted, no API costs
33
+ memory: { read: 0.0, write: 0.0 } # In-memory, no costs
34
+ }.freeze
35
+
36
+ # @param pricing [Hash] Custom pricing structure
37
+ # @param on_cost [Proc] Callback to invoke with cost events
38
+ def initialize(pricing: DEFAULT_PRICING, on_cost: nil)
39
+ super()
40
+ @pricing = pricing
41
+ @on_cost = on_cost
42
+ end
43
+
44
+ def after(request, response)
45
+ return unless response.success?
46
+
47
+ provider = request.provider || :unknown
48
+ operation_type = write_operation?(request.operation) ? :write : :read
49
+
50
+ cost = calculate_cost(provider, operation_type, request)
51
+ response.metadata[:cost_usd] = cost
52
+
53
+ # Invoke callback if provided
54
+ return unless @on_cost
55
+
56
+ @on_cost.call(
57
+ operation: request.operation,
58
+ provider: provider,
59
+ index: request.index,
60
+ cost_usd: cost,
61
+ timestamp: Time.now
62
+ )
63
+ end
64
+
65
+ private
66
+
67
+ # Check if operation is a write operation
68
+ #
69
+ # @param operation [Symbol] The operation type
70
+ # @return [Boolean] true if write operation
71
+ def write_operation?(operation)
72
+ [:upsert, :delete, :update, :create_index, :delete_index].include?(operation)
73
+ end
74
+
75
+ # Calculate cost for operation
76
+ #
77
+ # @param provider [Symbol] Provider name
78
+ # @param operation_type [Symbol] :read or :write
79
+ # @param request [Request] The request object
80
+ # @return [Float] Cost in USD
81
+ def calculate_cost(provider, operation_type, request)
82
+ rate = @pricing.dig(provider, operation_type) || 0.0
83
+ multiplier = operation_multiplier(request)
84
+ rate * multiplier
85
+ end
86
+
87
+ # Calculate multiplier for operation based on batch size
88
+ #
89
+ # @param request [Request] The request object
90
+ # @return [Integer, Float] Multiplier for the base rate
91
+ def operation_multiplier(request)
92
+ return 100 if delete_all?(request)
93
+
94
+ case request.operation
95
+ when :upsert
96
+ collection_size(request.params[:vectors])
97
+ when :fetch, :delete
98
+ collection_size(request.params[:ids])
99
+ else
100
+ 1 # Includes :query and all other operations
101
+ end
102
+ end
103
+
104
+ # Check if request is a delete_all operation
105
+ #
106
+ # @param request [Request] The request object
107
+ # @return [Boolean]
108
+ def delete_all?(request)
109
+ request.operation == :delete && request.params[:delete_all]
110
+ end
111
+
112
+ # Safely compute collection size with a default of 1
113
+ #
114
+ # @param collection [Enumerable, nil]
115
+ # @return [Integer]
116
+ def collection_size(collection)
117
+ collection&.size || 1
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vectra
4
+ module Middleware
5
+ # Instrumentation middleware for metrics and monitoring
6
+ #
7
+ # Emits instrumentation events for all operations, compatible with
8
+ # Vectra's existing instrumentation system.
9
+ #
10
+ # @example Enable instrumentation middleware
11
+ # Vectra::Client.use Vectra::Middleware::Instrumentation
12
+ #
13
+ # @example With custom event handler
14
+ # Vectra.on_operation do |event|
15
+ # puts "Operation: #{event[:operation]}, Duration: #{event[:duration_ms]}ms"
16
+ # end
17
+ #
18
+ # Vectra::Client.use Vectra::Middleware::Instrumentation
19
+ #
20
+ class Instrumentation < Base
21
+ def call(request, app)
22
+ start_time = Time.now
23
+
24
+ response = app.call(request)
25
+
26
+ duration_ms = ((Time.now - start_time) * 1000).round(2)
27
+
28
+ # Emit instrumentation event
29
+ Vectra::Instrumentation.instrument(
30
+ operation: request.operation,
31
+ provider: request.provider,
32
+ index: request.index,
33
+ namespace: request.namespace,
34
+ duration_ms: duration_ms,
35
+ success: response.success?,
36
+ error: response.error&.class&.name,
37
+ metadata: response.metadata
38
+ )
39
+
40
+ response
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vectra
4
+ module Middleware
5
+ # Logging middleware for tracking operations
6
+ #
7
+ # Logs before and after each operation, including timing information.
8
+ #
9
+ # @example With default logger
10
+ # Vectra::Client.use Vectra::Middleware::Logging
11
+ #
12
+ # @example With custom logger
13
+ # logger = Logger.new($stdout)
14
+ # Vectra::Client.use Vectra::Middleware::Logging, logger: logger
15
+ #
16
+ # @example Per-client logging
17
+ # client = Vectra::Client.new(
18
+ # provider: :qdrant,
19
+ # middleware: [Vectra::Middleware::Logging]
20
+ # )
21
+ #
22
+ class Logging < Base
23
+ def initialize(logger: nil)
24
+ super()
25
+ @logger = logger || Vectra.configuration.logger
26
+ end
27
+
28
+ def before(request)
29
+ return unless @logger
30
+
31
+ @start_time = Time.now
32
+ @logger.info(
33
+ "[Vectra] #{request.operation.upcase} " \
34
+ "index=#{request.index} " \
35
+ "namespace=#{request.namespace || 'default'}"
36
+ )
37
+ end
38
+
39
+ def after(request, response)
40
+ return unless @logger
41
+ return unless @start_time
42
+
43
+ duration_ms = ((Time.now - @start_time) * 1000).round(2)
44
+ response.metadata[:duration_ms] = duration_ms
45
+
46
+ if response.success?
47
+ @logger.info("[Vectra] ✅ #{request.operation} completed in #{duration_ms}ms")
48
+ else
49
+ @logger.error("[Vectra] ❌ #{request.operation} failed: #{response.error.message}")
50
+ end
51
+ end
52
+
53
+ def on_error(request, error)
54
+ return unless @logger
55
+
56
+ @logger.error(
57
+ "[Vectra] 💥 #{request.operation} exception: #{error.class} - #{error.message}"
58
+ )
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vectra
4
+ module Middleware
5
+ # PII Redaction middleware for protecting sensitive data
6
+ #
7
+ # Automatically redacts Personally Identifiable Information (PII) from
8
+ # metadata before upserting to vector databases.
9
+ #
10
+ # @example With default patterns (email, phone, SSN)
11
+ # Vectra::Client.use Vectra::Middleware::PIIRedaction
12
+ #
13
+ # @example With custom patterns
14
+ # custom_patterns = {
15
+ # credit_card: /\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/,
16
+ # api_key: /sk-[a-zA-Z0-9]{32}/
17
+ # }
18
+ # Vectra::Client.use Vectra::Middleware::PIIRedaction, patterns: custom_patterns
19
+ #
20
+ class PIIRedaction < Base
21
+ # Default PII patterns
22
+ DEFAULT_PATTERNS = {
23
+ email: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/,
24
+ phone: /\b\d{3}[-.]?\d{3}[-.]?\d{4}\b/,
25
+ ssn: /\b\d{3}-\d{2}-\d{4}\b/,
26
+ credit_card: /\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/
27
+ }.freeze
28
+
29
+ def initialize(patterns: DEFAULT_PATTERNS)
30
+ super()
31
+ @patterns = patterns
32
+ end
33
+
34
+ def before(request)
35
+ return unless request.operation == :upsert
36
+ return unless request.params[:vectors]
37
+
38
+ # Redact PII from metadata in all vectors
39
+ request.params[:vectors].each do |vector|
40
+ next unless vector[:metadata]
41
+
42
+ vector[:metadata] = redact_metadata(vector[:metadata])
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ # Redact PII from metadata hash
49
+ #
50
+ # @param metadata [Hash] Metadata to redact
51
+ # @return [Hash] Redacted metadata
52
+ def redact_metadata(metadata)
53
+ metadata.transform_values do |value|
54
+ next value unless value.is_a?(String)
55
+
56
+ redacted = value.dup
57
+ @patterns.each do |type, pattern|
58
+ redacted.gsub!(pattern, "[REDACTED_#{type.upcase}]")
59
+ end
60
+ redacted
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vectra
4
+ module Middleware
5
+ # Request object passed through middleware chain
6
+ #
7
+ # @example Basic usage
8
+ # request = Request.new(
9
+ # operation: :upsert,
10
+ # index: 'products',
11
+ # namespace: 'prod',
12
+ # vectors: [{ id: 'doc-1', values: [0.1, 0.2, 0.3] }]
13
+ # )
14
+ #
15
+ # request.operation # => :upsert
16
+ # request.index # => 'products'
17
+ # request.namespace # => 'prod'
18
+ # request.metadata[:custom_key] = 'custom_value'
19
+ #
20
+ class Request
21
+ attr_accessor :operation, :index, :namespace, :params, :metadata
22
+
23
+ # @param operation [Symbol] The operation type (:upsert, :query, :delete, etc.)
24
+ # @param params [Hash] All parameters for the operation
25
+ def initialize(operation:, **params)
26
+ @operation = operation
27
+ @index = params[:index]
28
+ @namespace = params[:namespace]
29
+ @params = params
30
+ @metadata = {}
31
+ end
32
+
33
+ # Convert request back to hash for provider call
34
+ #
35
+ # @return [Hash] Parameters hash
36
+ def to_h
37
+ params
38
+ end
39
+
40
+ # Get the provider from params
41
+ #
42
+ # @return [Symbol, nil] Provider name
43
+ def provider
44
+ params[:provider]
45
+ end
46
+
47
+ # Check if this is a write operation
48
+ #
49
+ # @return [Boolean]
50
+ def write_operation?
51
+ [:upsert, :delete, :update, :create_index, :delete_index].include?(operation)
52
+ end
53
+
54
+ # Check if this is a read operation
55
+ #
56
+ # @return [Boolean]
57
+ def read_operation?
58
+ [:query, :fetch, :list_indexes, :describe_index, :stats].include?(operation)
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vectra
4
+ module Middleware
5
+ # Response object returned through middleware chain
6
+ #
7
+ # @example Success response
8
+ # response = Response.new(result: { success: true })
9
+ # response.success? # => true
10
+ # response.result # => { success: true }
11
+ #
12
+ # @example Error response
13
+ # response = Response.new(error: StandardError.new('Failed'))
14
+ # response.failure? # => true
15
+ # response.error # => #<StandardError: Failed>
16
+ #
17
+ # @example With metadata
18
+ # response = Response.new(result: [])
19
+ # response.metadata[:duration_ms] = 45
20
+ # response.metadata[:cache_hit] = true
21
+ #
22
+ class Response
23
+ attr_accessor :result, :error, :metadata
24
+
25
+ # @param result [Object] The successful result
26
+ # @param error [Exception, nil] The error if failed
27
+ def initialize(result: nil, error: nil)
28
+ @result = result
29
+ @error = error
30
+ @metadata = {}
31
+ end
32
+
33
+ # Check if the response was successful
34
+ #
35
+ # @return [Boolean] true if no error
36
+ def success?
37
+ error.nil?
38
+ end
39
+
40
+ # Check if the response failed
41
+ #
42
+ # @return [Boolean] true if error present
43
+ def failure?
44
+ !success?
45
+ end
46
+
47
+ # Raise error if present
48
+ #
49
+ # @raise [Exception] The stored error
50
+ # @return [void]
51
+ def raise_if_error!
52
+ raise error if error
53
+ end
54
+
55
+ # Get the result or raise error
56
+ #
57
+ # @return [Object] The result
58
+ # @raise [Exception] If error present
59
+ def value!
60
+ raise_if_error!
61
+ result
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vectra
4
+ module Middleware
5
+ # Retry middleware for handling transient failures
6
+ #
7
+ # Automatically retries failed requests with configurable backoff strategy.
8
+ #
9
+ # @example With default settings (3 attempts, exponential backoff)
10
+ # Vectra::Client.use Vectra::Middleware::Retry
11
+ #
12
+ # @example With custom settings
13
+ # Vectra::Client.use Vectra::Middleware::Retry, max_attempts: 5, backoff: :linear
14
+ #
15
+ # @example Per-client retry
16
+ # client = Vectra::Client.new(
17
+ # provider: :pinecone,
18
+ # middleware: [[Vectra::Middleware::Retry, { max_attempts: 3 }]]
19
+ # )
20
+ #
21
+ class Retry < Base
22
+ # @param max_attempts [Integer] Maximum number of attempts (default: 3)
23
+ # @param backoff [Symbol, Numeric] Backoff strategy (:exponential, :linear) or fixed delay
24
+ def initialize(max_attempts: 3, backoff: :exponential)
25
+ super()
26
+ @max_attempts = max_attempts
27
+ @backoff = backoff
28
+ end
29
+
30
+ def call(request, app)
31
+ attempt = 0
32
+ last_error = nil
33
+
34
+ loop do
35
+ attempt += 1
36
+
37
+ begin
38
+ response = app.call(request)
39
+
40
+ # If successful, return immediately
41
+ if response.success?
42
+ response.metadata[:retry_count] = attempt - 1
43
+ return response
44
+ end
45
+
46
+ # If error is retryable and we haven't exceeded max attempts, retry
47
+ if response.error && retryable?(response.error) && attempt < @max_attempts
48
+ sleep(backoff_delay(attempt))
49
+ next
50
+ end
51
+
52
+ # Error is not retryable or max attempts reached, return response
53
+ response.metadata[:retry_count] = attempt - 1
54
+ return response
55
+ rescue StandardError => e
56
+ last_error = e
57
+
58
+ # If error is retryable and we haven't exceeded max attempts, retry
59
+ if retryable?(e) && attempt < @max_attempts
60
+ sleep(backoff_delay(attempt))
61
+ next
62
+ end
63
+
64
+ # Error is not retryable or max attempts reached, raise
65
+ raise
66
+ end
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ # Check if error is retryable
73
+ #
74
+ # @param error [Exception] The error to check
75
+ # @return [Boolean] true if error is retryable
76
+ def retryable?(error)
77
+ error.is_a?(Vectra::RateLimitError) ||
78
+ error.is_a?(Vectra::ConnectionError) ||
79
+ error.is_a?(Vectra::TimeoutError) ||
80
+ error.is_a?(Vectra::ServerError)
81
+ end
82
+
83
+ # Calculate backoff delay
84
+ #
85
+ # @param attempt [Integer] Current attempt number
86
+ # @return [Float] Delay in seconds
87
+ def backoff_delay(attempt)
88
+ case @backoff
89
+ when :exponential
90
+ # 0.2s, 0.4s, 0.8s, 1.6s, ...
91
+ (2**(attempt - 1)) * 0.2
92
+ when :linear
93
+ # 0.5s, 1.0s, 1.5s, 2.0s, ...
94
+ attempt * 0.5
95
+ when Numeric
96
+ @backoff
97
+ else
98
+ 1.0
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vectra
4
+ module Middleware
5
+ # Middleware stack executor
6
+ #
7
+ # Builds and executes a chain of middleware around provider calls.
8
+ # Similar to Rack middleware, each middleware wraps the next one
9
+ # in the chain until reaching the actual provider.
10
+ #
11
+ # @example Basic usage
12
+ # provider = Vectra::Providers::Memory.new
13
+ # middlewares = [LoggingMiddleware.new, RetryMiddleware.new]
14
+ # stack = Stack.new(provider, middlewares)
15
+ #
16
+ # result = stack.call(:upsert, index: 'test', vectors: [...])
17
+ #
18
+ class Stack
19
+ # @param provider [Vectra::Providers::Base] The actual provider
20
+ # @param middlewares [Array<Base>] Array of middleware instances
21
+ def initialize(provider, middlewares = [])
22
+ @provider = provider
23
+ @middlewares = middlewares
24
+ end
25
+
26
+ # Execute the middleware stack for an operation
27
+ #
28
+ # @param operation [Symbol] The operation to perform (:upsert, :query, etc.)
29
+ # @param params [Hash] The operation parameters
30
+ # @return [Object] The result from the provider
31
+ # @raise [Exception] Any error from middleware or provider
32
+ def call(operation, **params)
33
+ request = Request.new(operation: operation, **params)
34
+
35
+ # Build middleware chain
36
+ app = build_chain(request)
37
+
38
+ # Execute chain
39
+ response = app.call(request)
40
+
41
+ # Raise if error occurred
42
+ raise response.error if response.error
43
+
44
+ response.result
45
+ end
46
+
47
+ private
48
+
49
+ # Build the middleware chain
50
+ #
51
+ # @param request [Request] The request object (unused here, but available)
52
+ # @return [Proc] The complete middleware chain
53
+ def build_chain(_request)
54
+ # Final app: actual provider call
55
+ final_app = lambda do |req|
56
+ # Remove middleware-specific params before calling provider
57
+ provider_params = req.to_h.except(:provider)
58
+ result = @provider.public_send(req.operation, **provider_params)
59
+ Response.new(result: result)
60
+ rescue StandardError => e
61
+ Response.new(error: e)
62
+ end
63
+
64
+ # Wrap with middlewares in reverse order
65
+ # (last middleware in array is first to execute)
66
+ @middlewares.reverse.inject(final_app) do |next_app, middleware|
67
+ lambda do |req|
68
+ middleware.call(req, next_app)
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Vectra
4
- VERSION = "1.0.7"
4
+ VERSION = "1.1.0"
5
5
  end
data/lib/vectra.rb CHANGED
@@ -18,6 +18,15 @@ require_relative "vectra/health_check"
18
18
  require_relative "vectra/credential_rotation"
19
19
  require_relative "vectra/audit_log"
20
20
  require_relative "vectra/active_record"
21
+ require_relative "vectra/middleware/request"
22
+ require_relative "vectra/middleware/response"
23
+ require_relative "vectra/middleware/base"
24
+ require_relative "vectra/middleware/stack"
25
+ require_relative "vectra/middleware/logging"
26
+ require_relative "vectra/middleware/retry"
27
+ require_relative "vectra/middleware/instrumentation"
28
+ require_relative "vectra/middleware/pii_redaction"
29
+ require_relative "vectra/middleware/cost_tracker"
21
30
  require_relative "vectra/providers/base"
22
31
  require_relative "vectra/providers/pinecone"
23
32
  require_relative "vectra/providers/qdrant"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: vectra-client
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.7
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mijo Kristo
@@ -268,6 +268,7 @@ files:
268
268
  - docs/guides/faq.md
269
269
  - docs/guides/getting-started.md
270
270
  - docs/guides/installation.md
271
+ - docs/guides/middleware.md
271
272
  - docs/guides/monitoring.md
272
273
  - docs/guides/performance.md
273
274
  - docs/guides/rails-integration.md
@@ -294,6 +295,7 @@ files:
294
295
  - examples/grafana-dashboard.json
295
296
  - examples/grafana-setup.md
296
297
  - examples/instrumentation_demo.rb
298
+ - examples/middleware_demo.rb
297
299
  - examples/prometheus-exporter.rb
298
300
  - lib/generators/vectra/index_generator.rb
299
301
  - lib/generators/vectra/install_generator.rb
@@ -316,6 +318,15 @@ files:
316
318
  - lib/vectra/instrumentation/new_relic.rb
317
319
  - lib/vectra/instrumentation/sentry.rb
318
320
  - lib/vectra/logging.rb
321
+ - lib/vectra/middleware/base.rb
322
+ - lib/vectra/middleware/cost_tracker.rb
323
+ - lib/vectra/middleware/instrumentation.rb
324
+ - lib/vectra/middleware/logging.rb
325
+ - lib/vectra/middleware/pii_redaction.rb
326
+ - lib/vectra/middleware/request.rb
327
+ - lib/vectra/middleware/response.rb
328
+ - lib/vectra/middleware/retry.rb
329
+ - lib/vectra/middleware/stack.rb
319
330
  - lib/vectra/pool.rb
320
331
  - lib/vectra/providers/base.rb
321
332
  - lib/vectra/providers/memory.rb