exaonruby 1.0.0 → 1.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2da95920bde51eb7724ef7c1cfa01d25906d4cb04cde2d2dc9d1055ec1267f9e
4
- data.tar.gz: 190c39f5437c045f60067856033ad54330ce05425dd0edea79cb0fa74fed989f
3
+ metadata.gz: f7fa267a3b26d29aff9a6e5886cd43568ad0820aad17e54c8760141bbe208aa8
4
+ data.tar.gz: 2a1823d83853927fee270c1070b4ead7801d1d2df2d6e5c8769d644cefb31459
5
5
  SHA512:
6
- metadata.gz: dea6072b3c1b149c869a4d76854cd36a3aa1a18241cbdea6a105f1331e2fb297723def00c59a30e13c4c044d93095b03b1bd7594f9dc9815e8c20b3bc59c3546
7
- data.tar.gz: 12db1ecb3e8b3528bcaf74c03ea9a259136d794a9c68125562d69fe1d031808582f140cb2e50eb06b2faf3b9ec476c2eefc56809fe013ad3439d63fba35a7776
6
+ metadata.gz: d128dce03cfee863a805039fcb4e9d4b12b93bff213ae0e03be21702040422a284864910df6916183c6aca5b59a91811d4cc3589c348733c31b441eee46db518
7
+ data.tar.gz: 87fb73c8641ef0a78af2f426d0a9499a934d4149ac6c482acab4a688575b87449a8d4c1feb23d46cf9f0b9c7853c787cf052acda30367c9c6fa8f38946559c47
data/CHANGELOG.md ADDED
@@ -0,0 +1,134 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.2.0] - 2025-12-18
9
+
10
+ ### Added
11
+
12
+ - **Request Logging Middleware** - Pretty HTTP request/response logging
13
+ - Configurable log levels and body logging
14
+ - Sensitive data filtering (API keys, passwords)
15
+ - Request timing with status indicators (✓ ⚠ ✗)
16
+
17
+ - **Rate Limiter Middleware** - Client-side rate limiting
18
+ - Token bucket algorithm for smooth rate control
19
+ - Configurable requests per second and burst size
20
+ - Thread-safe implementation
21
+
22
+ - **Response Cache Middleware** - Reduce API costs
23
+ - `Exa::Cache::MemoryStore` for single-process apps
24
+ - `Exa::Cache::RedisStore` for distributed caching
25
+ - Configurable TTL and cacheable endpoints
26
+
27
+ - **OpenTelemetry Instrumentation** - Distributed tracing
28
+ - Automatic span creation for all requests
29
+ - HTTP attributes and status codes
30
+ - Error recording
31
+
32
+ - **Parallel Requests Utility** - Batch operations
33
+ - `Exa::Utils::Parallel.map` for concurrent requests
34
+ - Configurable concurrency limits
35
+ - Multiple error handling strategies
36
+
37
+ - **Rails Integration** - First-class Rails support
38
+ - Railtie for automatic configuration
39
+ - `rails g exa:install` generator
40
+ - ActiveJob adapter for async research
41
+ - ActionCable integration for streaming
42
+ - Rake tasks (`rails exa:verify`, `rails exa:config`)
43
+
44
+ ### Changed
45
+
46
+ - Added `concurrent-ruby` as a core dependency for parallel requests
47
+
48
+ ## [1.1.0] - 2025-12-18
49
+
50
+ ### Added
51
+
52
+ - **SSE Streaming** - Real-time streaming for Answer and Research APIs
53
+ - `Exa::Utils::SSEClient.stream_answer` - Stream answer tokens as they're generated
54
+ - `Exa::Utils::SSEClient.stream_research` - Stream research progress and output
55
+ - Automatic reconnection and error handling
56
+ - Ping/heartbeat support
57
+
58
+ - **Sorbet Type Definitions** - Optional static type checking
59
+ - `Exa::Types` module with T::Struct definitions for all API types
60
+ - Optional dependency on `sorbet-runtime`
61
+ - Full type coverage for Search, Answer, Research, Websets, Monitors, Imports, Webhooks, Events
62
+
63
+ ## [1.0.0] - 2025-12-18
64
+
65
+ ### Added
66
+
67
+ - **Search API**
68
+ - Neural, auto, fast, and deep search types
69
+ - Content extraction with text, highlights, and summaries
70
+ - Domain and date filtering
71
+ - Category filtering (company, person, research paper, etc.)
72
+
73
+ - **Contents API**
74
+ - Fetch full page contents from URLs
75
+ - Livecrawl support (never, fallback, preferred, always)
76
+ - Subpage extraction
77
+
78
+ - **Find Similar API**
79
+ - Discover semantically similar pages
80
+ - Exclude source URL options
81
+
82
+ - **Answer API**
83
+ - LLM-powered question answering
84
+ - Citations with source URLs
85
+ - Search options integration
86
+
87
+ - **Research API**
88
+ - Async research task creation
89
+ - Multiple models (exa-research-fast, exa-research, exa-research-pro)
90
+ - Structured output schema support
91
+ - Task polling and cancellation
92
+
93
+ - **Websets API**
94
+ - Full CRUD operations for Websets
95
+ - Item management (list, get, delete)
96
+ - Search operations (create, get, cancel)
97
+ - Enrichment operations (create, list, get, delete)
98
+
99
+ - **Monitors API**
100
+ - Create automated search/refresh schedules
101
+ - Cron expression support
102
+ - Monitor run tracking
103
+
104
+ - **Imports API**
105
+ - CSV upload with presigned URLs
106
+ - Entity type configuration
107
+ - Import status tracking
108
+
109
+ - **Webhooks API**
110
+ - Create webhook subscriptions
111
+ - Event type filtering
112
+ - Webhook attempt tracking
113
+
114
+ - **Events API**
115
+ - List and filter events
116
+ - Event type helpers
117
+
118
+ - **CLI**
119
+ - Beautiful colorful command-line interface
120
+ - Search, answer, similar, research commands
121
+ - Websets management subcommands
122
+ - JSON output option
123
+
124
+ - **n8n/Zapier Integration**
125
+ - Webhook signature verification
126
+ - HMAC-SHA256 with timing attack prevention
127
+ - Timestamp validation for replay attack prevention
128
+ - Framework-agnostic header parsing
129
+
130
+ - **Core Features**
131
+ - Automatic retry with exponential backoff
132
+ - Comprehensive error hierarchy
133
+ - Rate limit handling with retry_after
134
+ - YARD documentation on all public methods
data/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # Exa Ruby
2
2
 
