langfuse-rb 0.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +60 -0
- data/LICENSE +21 -0
- data/README.md +106 -0
- data/lib/langfuse/api_client.rb +330 -0
- data/lib/langfuse/cache_warmer.rb +219 -0
- data/lib/langfuse/chat_prompt_client.rb +98 -0
- data/lib/langfuse/client.rb +338 -0
- data/lib/langfuse/config.rb +135 -0
- data/lib/langfuse/observations.rb +615 -0
- data/lib/langfuse/otel_attributes.rb +275 -0
- data/lib/langfuse/otel_setup.rb +123 -0
- data/lib/langfuse/prompt_cache.rb +131 -0
- data/lib/langfuse/propagation.rb +471 -0
- data/lib/langfuse/rails_cache_adapter.rb +200 -0
- data/lib/langfuse/score_client.rb +321 -0
- data/lib/langfuse/span_processor.rb +61 -0
- data/lib/langfuse/text_prompt_client.rb +67 -0
- data/lib/langfuse/types.rb +353 -0
- data/lib/langfuse/version.rb +5 -0
- data/lib/langfuse.rb +457 -0
- metadata +177 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 5905472e2a3dc7fc674f1fbdc86b76105f8513a3079c00c8f9c5c346c19c2f16
|
|
4
|
+
data.tar.gz: 683d86aca0810243d76fd4a0e5418e635ccaf50fd0cbe966290541eff91ccf4e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a5a5b0352f26ce66c2997b6a9bdebde37f1629550bca0c7ef221ad1954f763f1d5a1343915c54d6354ccaafb02b9e7183ac66ef2bf18a9142a69a13852e41762
|
|
7
|
+
data.tar.gz: a2c05dd96df86951eccefd43719805b07595139f6dc56bc4fe1c35a54fe9ee3936ac4f06b4b1ac78fa419cdf717fd988f4555e349ab3b9a67738405e4ee34220
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [1.0.0] - 2025-10-16 🚀
|
|
11
|
+
|
|
12
|
+
### Initial Release
|
|
13
|
+
|
|
14
|
+
Complete Ruby SDK for Langfuse with prompt management, distributed caching, LLM tracing, and Rails integration.
|
|
15
|
+
|
|
16
|
+
#### Prompt Management
|
|
17
|
+
- Fetch and compile text and chat prompts with Mustache templating
|
|
18
|
+
- Support for prompt versioning and label-based fetching (production, staging, etc.)
|
|
19
|
+
- Automatic variable substitution with nested objects and arrays
|
|
20
|
+
- Global configuration pattern with `Langfuse.configure` block
|
|
21
|
+
- Fallback prompt support for graceful error recovery
|
|
22
|
+
|
|
23
|
+
#### Caching
|
|
24
|
+
- Dual backend support: in-memory (default) and Rails.cache (distributed)
|
|
25
|
+
- Thread-safe in-memory cache with TTL and LRU eviction
|
|
26
|
+
- Distributed caching with Redis/Memcached via Rails.cache
|
|
27
|
+
- Automatic stampede protection with distributed locks (Rails.cache only)
|
|
28
|
+
- Cache warming utilities for deployment automation
|
|
29
|
+
- Auto-discovery of all prompts with configurable labels
|
|
30
|
+
|
|
31
|
+
#### LLM Tracing & Observability
|
|
32
|
+
- Built on OpenTelemetry for industry-standard distributed tracing
|
|
33
|
+
- Block-based Ruby API for traces, spans, and generations
|
|
34
|
+
- Automatic prompt-to-trace linking
|
|
35
|
+
- Token usage and cost tracking
|
|
36
|
+
- W3C Trace Context support for distributed tracing across services
|
|
37
|
+
- Integration with APM tools (Datadog, New Relic, Honeycomb, etc.)
|
|
38
|
+
- Async processing with batch span export
|
|
39
|
+
|
|
40
|
+
#### Rails Integration
|
|
41
|
+
- Rails-friendly configuration with initializer support
|
|
42
|
+
- Background job integration (Sidekiq, GoodJob, Delayed Job, etc.)
|
|
43
|
+
- Rake tasks for cache management
|
|
44
|
+
- Environment-specific configuration patterns
|
|
45
|
+
- Credentials support for secure key management
|
|
46
|
+
|
|
47
|
+
#### Developer Experience
|
|
48
|
+
- Comprehensive error handling with specific error classes
|
|
49
|
+
- HTTP client with automatic retry logic and exponential backoff
|
|
50
|
+
- Circuit breaker pattern for resilience (via Stoplight)
|
|
51
|
+
- 99.7% test coverage with 339 comprehensive test cases
|
|
52
|
+
- Extensive documentation with guides for Rails, tracing, and migration
|
|
53
|
+
|
|
54
|
+
#### Dependencies
|
|
55
|
+
- Ruby >= 3.2.0
|
|
56
|
+
- No Rails dependency (works with any Ruby project)
|
|
57
|
+
- Minimal runtime dependencies (Faraday, Mustache, OpenTelemetry)
|
|
58
|
+
|
|
59
|
+
[Unreleased]: https://github.com/langfuse/langfuse-ruby/compare/v1.0.0...HEAD
|
|
60
|
+
[1.0.0]: https://github.com/langfuse/langfuse-ruby/releases/tag/v1.0.0
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Langfuse
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
3
|
+
# Langfuse Ruby SDK
|
|
4
|
+
|
|
5
|
+
[](https://badge.fury.io/rb/langfuse)
|
|
6
|
+
[](https://www.ruby-lang.org/en/)
|
|
7
|
+
[](coverage)
|
|
8
|
+
|
|
9
|
+
> Ruby SDK for [Langfuse](https://langfuse.com) - Open-source LLM observability and prompt management.
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- 🎯 **Prompt Management** - Centralized prompt versioning with Mustache templating
|
|
14
|
+
- 📊 **LLM Tracing** - Zero-boilerplate observability built on OpenTelemetry
|
|
15
|
+
- ⚡ **Performance** - In-memory or Redis-backed caching with stampede protection
|
|
16
|
+
- 💬 **Chat & Text Prompts** - First-class support for both formats
|
|
17
|
+
- 🔄 **Automatic Retries** - Built-in exponential backoff for resilient API calls
|
|
18
|
+
- 🛡️ **Fallback Support** - Graceful degradation when API unavailable
|
|
19
|
+
- 🚀 **Rails-Friendly** - Global configuration pattern, works with any Ruby project
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
# Gemfile
|
|
25
|
+
gem 'langfuse-rb'
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
bundle install
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Quick Start
|
|
33
|
+
|
|
34
|
+
**Configure once at startup:**
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
# config/initializers/langfuse.rb (Rails)
|
|
38
|
+
# or at the top of your script
|
|
39
|
+
Langfuse.configure do |config|
|
|
40
|
+
config.public_key = ENV['LANGFUSE_PUBLIC_KEY']
|
|
41
|
+
config.secret_key = ENV['LANGFUSE_SECRET_KEY']
|
|
42
|
+
# Optional: for self-hosted instances
|
|
43
|
+
config.base_url = ENV.fetch('LANGFUSE_BASE_URL', 'https://cloud.langfuse.com')
|
|
44
|
+
end
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**Fetch and use a prompt:**
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
prompt = Langfuse.client.get_prompt("greeting")
|
|
51
|
+
message = prompt.compile(name: "Alice")
|
|
52
|
+
# => "Hello Alice!"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Trace an LLM call:**
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
Langfuse.observe("chat-completion", as_type: :generation) do |gen|
|
|
59
|
+
response = openai_client.chat(
|
|
60
|
+
parameters: {
|
|
61
|
+
model: "gpt-4",
|
|
62
|
+
messages: [{ role: "user", content: "Hello!" }]
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
gen.update(
|
|
67
|
+
model: "gpt-4",
|
|
68
|
+
output: response.dig("choices", 0, "message", "content"),
|
|
69
|
+
usage_details: {
|
|
70
|
+
prompt_tokens: response.dig("usage", "prompt_tokens"),
|
|
71
|
+
completion_tokens: response.dig("usage", "completion_tokens")
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
> [!IMPORTANT]
|
|
78
|
+
> For complete reference see [docs](./docs/) section.
|
|
79
|
+
|
|
80
|
+
## Requirements
|
|
81
|
+
|
|
82
|
+
- Ruby >= 3.2.0
|
|
83
|
+
- No Rails dependency (works with any Ruby project)
|
|
84
|
+
|
|
85
|
+
## Contributing
|
|
86
|
+
|
|
87
|
+
We welcome contributions! Please:
|
|
88
|
+
|
|
89
|
+
1. Check existing [issues](https://github.com/simplepractice/langfuse-rb/issues) and roadmap
|
|
90
|
+
2. Open an issue to discuss your idea
|
|
91
|
+
3. Fork the repo and create a feature branch
|
|
92
|
+
4. Write tests (maintain >95% coverage)
|
|
93
|
+
5. Ensure `bundle exec rspec` and `bundle exec rubocop` pass
|
|
94
|
+
6. Submit a pull request
|
|
95
|
+
|
|
96
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines.
|
|
97
|
+
|
|
98
|
+
## Support
|
|
99
|
+
|
|
100
|
+
- **[GitHub Issues](https://github.com/simplepractice/langfuse-rb/issues)** - Bug reports and feature requests
|
|
101
|
+
- **[Langfuse Documentation](https://langfuse.com/docs)** - Platform documentation
|
|
102
|
+
- **[API Reference](https://api.reference.langfuse.com)** - REST API reference
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
|
|
106
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "faraday/retry"
|
|
5
|
+
require "base64"
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
module Langfuse
|
|
9
|
+
# HTTP client for Langfuse API
|
|
10
|
+
#
|
|
11
|
+
# Handles authentication, connection management, and HTTP requests
|
|
12
|
+
# to the Langfuse REST API.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# api_client = Langfuse::ApiClient.new(
|
|
16
|
+
# public_key: "pk_...",
|
|
17
|
+
# secret_key: "sk_...",
|
|
18
|
+
# base_url: "https://cloud.langfuse.com",
|
|
19
|
+
# timeout: 5,
|
|
20
|
+
# logger: Logger.new($stdout)
|
|
21
|
+
# )
|
|
22
|
+
#
|
|
23
|
+
# rubocop:disable Metrics/ClassLength
|
|
24
|
+
class ApiClient
|
|
25
|
+
attr_reader :public_key, :secret_key, :base_url, :timeout, :logger, :cache
|
|
26
|
+
|
|
27
|
+
# Initialize a new API client
|
|
28
|
+
#
|
|
29
|
+
# @param public_key [String] Langfuse public API key
|
|
30
|
+
# @param secret_key [String] Langfuse secret API key
|
|
31
|
+
# @param base_url [String] Base URL for Langfuse API
|
|
32
|
+
# @param timeout [Integer] HTTP request timeout in seconds
|
|
33
|
+
# @param logger [Logger] Logger instance for debugging
|
|
34
|
+
# @param cache [PromptCache, nil] Optional cache for prompt responses
|
|
35
|
+
def initialize(public_key:, secret_key:, base_url:, timeout: 5, logger: nil, cache: nil)
|
|
36
|
+
@public_key = public_key
|
|
37
|
+
@secret_key = secret_key
|
|
38
|
+
@base_url = base_url
|
|
39
|
+
@timeout = timeout
|
|
40
|
+
@logger = logger || Logger.new($stdout, level: Logger::WARN)
|
|
41
|
+
@cache = cache
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Get a Faraday connection
|
|
45
|
+
#
|
|
46
|
+
# @param timeout [Integer, nil] Optional custom timeout for this connection
|
|
47
|
+
# @return [Faraday::Connection]
|
|
48
|
+
def connection(timeout: nil)
|
|
49
|
+
if timeout
|
|
50
|
+
# Create dedicated connection for custom timeout
|
|
51
|
+
# to avoid mutating shared connection
|
|
52
|
+
build_connection(timeout: timeout)
|
|
53
|
+
else
|
|
54
|
+
@connection ||= build_connection
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# List all prompts in the Langfuse project
|
|
59
|
+
#
|
|
60
|
+
# Fetches a list of all prompt names available in your project.
|
|
61
|
+
# Note: This returns metadata only, not full prompt content.
|
|
62
|
+
#
|
|
63
|
+
# @param page [Integer, nil] Optional page number for pagination
|
|
64
|
+
# @param limit [Integer, nil] Optional limit per page (default: API default)
|
|
65
|
+
# @return [Array<Hash>] Array of prompt metadata hashes
|
|
66
|
+
# @raise [UnauthorizedError] if authentication fails
|
|
67
|
+
# @raise [ApiError] for other API errors
|
|
68
|
+
#
|
|
69
|
+
# @example
|
|
70
|
+
# prompts = api_client.list_prompts
|
|
71
|
+
# prompts.each do |prompt|
|
|
72
|
+
# puts "#{prompt['name']} (v#{prompt['version']})"
|
|
73
|
+
# end
|
|
74
|
+
def list_prompts(page: nil, limit: nil)
|
|
75
|
+
params = {}
|
|
76
|
+
params[:page] = page if page
|
|
77
|
+
params[:limit] = limit if limit
|
|
78
|
+
|
|
79
|
+
path = "/api/public/v2/prompts"
|
|
80
|
+
response = connection.get(path, params)
|
|
81
|
+
result = handle_response(response)
|
|
82
|
+
|
|
83
|
+
# API returns { data: [...], meta: {...} }
|
|
84
|
+
result["data"] || []
|
|
85
|
+
rescue Faraday::RetriableResponse => e
|
|
86
|
+
logger.error("Faraday error: Retries exhausted - #{e.response.status}")
|
|
87
|
+
handle_response(e.response)
|
|
88
|
+
rescue Faraday::Error => e
|
|
89
|
+
logger.error("Faraday error: #{e.message}")
|
|
90
|
+
raise ApiError, "HTTP request failed: #{e.message}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Fetch a prompt from the Langfuse API
|
|
94
|
+
#
|
|
95
|
+
# Checks cache first if caching is enabled. On cache miss, fetches from API
|
|
96
|
+
# and stores in cache. When using Rails.cache backend, uses distributed lock
|
|
97
|
+
# to prevent cache stampedes.
|
|
98
|
+
#
|
|
99
|
+
# @param name [String] The name of the prompt
|
|
100
|
+
# @param version [Integer, nil] Optional specific version number
|
|
101
|
+
# @param label [String, nil] Optional label (e.g., "production", "latest")
|
|
102
|
+
# @return [Hash] The prompt data
|
|
103
|
+
# @raise [ArgumentError] if both version and label are provided
|
|
104
|
+
# @raise [NotFoundError] if the prompt is not found
|
|
105
|
+
# @raise [UnauthorizedError] if authentication fails
|
|
106
|
+
# @raise [ApiError] for other API errors
|
|
107
|
+
def get_prompt(name, version: nil, label: nil)
|
|
108
|
+
raise ArgumentError, "Cannot specify both version and label" if version && label
|
|
109
|
+
|
|
110
|
+
cache_key = PromptCache.build_key(name, version: version, label: label)
|
|
111
|
+
|
|
112
|
+
# Use distributed lock if cache supports it (Rails.cache backend)
|
|
113
|
+
if cache.respond_to?(:fetch_with_lock)
|
|
114
|
+
cache.fetch_with_lock(cache_key) do
|
|
115
|
+
fetch_prompt_from_api(name, version: version, label: label)
|
|
116
|
+
end
|
|
117
|
+
elsif cache
|
|
118
|
+
# In-memory cache - use simple get/set pattern
|
|
119
|
+
cached_data = cache.get(cache_key)
|
|
120
|
+
return cached_data if cached_data
|
|
121
|
+
|
|
122
|
+
prompt_data = fetch_prompt_from_api(name, version: version, label: label)
|
|
123
|
+
cache.set(cache_key, prompt_data)
|
|
124
|
+
prompt_data
|
|
125
|
+
else
|
|
126
|
+
# No cache - fetch directly
|
|
127
|
+
fetch_prompt_from_api(name, version: version, label: label)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Send a batch of events to the Langfuse ingestion API
|
|
132
|
+
#
|
|
133
|
+
# Sends events (scores, traces, observations) to the ingestion endpoint.
|
|
134
|
+
# Retries transient errors (429, 503, 504, network errors) with exponential backoff.
|
|
135
|
+
# Batch operations are idempotent (events have unique IDs), so retries are safe.
|
|
136
|
+
#
|
|
137
|
+
# @param events [Array<Hash>] Array of event hashes to send
|
|
138
|
+
# @return [void]
|
|
139
|
+
# @raise [UnauthorizedError] if authentication fails
|
|
140
|
+
# @raise [ApiError] for other API errors after retries exhausted
|
|
141
|
+
#
|
|
142
|
+
# @example
|
|
143
|
+
# events = [
|
|
144
|
+
# {
|
|
145
|
+
# id: SecureRandom.uuid,
|
|
146
|
+
# type: "score-create",
|
|
147
|
+
# timestamp: Time.now.iso8601,
|
|
148
|
+
# body: { name: "quality", value: 0.85, trace_id: "abc123..." }
|
|
149
|
+
# }
|
|
150
|
+
# ]
|
|
151
|
+
# api_client.send_batch(events)
|
|
152
|
+
def send_batch(events)
|
|
153
|
+
raise ArgumentError, "events must be an array" unless events.is_a?(Array)
|
|
154
|
+
raise ArgumentError, "events array cannot be empty" if events.empty?
|
|
155
|
+
|
|
156
|
+
path = "/api/public/ingestion"
|
|
157
|
+
payload = { batch: events }
|
|
158
|
+
|
|
159
|
+
response = connection.post(path, payload)
|
|
160
|
+
handle_batch_response(response)
|
|
161
|
+
rescue Faraday::RetriableResponse => e
|
|
162
|
+
# Retry middleware exhausted all retries - handle the final response
|
|
163
|
+
logger.error("Langfuse batch send failed: Retries exhausted - #{e.response.status}")
|
|
164
|
+
handle_batch_response(e.response)
|
|
165
|
+
rescue Faraday::Error => e
|
|
166
|
+
logger.error("Langfuse batch send failed: #{e.message}")
|
|
167
|
+
raise ApiError, "Batch send failed: #{e.message}"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
private
|
|
171
|
+
|
|
172
|
+
# Fetch a prompt from the API (without caching)
|
|
173
|
+
#
|
|
174
|
+
# @param name [String] The name of the prompt
|
|
175
|
+
# @param version [Integer, nil] Optional specific version number
|
|
176
|
+
# @param label [String, nil] Optional label
|
|
177
|
+
# @return [Hash] The prompt data
|
|
178
|
+
# @raise [NotFoundError] if the prompt is not found
|
|
179
|
+
# @raise [UnauthorizedError] if authentication fails
|
|
180
|
+
# @raise [ApiError] for other API errors
|
|
181
|
+
def fetch_prompt_from_api(name, version: nil, label: nil)
|
|
182
|
+
params = build_prompt_params(version: version, label: label)
|
|
183
|
+
path = "/api/public/v2/prompts/#{name}"
|
|
184
|
+
|
|
185
|
+
response = connection.get(path, params)
|
|
186
|
+
handle_response(response)
|
|
187
|
+
rescue Faraday::RetriableResponse => e
|
|
188
|
+
# Retry middleware exhausted all retries - handle the final response
|
|
189
|
+
logger.error("Faraday error: Retries exhausted - #{e.response.status}")
|
|
190
|
+
handle_response(e.response)
|
|
191
|
+
rescue Faraday::Error => e
|
|
192
|
+
logger.error("Faraday error: #{e.message}")
|
|
193
|
+
raise ApiError, "HTTP request failed: #{e.message}"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Build a new Faraday connection
|
|
197
|
+
#
|
|
198
|
+
# @param timeout [Integer, nil] Optional timeout override
|
|
199
|
+
# @return [Faraday::Connection]
|
|
200
|
+
def build_connection(timeout: nil)
|
|
201
|
+
Faraday.new(
|
|
202
|
+
url: base_url,
|
|
203
|
+
headers: default_headers
|
|
204
|
+
) do |conn|
|
|
205
|
+
conn.request :json
|
|
206
|
+
conn.request :retry, retry_options
|
|
207
|
+
conn.response :json, content_type: /\bjson$/
|
|
208
|
+
conn.adapter Faraday.default_adapter
|
|
209
|
+
conn.options.timeout = timeout || @timeout
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Configuration for retry middleware
|
|
214
|
+
#
|
|
215
|
+
# Retries transient errors with exponential backoff:
|
|
216
|
+
# - Max 2 retries (3 total attempts)
|
|
217
|
+
# - Exponential backoff (0.05s * 2^retry_count)
|
|
218
|
+
# - Retries GET requests and POST requests to batch endpoint (idempotent operations)
|
|
219
|
+
# - Retries on: 429 (rate limit), 503 (service unavailable), 504 (gateway timeout)
|
|
220
|
+
# - Does NOT retry on: 4xx errors (except 429), 5xx errors (except 503, 504)
|
|
221
|
+
#
|
|
222
|
+
# @return [Hash] Retry options for Faraday::Retry middleware
|
|
223
|
+
def retry_options
|
|
224
|
+
{
|
|
225
|
+
max: 2,
|
|
226
|
+
interval: 0.05,
|
|
227
|
+
backoff_factor: 2,
|
|
228
|
+
methods: %i[get post],
|
|
229
|
+
retry_statuses: [429, 503, 504],
|
|
230
|
+
exceptions: [Faraday::TimeoutError, Faraday::ConnectionFailed]
|
|
231
|
+
}
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Default headers for all requests
|
|
235
|
+
#
|
|
236
|
+
# @return [Hash]
|
|
237
|
+
def default_headers
|
|
238
|
+
{
|
|
239
|
+
"Authorization" => authorization_header,
|
|
240
|
+
"User-Agent" => user_agent,
|
|
241
|
+
"Content-Type" => "application/json"
|
|
242
|
+
}
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Generate Basic Auth header
|
|
246
|
+
#
|
|
247
|
+
# @return [String] Basic Auth header value
|
|
248
|
+
def authorization_header
|
|
249
|
+
credentials = "#{public_key}:#{secret_key}"
|
|
250
|
+
"Basic #{Base64.strict_encode64(credentials)}"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# User agent string
|
|
254
|
+
#
|
|
255
|
+
# @return [String]
|
|
256
|
+
def user_agent
|
|
257
|
+
"langfuse-rb/#{Langfuse::VERSION}"
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Build query parameters for prompt request
|
|
261
|
+
#
|
|
262
|
+
# @param version [Integer, nil] Optional version number
|
|
263
|
+
# @param label [String, nil] Optional label
|
|
264
|
+
# @return [Hash] Query parameters
|
|
265
|
+
def build_prompt_params(version: nil, label: nil)
|
|
266
|
+
params = {}
|
|
267
|
+
params[:version] = version if version
|
|
268
|
+
params[:label] = label if label
|
|
269
|
+
params
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Handle HTTP response and raise appropriate errors
|
|
273
|
+
#
|
|
274
|
+
# @param response [Faraday::Response] The HTTP response
|
|
275
|
+
# @return [Hash] The parsed response body
|
|
276
|
+
# @raise [NotFoundError] if status is 404
|
|
277
|
+
# @raise [UnauthorizedError] if status is 401
|
|
278
|
+
# @raise [ApiError] for other error statuses
|
|
279
|
+
def handle_response(response)
|
|
280
|
+
case response.status
|
|
281
|
+
when 200
|
|
282
|
+
response.body
|
|
283
|
+
when 401
|
|
284
|
+
raise UnauthorizedError, "Authentication failed. Check your API keys."
|
|
285
|
+
when 404
|
|
286
|
+
raise NotFoundError, "Prompt not found"
|
|
287
|
+
else
|
|
288
|
+
error_message = extract_error_message(response)
|
|
289
|
+
raise ApiError, "API request failed (#{response.status}): #{error_message}"
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Handle HTTP response for batch requests
|
|
294
|
+
#
|
|
295
|
+
# @param response [Faraday::Response] The HTTP response
|
|
296
|
+
# @return [void]
|
|
297
|
+
# @raise [UnauthorizedError] if status is 401
|
|
298
|
+
# @raise [ApiError] for other error statuses
|
|
299
|
+
def handle_batch_response(response)
|
|
300
|
+
case response.status
|
|
301
|
+
when 200, 201, 204, 207
|
|
302
|
+
nil
|
|
303
|
+
when 401
|
|
304
|
+
raise UnauthorizedError, "Authentication failed. Check your API keys."
|
|
305
|
+
else
|
|
306
|
+
error_message = extract_error_message(response)
|
|
307
|
+
raise ApiError, "Batch send failed (#{response.status}): #{error_message}"
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Extract error message from response body
|
|
312
|
+
#
|
|
313
|
+
# @param response [Faraday::Response] The HTTP response
|
|
314
|
+
# @return [String] The error message
|
|
315
|
+
def extract_error_message(response)
|
|
316
|
+
body_hash = case response.body
|
|
317
|
+
in Hash => h then h
|
|
318
|
+
in String => s then begin
|
|
319
|
+
JSON.parse(s)
|
|
320
|
+
rescue StandardError
|
|
321
|
+
{}
|
|
322
|
+
end
|
|
323
|
+
else {}
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
%w[message error].filter_map { |key| body_hash[key] }.first || "Unknown error"
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
# rubocop:enable Metrics/ClassLength
|