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 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
+ ![header](https://camo.githubusercontent.com/26d19b945bc752101b4aca468e07b118a44af07340db79af29f7df95505f2cea/68747470733a2f2f6c616e67667573652e636f6d2f6c616e67667573655f6c6f676f5f77686974652e706e67)
2
+
3
+ # Langfuse Ruby SDK
4
+
5
+ [![Gem Version](https://badge.fury.io/rb/langfuse.svg)](https://badge.fury.io/rb/langfuse)
6
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.2.0-ruby.svg)](https://www.ruby-lang.org/en/)
7
+ [![Test Coverage](https://img.shields.io/badge/coverage-99.6%25-brightgreen.svg)](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