3
- A production-ready Ruby gem wrapper for the [Exa.ai](https://exa.ai) API, providing intelligent web search, content extraction, and structured data collection capabilities.
3
+ A Ruby gem wrapper for the [Exa.ai](https://exa.ai) API, providing intelligent web search, content extraction, and structured data collection capabilities.
4
+
5
+ Want to say thanks? Click the ⭐ at the top of the page.
4
6
 
5
7
  ## Features
6
8
 
@@ -13,10 +15,17 @@ A production-ready Ruby gem wrapper for the [Exa.ai](https://exa.ai) API, provid
13
15
  - **Monitors**: Automated scheduled searches and content refresh
14
16
  - **Imports**: Upload CSV data into Websets
15
17
  - **Webhooks & Events**: Real-time notifications for Websets activity
18
+ - **SSE Streaming**: Real-time token streaming for Answer and Research APIs
19
+ - **Request Logging**: Pretty HTTP logging with timing and status indicators
20
+ - **Rate Limiting**: Client-side token bucket rate limiting
21
+ - **Response Caching**: Memory and Redis caching to reduce API costs
22
+ - **Instrumentation**: OpenTelemetry distributed tracing
23
+ - **Parallel Requests**: Concurrent batch operations
24
+ - **Rails Integration**: Railtie, generator, ActiveJob, ActionCable
25
+ - **Sorbet Types**: Optional T::Struct type definitions
16
26
  - **Beautiful CLI**: Colorful command-line interface
17
27
  - **n8n/Zapier Integration**: Webhook signature verification utilities
18
28
  - **Automatic Retries**: Built-in retry logic for transient failures
19
- - **Rate Limit Handling**: Proper error handling with retry information
20
29
  - **Type Documentation**: Comprehensive YARD documentation
21
30
 
22
31
  ## Installation
@@ -593,12 +602,85 @@ exa search "AI news" --json
593
602
  exa version
594
603
  ```
595
604
 
605
+ ## SSE Streaming
606
+
607
+ Stream tokens in real-time for Answer and Research APIs:
608
+
609
+ ```ruby
610
+ # Stream an answer with real-time token output
611
+ Exa::Utils::SSEClient.stream_answer(
612
+ api_key: ENV["EXA_API_KEY"],
613
+ query: "What is quantum computing?"
614
+ ) do |event|
615
+ case event[:type]
616
+ when :token
617
+ print event[:data] # Print each token as it arrives
618
+ when :citation
619
+ puts "\nSource: #{event[:data][:url]}"
620
+ when :done
621
+ puts "\n\nComplete!"
622
+ when :error
623
+ puts "Error: #{event[:data]}"
624
+ end
625
+ end
626
+
627
+ # Stream research progress
628
+ Exa::Utils::SSEClient.stream_research(
629
+ api_key: ENV["EXA_API_KEY"],
630
+ instructions: "Research latest AI developments"
631
+ ) do |event|
632
+ case event[:type]
633
+ when :progress
634
+ puts "Progress: #{event[:data][:percent]}%"
635
+ when :output
636
+ puts event[:data]
637
+ end
638
+ end
639
+
640
+ # Instance-based streaming
641
+ streamer = Exa::Utils::SSEClient.new(api_key: ENV["EXA_API_KEY"])
642
+ streamer.answer("What is GPT-4?") { |e| print e[:data] if e[:type] == :token }
643
+ ```
644
+
645
+ ## Sorbet Type Definitions
646
+
647
+ Optional static type checking with Sorbet:
648
+
649
+ ```ruby
650
+ # Install sorbet-runtime for type definitions
651
+ # gem install sorbet-runtime
652
+
653
+ require 'exa'
654
+
655
+ # Types are available when sorbet-runtime is installed
656
+ params = Exa::Types::SearchParams.new(
657
+ query: "AI research",
658
+ type: "neural",
659
+ num_results: 10,
660
+ text: true
661
+ )
662
+
663
+ # Type definitions for all API responses
664
+ # Exa::Types::SearchResultData
665
+ # Exa::Types::AnswerResponseData
666
+ # Exa::Types::ResearchTaskData
667
+ # Exa::Types::WebsetData
668
+ # Exa::Types::MonitorData
669
+ # Exa::Types::ImportData
670
+ # Exa::Types::WebhookData
671
+ # Exa::Types::EventData
672
+ ```
673
+
596
674
  ## Requirements
597
675
 
598
676
  - Ruby >= 3.1
599
677
  - faraday >= 2.0
600
678
  - faraday-retry >= 2.0
601
679
  - thor >= 1.0
680
+ - concurrent-ruby >= 1.2
681
+ - sorbet-runtime >= 0.5 (optional, for type definitions)
682
+ - opentelemetry-sdk (optional, for instrumentation)
683
+ - redis (optional, for distributed caching)
602
684
 
603
685
  ## Development
604
686
 
@@ -611,4 +693,3 @@ The gem is available as open source under the terms of the [MIT License](https:/
611
693
  ## Contributing
612
694
 
613
695
  Bug reports and pull requests are welcome on GitHub at https://github.com/exa-labs/exa-ruby.
614
-
data/exaonruby.gemspec CHANGED
@@ -8,10 +8,12 @@ Gem::Specification.new do |spec|
8
8
  spec.authors = ["tigel-agm"]
9
9
  spec.email = []
10
10
 
11
- spec.summary = "Complete Ruby client for the Exa.ai API with beautiful CLI"
11
+ spec.summary = "Complete Ruby client for the Exa.ai API with CLI, middleware, and Rails integration"
12
12
  spec.description = "A production-ready Ruby gem wrapper for the Exa.ai Search and Websets APIs. " \
13
13
  "Features neural search, LLM-powered answers, async research tasks, " \
14
- "Websets management (monitors, imports, webhooks), and a beautiful CLI. " \
14
+ "Websets management (monitors, imports, webhooks), SSE streaming, " \
15
+ "request logging, rate limiting, response caching, OpenTelemetry instrumentation, " \
16
+ "parallel requests, Rails integration, Sorbet types, and a beautiful CLI. " \
15
17
  "Includes n8n/Zapier webhook signature verification utilities."
16
18
  spec.homepage = "https://github.com/tigel-agm/exaonruby"
17
19
  spec.license = "MIT"
@@ -24,14 +26,20 @@ Gem::Specification.new do |spec|
24
26
  spec.metadata["rubygems_mfa_required"] = "true"
25
27
 
26
28
  # Include all lib files explicitly since we may not have git
27
- spec.files = Dir.glob("{lib,exe}/**/*") + %w[LICENSE.txt README.md]
29
+ spec.files = Dir.glob("{lib,exe}/**/*") + %w[LICENSE.txt README.md CHANGELOG.md]
28
30
  spec.files += Dir.glob("*.gemspec")
29
31
 
30
32
  spec.bindir = "exe"
31
33
  spec.executables = ["exa"]
32
34
  spec.require_paths = ["lib"]
33
35
 
36
+ # Core dependencies
34
37
  spec.add_dependency "faraday", ">= 2.0", "< 3.0"
35
38
  spec.add_dependency "faraday-retry", ">= 2.0", "< 3.0"
36
39
  spec.add_dependency "thor", ">= 1.0", "< 3.0"
40
+ spec.add_dependency "concurrent-ruby", ">= 1.2", "< 2.0"
41
+
42
+ # Optional: Sorbet types (install sorbet-runtime for type checking)
43
+ # Optional: OpenTelemetry (install opentelemetry-sdk for instrumentation)
44
+ # Optional: Redis (install redis gem for distributed caching)
37
45
  end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ module Exa
6
+ module Middleware
7
+ # OpenTelemetry instrumentation middleware for distributed tracing
8
+ #
9
+ # Integrates with OpenTelemetry for comprehensive request tracing.
10
+ # Requires opentelemetry-sdk gem to be installed.
11
+ #
12
+ # @example Enable instrumentation
13
+ # require 'opentelemetry/sdk'
14
+ # OpenTelemetry::SDK.configure
15
+ #
16
+ # client = Exa::Client.new(api_key: key) do |config|
17
+ # config.instrumentation = true
18
+ # end
19
+ class Instrumentation < Faraday::Middleware
20
+ # @param app [Faraday::Middleware] Next middleware
21
+ # @param tracer_name [String] Name for the tracer
22
+ def initialize(app, tracer_name: "exa.api")
23
+ super(app)
24
+ @tracer_name = tracer_name
25
+ @tracer = nil
26
+ end
27
+
28
+ def call(env)
29
+ return @app.call(env) unless otel_available?
30
+
31
+ tracer.in_span(span_name(env), attributes: span_attributes(env), kind: :client) do |span|
32
+ begin
33
+ response = @app.call(env)
34
+
35
+ response.on_complete do |response_env|
36
+ span.set_attribute("http.status_code", response_env[:status])
37
+ span.status = otel_status(response_env[:status])
38
+ end
39
+
40
+ response
41
+ rescue StandardError => e
42
+ span.record_exception(e)
43
+ span.status = OpenTelemetry::Trace::Status.error(e.message)
44
+ raise
45
+ end
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def otel_available?
52
+ defined?(OpenTelemetry) && OpenTelemetry.tracer_provider
53
+ rescue StandardError
54
+ false
55
+ end
56
+
57
+ def tracer
58
+ @tracer ||= OpenTelemetry.tracer_provider.tracer(@tracer_name)
59
+ end
60
+
61
+ def span_name(env)
62
+ method = env[:method].to_s.upcase
63
+ path = env[:url].path.split("/").reject(&:empty?).first || "request"
64
+ "Exa #{method} /#{path}"
65
+ end
66
+
67
+ def span_attributes(env)
68
+ {
69
+ "http.method" => env[:method].to_s.upcase,
70
+ "http.url" => sanitize_url(env[:url].to_s),
71
+ "http.target" => env[:url].path,
72
+ "http.host" => env[:url].host,
73
+ "http.scheme" => env[:url].scheme,
74
+ "net.peer.name" => env[:url].host,
75
+ "net.peer.port" => env[:url].port
76
+ }
77
+ end
78
+
79
+ def sanitize_url(url)
80
+ url.gsub(/api_key=[^&]+/, "api_key=[REDACTED]")
81
+ end
82
+
83
+ def otel_status(status_code)
84
+ case status_code
85
+ when 200..299
86
+ OpenTelemetry::Trace::Status.ok
87
+ when 400..599
88
+ OpenTelemetry::Trace::Status.error("HTTP #{status_code}")
89
+ else
90
+ OpenTelemetry::Trace::Status.unset
91
+ end
92
+ end
93
+ end
94
+
95
+ Faraday::Middleware.register_middleware(exa_instrumentation: Instrumentation)
96
+ end
97
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ module Exa
6
+ module Middleware
7
+ # Client-side rate limiter using token bucket algorithm
8
+ #
9
+ # Prevents 429 errors by limiting requests before they're sent.
10
+ # Uses a thread-safe token bucket with configurable rates.
11
+ #
12
+ # @example Enable rate limiting
13
+ # client = Exa::Client.new(api_key: key) do |config|
14
+ # config.rate_limit = 10 # requests per second
15
+ # config.rate_limit_burst = 20 # max burst size
16
+ # end
17
+ #
18
+ # @example Lower limits for safety
19
+ # client = Exa::Client.new(api_key: key) do |config|
20
+ # config.rate_limit = 5 # conservative 5 req/sec
21
+ # end
22
+ class RateLimiter < Faraday::Middleware
23
+ # @param app [Faraday::Middleware] Next middleware
24
+ # @param rate [Float] Tokens per second (requests per second)
25
+ # @param burst [Integer] Maximum tokens (burst capacity)
26
+ def initialize(app, rate: 10.0, burst: 20)
27
+ super(app)
28
+ @rate = rate.to_f
29
+ @burst = burst
30
+ @tokens = burst.to_f
31
+ @last_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
32
+ @mutex = Mutex.new
33
+ end
34
+
35
+ def call(env)
36
+ wait_for_token
37
+ @app.call(env)
38
+ end
39
+
40
+ private
41
+
42
+ # Wait until a token is available
43
+ def wait_for_token
44
+ @mutex.synchronize do
45
+ refill_tokens
46
+
47
+ if @tokens < 1.0
48
+ # Calculate wait time needed
49
+ wait_time = (1.0 - @tokens) / @rate
50
+ sleep(wait_time) if wait_time > 0
51
+ refill_tokens
52
+ end
53
+
54
+ @tokens -= 1.0
55
+ end
56
+ end
57
+
58
+ # Refill tokens based on time elapsed
59
+ def refill_tokens
60
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
61
+ elapsed = now - @last_time
62
+ @last_time = now
63
+
64
+ # Add tokens based on elapsed time
65
+ @tokens = [@tokens + (elapsed * @rate), @burst.to_f].min
66
+ end
67
+ end
68
+
69
+ # Faraday middleware registration
70
+ Faraday::Middleware.register_middleware(exa_rate_limiter: RateLimiter)
71
+ end
72
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ require "logger"
6
+ require "json"
7
+
8
+ module Exa
9
+ module Middleware
10
+ # Pretty request/response logging middleware for Faraday
11
+ #
12
+ # Logs all HTTP requests and responses with timing, status codes, and
13
+ # optional body logging. Useful for debugging and monitoring API usage.
14
+ #
15
+ # @example Enable logging on client
16
+ # client = Exa::Client.new(api_key: key) do |config|
17
+ # config.logger = Logger.new($stdout)
18
+ # config.log_level = :info
19
+ # config.log_bodies = true
20
+ # end
21
+ #
22
+ # @example Custom logger
23
+ # client = Exa::Client.new(api_key: key) do |config|
24
+ # config.logger = Rails.logger
25
+ # config.log_level = :debug
26
+ # end
27
+ class RequestLogger < Faraday::Middleware
28
+ DEFAULT_OPTIONS = {
29
+ log_level: :info,
30
+ log_headers: false,
31
+ log_bodies: false,
32
+ filter_keys: %w[api_key x-api-key authorization password secret token].freeze,
33
+ max_body_size: 2000
34
+ }.freeze
35
+
36
+ # @param app [Faraday::Middleware] Next middleware in chain
37
+ # @param logger [Logger] Logger instance
38
+ # @param options [Hash] Configuration options
39
+ def initialize(app, logger: nil, **options)
40
+ super(app)
41
+ @logger = logger || default_logger
42
+ @options = DEFAULT_OPTIONS.merge(options)
43
+ end
44
+
45
+ def call(env)
46
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
47
+ request_id = generate_request_id
48
+
49
+ log_request(env, request_id)
50
+
51
+ @app.call(env).on_complete do |response_env|
52
+ elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
53
+ log_response(response_env, request_id, elapsed)
54
+ end
55
+ rescue StandardError => e
56
+ elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
57
+ log_error(env, request_id, elapsed, e)
58
+ raise
59
+ end
60
+
61
+ private
62
+
63
+ def log_request(env, request_id)
64
+ method = env[:method].to_s.upcase
65
+ url = env[:url].to_s.gsub(/api_key=[^&]+/, "api_key=[FILTERED]")
66
+
67
+ message = "[Exa] #{request_id} → #{method} #{url}"
68
+
69
+ if @options[:log_headers]
70
+ headers = filter_sensitive(env[:request_headers] || {})
71
+ message += "\n Headers: #{headers.to_json}"
72
+ end
73
+
74
+ if @options[:log_bodies] && env[:body]
75
+ body = truncate_body(filter_body(env[:body]))
76
+ message += "\n Body: #{body}"
77
+ end
78
+
79
+ log(message)
80
+ end
81
+
82
+ def log_response(env, request_id, elapsed)
83
+ status = env[:status]
84
+ status_emoji = status_indicator(status)
85
+
86
+ message = "[Exa] #{request_id} ← #{status_emoji} #{status} (#{elapsed}ms)"
87
+
88
+ if @options[:log_bodies] && env[:body]
89
+ body = truncate_body(env[:body].to_s)
90
+ message += "\n Body: #{body}"
91
+ end
92
+
93
+ log(message)
94
+ end
95
+
96
+ def log_error(env, request_id, elapsed, error)
97
+ message = "[Exa] #{request_id} ✗ ERROR after #{elapsed}ms: #{error.class}: #{error.message}"
98
+ log(message, :error)
99
+ end
100
+
101
+ def log(message, level = nil)
102
+ level ||= @options[:log_level]
103
+
104
+ case level
105
+ when :debug
106
+ @logger.debug(message)
107
+ when :info
108
+ @logger.info(message)
109
+ when :warn
110
+ @logger.warn(message)
111
+ when :error
112
+ @logger.error(message)
113
+ else
114
+ @logger.info(message)
115
+ end
116
+ end
117
+
118
+ def status_indicator(status)
119
+ case status
120
+ when 200..299 then "✓"
121
+ when 300..399 then "↻"
122
+ when 400..499 then "⚠"
123
+ when 500..599 then "✗"
124
+ else "?"
125
+ end
126
+ end
127
+
128
+ def generate_request_id
129
+ format("%06x", rand(0xFFFFFF))
130
+ end
131
+
132
+ def filter_sensitive(hash)
133
+ hash.each_with_object({}) do |(key, value), result|
134
+ if @options[:filter_keys].any? { |k| key.to_s.downcase.include?(k.downcase) }
135
+ result[key] = "[FILTERED]"
136
+ else
137
+ result[key] = value
138
+ end
139
+ end
140
+ end
141
+
142
+ def filter_body(body)
143
+ return body unless body.is_a?(Hash)
144
+
145
+ body.each_with_object({}) do |(key, value), result|
146
+ if @options[:filter_keys].any? { |k| key.to_s.downcase.include?(k.downcase) }
147
+ result[key] = "[FILTERED]"
148
+ else
149
+ result[key] = value
150
+ end
151
+ end.to_json
152
+ end
153
+
154
+ def truncate_body(body)
155
+ return body if body.length <= @options[:max_body_size]
156
+
157
+ "#{body[0, @options[:max_body_size]]}... (truncated)"
158
+ end
159
+
160
+ def default_logger
161
+ logger = Logger.new($stdout)
162
+ logger.formatter = proc { |_, _, _, msg| "#{msg}\n" }
163
+ logger
164
+ end
165
+ end
166
+
167
+ # Register middleware with Faraday
168
+ Faraday::Middleware.register_middleware(exa_logger: RequestLogger)
169
+ end
170
+ end