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 +4 -4
- data/CHANGELOG.md +134 -0
- data/README.md +84 -3
- data/exaonruby.gemspec +11 -3
- data/lib/exa/middleware/instrumentation.rb +97 -0
- data/lib/exa/middleware/rate_limiter.rb +72 -0
- data/lib/exa/middleware/request_logger.rb +170 -0
- data/lib/exa/middleware/response_cache.rb +226 -0
- data/lib/exa/rails.rb +157 -0
- data/lib/exa/types.rb +204 -0
- data/lib/exa/utils/parallel.rb +135 -0
- data/lib/exa/utils/sse_client.rb +279 -0
- data/lib/exa/version.rb +1 -1
- data/lib/exa.rb +25 -0
- data/lib/generators/exa/install_generator.rb +94 -0
- metadata +36 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f7fa267a3b26d29aff9a6e5886cd43568ad0820aad17e54c8760141bbe208aa8
|
|
4
|
+
data.tar.gz: 2a1823d83853927fee270c1070b4ead7801d1d2df2d6e5c8769d644cefb31459
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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),
|
|
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
|