langfuse-rb 0.1.0 → 0.3.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: 5905472e2a3dc7fc674f1fbdc86b76105f8513a3079c00c8f9c5c346c19c2f16
4
- data.tar.gz: 683d86aca0810243d76fd4a0e5418e635ccaf50fd0cbe966290541eff91ccf4e
3
+ metadata.gz: 60270020fc35460c5e29381351bbca35f1cdfe8b7dfe05eca43a0fb20dc06b7b
4
+ data.tar.gz: 4951c9b1546de4c9d00bb3b3be4c5325c8edf4d5bf6f5ecab7d9520ab052451b
5
5
  SHA512:
6
- metadata.gz: a5a5b0352f26ce66c2997b6a9bdebde37f1629550bca0c7ef221ad1954f763f1d5a1343915c54d6354ccaafb02b9e7183ac66ef2bf18a9142a69a13852e41762
7
- data.tar.gz: a2c05dd96df86951eccefd43719805b07595139f6dc56bc4fe1c35a54fe9ee3936ac4f06b4b1ac78fa419cdf717fd988f4555e349ab3b9a67738405e4ee34220
6
+ metadata.gz: 84fa1fc6ea91bda9ddcaa32dd43cb457439e0e4cdee06e3f3df88aae9c54c5b10abd16cb120f234da28733ba6497c466d0ece7888410d7b2711815e13f3956ef
7
+ data.tar.gz: 785bd5801a8c6b0ecd7c94f43083bdd6f12463bb918fa5f4a6faee32924a87a5058cf9700b20984fa9e8964cb3070975235771ddfddabca115cb33538020b065
data/CHANGELOG.md CHANGED
@@ -7,54 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
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
10
+ ## [0.3.0] - 2026-01-23
11
+
12
+ ### Added
13
+ - Stale-while-revalidate (SWR) cache strategy for improved performance (#35)
14
+
15
+ ### Fixed
16
+ - OpenTelemetry Baggage API method signatures for context propagation (#39)
17
+
18
+ ### Changed
19
+ - Relaxed Faraday version constraint for better compatibility with older projects (#37)
20
+
21
+ ## [0.2.0] - 2025-12-19
22
+
23
+ ### Added
24
+ - Prompt creation and update methods (`create_prompt`, `update_prompt`) (#36)
25
+
26
+ ## [0.1.0] - 2025-12-01
27
+
28
+ ### Added
29
+ - Observe API with context propagation and scoring (#31)
30
+ - W3C TraceContext propagator for distributed tracing (#1)
31
+ - Ruby 3.4 support (#3)
32
+ - OpenTelemetry-based tracing with OTLP export
33
+ - Distributed caching with Rails.cache backend and stampede protection
34
+ - Prompt management (text and chat) with Mustache templating
35
+ - In-memory caching with TTL and LRU eviction
36
+ - Fallback prompt support
37
+ - Global configuration pattern with `Langfuse.configure`
38
+
39
+ ### Changed
40
+ - Migrated from legacy ingestion API to OTLP endpoint
41
+ - Removed `tracing_enabled` configuration flag (#2)
42
+
43
+ [Unreleased]: https://github.com/simplepractice/langfuse-rb/compare/v0.3.0...HEAD
44
+ [0.3.0]: https://github.com/simplepractice/langfuse-rb/compare/v0.2.0...v0.3.0
45
+ [0.2.0]: https://github.com/simplepractice/langfuse-rb/compare/v0.1.0...v0.2.0
46
+ [0.1.0]: https://github.com/simplepractice/langfuse-rb/releases/tag/v0.1.0
data/README.md CHANGED
@@ -2,49 +2,55 @@
2
2
 
3
3
  # Langfuse Ruby SDK
4
4
 
5
- [![Gem Version](https://badge.fury.io/rb/langfuse.svg)](https://badge.fury.io/rb/langfuse)
5
+ [![Gem Version](https://badge.fury.io/rb/langfuse-rb.svg?icon=si%3Arubygems)](https://badge.fury.io/rb/langfuse-rb)
6
6
  [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.2.0-ruby.svg)](https://www.ruby-lang.org/en/)
7
7
  [![Test Coverage](https://img.shields.io/badge/coverage-99.6%25-brightgreen.svg)](coverage)
8
8
 
9
9
  > Ruby SDK for [Langfuse](https://langfuse.com) - Open-source LLM observability and prompt management.
10
10
 
11
- ## Features
11
+ <br>
12
+
13
+ ### Features
12
14
 
13
15
  - 🎯 **Prompt Management** - Centralized prompt versioning with Mustache templating
14
16
  - 📊 **LLM Tracing** - Zero-boilerplate observability built on OpenTelemetry
15
- - ⚡ **Performance** - In-memory or Redis-backed caching with stampede protection
17
+ - ⚡ **Performance** - In-memory or Redis-backed caching with stampede protection, both supporting stale-while-revalidate cache strategy
16
18
  - 💬 **Chat & Text Prompts** - First-class support for both formats
17
19
  - 🔄 **Automatic Retries** - Built-in exponential backoff for resilient API calls
18
20
  - 🛡️ **Fallback Support** - Graceful degradation when API unavailable
19
21
  - 🚀 **Rails-Friendly** - Global configuration pattern, works with any Ruby project
20
22
 
21
- ## Installation
23
+ <br>
24
+
25
+ ### Installation
22
26
 
23
27
  ```ruby
24
- # Gemfile
28
+ # Add to Gemfile & bundle install
25
29
  gem 'langfuse-rb'
26
30
  ```
27
31
 
28
- ```bash
29
- bundle install
30
- ```
32
+ <br>
31
33
 
32
- ## Quick Start
34
+ ### Quick Start
33
35
 
34
- **Configure once at startup:**
36
+ > Configure once at startup
35
37
 
36
38
  ```ruby
37
39
  # config/initializers/langfuse.rb (Rails)
38
- # or at the top of your script
40
+ # Or at the top of your script
39
41
  Langfuse.configure do |config|
40
42
  config.public_key = ENV['LANGFUSE_PUBLIC_KEY']
41
43
  config.secret_key = ENV['LANGFUSE_SECRET_KEY']
42
44
  # Optional: for self-hosted instances
43
45
  config.base_url = ENV.fetch('LANGFUSE_BASE_URL', 'https://cloud.langfuse.com')
46
+
47
+ # Optional: Enable stale-while-revalidate for best performance
48
+ config.cache_backend = :rails # or :memory
49
+ config.cache_stale_while_revalidate = true
44
50
  end
45
51
  ```
46
52
 
47
- **Fetch and use a prompt:**
53
+ > Fetch and use a prompt
48
54
 
49
55
  ```ruby
50
56
  prompt = Langfuse.client.get_prompt("greeting")
@@ -52,7 +58,7 @@ message = prompt.compile(name: "Alice")
52
58
  # => "Hello Alice!"
53
59
  ```
54
60
 
55
- **Trace an LLM call:**
61
+ > Trace an LLM call
56
62
 
57
63
  ```ruby
58
64
  Langfuse.observe("chat-completion", as_type: :generation) do |gen|
@@ -74,28 +80,35 @@ Langfuse.observe("chat-completion", as_type: :generation) do |gen|
74
80
  end
75
81
  ```
76
82
 
77
- > [!IMPORTANT]
83
+ > [!IMPORTANT]
78
84
  > For complete reference see [docs](./docs/) section.
79
85
 
80
- ## Requirements
86
+ <br>
87
+
88
+ ### Requirements
81
89
 
82
90
  - Ruby >= 3.2.0
83
91
  - No Rails dependency (works with any Ruby project)
84
92
 
85
- ## Contributing
93
+ <br>
94
+
95
+ ### Contributing
86
96
 
87
97
  We welcome contributions! Please:
88
98
 
89
- 1. Check existing [issues](https://github.com/simplepractice/langfuse-rb/issues) and roadmap
99
+ 1. Check existing [issues](https://github.com/simplepractice/langfuse-rb/issues)
90
100
  2. Open an issue to discuss your idea
91
101
  3. Fork the repo and create a feature branch
92
102
  4. Write tests (maintain >95% coverage)
93
103
  5. Ensure `bundle exec rspec` and `bundle exec rubocop` pass
94
104
  6. Submit a pull request
95
105
 
96
- See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines.
106
+ > [!TIP]
107
+ > See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines.
108
+
109
+ <br>
97
110
 
98
- ## Support
111
+ ### Support
99
112
 
100
113
  - **[GitHub Issues](https://github.com/simplepractice/langfuse-rb/issues)** - Bug reports and feature requests
101
114
  - **[Langfuse Documentation](https://langfuse.com/docs)** - Platform documentation
@@ -103,4 +116,4 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines.
103
116
 
104
117
  ## License
105
118
 
106
- [MIT](LICENSE)
119
+ [MIT](LICENSE)
@@ -4,6 +4,7 @@ require "faraday"
4
4
  require "faraday/retry"
5
5
  require "base64"
6
6
  require "json"
7
+ require "uri"
7
8
 
8
9
  module Langfuse
9
10
  # HTTP client for Langfuse API
@@ -20,8 +21,7 @@ module Langfuse
20
21
  # logger: Logger.new($stdout)
21
22
  # )
22
23
  #
23
- # rubocop:disable Metrics/ClassLength
24
- class ApiClient
24
+ class ApiClient # rubocop:disable Metrics/ClassLength
25
25
  attr_reader :public_key, :secret_key, :base_url, :timeout, :logger, :cache
26
26
 
27
27
  # Initialize a new API client
@@ -106,26 +106,88 @@ module Langfuse
106
106
  # @raise [ApiError] for other API errors
107
107
  def get_prompt(name, version: nil, label: nil)
108
108
  raise ArgumentError, "Cannot specify both version and label" if version && label
109
+ return fetch_prompt_from_api(name, version: version, label: label) if cache.nil?
109
110
 
110
111
  cache_key = PromptCache.build_key(name, version: version, label: label)
112
+ fetch_with_appropriate_caching_strategy(cache_key, name, version, label)
113
+ end
111
114
 
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
115
+ # Create a new prompt (or new version if prompt with same name exists)
116
+ #
117
+ # @param name [String] The prompt name
118
+ # @param prompt [String, Array<Hash>] The prompt content
119
+ # @param type [String] Prompt type ("text" or "chat")
120
+ # @param config [Hash] Optional configuration (model params, etc.)
121
+ # @param labels [Array<String>] Optional labels (e.g., ["production"])
122
+ # @param tags [Array<String>] Optional tags
123
+ # @param commit_message [String, nil] Optional commit message
124
+ # @return [Hash] The created prompt data
125
+ # @raise [UnauthorizedError] if authentication fails
126
+ # @raise [ApiError] for other API errors
127
+ #
128
+ # @example Create a text prompt
129
+ # api_client.create_prompt(
130
+ # name: "greeting",
131
+ # prompt: "Hello {{name}}!",
132
+ # type: "text",
133
+ # labels: ["production"]
134
+ # )
135
+ #
136
+ # rubocop:disable Metrics/ParameterLists
137
+ def create_prompt(name:, prompt:, type:, config: {}, labels: [], tags: [], commit_message: nil)
138
+ path = "/api/public/v2/prompts"
139
+ payload = {
140
+ name: name,
141
+ prompt: prompt,
142
+ type: type,
143
+ config: config,
144
+ labels: labels,
145
+ tags: tags
146
+ }
147
+ payload[:commitMessage] = commit_message if commit_message
148
+
149
+ response = connection.post(path, payload)
150
+ handle_response(response)
151
+ rescue Faraday::RetriableResponse => e
152
+ logger.error("Faraday error: Retries exhausted - #{e.response.status}")
153
+ handle_response(e.response)
154
+ rescue Faraday::Error => e
155
+ logger.error("Faraday error: #{e.message}")
156
+ raise ApiError, "HTTP request failed: #{e.message}"
157
+ end
158
+ # rubocop:enable Metrics/ParameterLists
159
+
160
+ # Update labels for an existing prompt version
161
+ #
162
+ # @param name [String] The prompt name
163
+ # @param version [Integer] The version number to update
164
+ # @param labels [Array<String>] New labels (replaces existing). Required.
165
+ # @return [Hash] The updated prompt data
166
+ # @raise [ArgumentError] if labels is not an array
167
+ # @raise [NotFoundError] if the prompt is not found
168
+ # @raise [UnauthorizedError] if authentication fails
169
+ # @raise [ApiError] for other API errors
170
+ #
171
+ # @example Promote a prompt to production
172
+ # api_client.update_prompt(
173
+ # name: "greeting",
174
+ # version: 2,
175
+ # labels: ["production"]
176
+ # )
177
+ def update_prompt(name:, version:, labels:)
178
+ raise ArgumentError, "labels must be an array" unless labels.is_a?(Array)
179
+
180
+ path = "/api/public/v2/prompts/#{URI.encode_uri_component(name)}/versions/#{version}"
181
+ payload = { newLabels: labels }
182
+
183
+ response = connection.patch(path, payload)
184
+ handle_response(response)
185
+ rescue Faraday::RetriableResponse => e
186
+ logger.error("Faraday error: Retries exhausted - #{e.response.status}")
187
+ handle_response(e.response)
188
+ rescue Faraday::Error => e
189
+ logger.error("Faraday error: #{e.message}")
190
+ raise ApiError, "HTTP request failed: #{e.message}"
129
191
  end
130
192
 
131
193
  # Send a batch of events to the Langfuse ingestion API
@@ -167,8 +229,63 @@ module Langfuse
167
229
  raise ApiError, "Batch send failed: #{e.message}"
168
230
  end
169
231
 
232
+ def shutdown
233
+ cache.shutdown if cache.respond_to?(:shutdown)
234
+ end
235
+
170
236
  private
171
237
 
238
+ # Fetch prompt using the most appropriate caching strategy available
239
+ #
240
+ # @param cache_key [String] The cache key for this prompt
241
+ # @param name [String] The name of the prompt
242
+ # @param version [Integer, nil] Optional specific version number
243
+ # @param label [String, nil] Optional label
244
+ # @return [Hash] The prompt data
245
+ def fetch_with_appropriate_caching_strategy(cache_key, name, version, label)
246
+ if swr_cache_available?
247
+ fetch_with_swr_cache(cache_key, name, version, label)
248
+ elsif distributed_cache_available?
249
+ fetch_with_distributed_cache(cache_key, name, version, label)
250
+ else
251
+ fetch_with_simple_cache(cache_key, name, version, label)
252
+ end
253
+ end
254
+
255
+ # Check if SWR cache is available
256
+ def swr_cache_available?
257
+ cache.respond_to?(:swr_enabled?) && cache.swr_enabled?
258
+ end
259
+
260
+ # Check if distributed cache is available
261
+ def distributed_cache_available?
262
+ cache.respond_to?(:fetch_with_lock)
263
+ end
264
+
265
+ # Fetch with SWR cache
266
+ def fetch_with_swr_cache(cache_key, name, version, label)
267
+ cache.fetch_with_stale_while_revalidate(cache_key) do
268
+ fetch_prompt_from_api(name, version: version, label: label)
269
+ end
270
+ end
271
+
272
+ # Fetch with distributed cache (Rails.cache with stampede protection)
273
+ def fetch_with_distributed_cache(cache_key, name, version, label)
274
+ cache.fetch_with_lock(cache_key) do
275
+ fetch_prompt_from_api(name, version: version, label: label)
276
+ end
277
+ end
278
+
279
+ # Fetch with simple cache (in-memory cache)
280
+ def fetch_with_simple_cache(cache_key, name, version, label)
281
+ cached_data = cache.get(cache_key)
282
+ return cached_data if cached_data
283
+
284
+ prompt_data = fetch_prompt_from_api(name, version: version, label: label)
285
+ cache.set(cache_key, prompt_data)
286
+ prompt_data
287
+ end
288
+
172
289
  # Fetch a prompt from the API (without caching)
173
290
  #
174
291
  # @param name [String] The name of the prompt
@@ -180,7 +297,7 @@ module Langfuse
180
297
  # @raise [ApiError] for other API errors
181
298
  def fetch_prompt_from_api(name, version: nil, label: nil)
182
299
  params = build_prompt_params(version: version, label: label)
183
- path = "/api/public/v2/prompts/#{name}"
300
+ path = "/api/public/v2/prompts/#{URI.encode_uri_component(name)}"
184
301
 
185
302
  response = connection.get(path, params)
186
303
  handle_response(response)
@@ -215,7 +332,9 @@ module Langfuse
215
332
  # Retries transient errors with exponential backoff:
216
333
  # - Max 2 retries (3 total attempts)
217
334
  # - Exponential backoff (0.05s * 2^retry_count)
218
- # - Retries GET requests and POST requests to batch endpoint (idempotent operations)
335
+ # - Retries GET and PATCH requests (idempotent operations)
336
+ # - Retries POST requests to batch endpoint (idempotent due to event UUIDs)
337
+ # - Note: POST to create_prompt is NOT idempotent; retries may create duplicate versions
219
338
  # - Retries on: 429 (rate limit), 503 (service unavailable), 504 (gateway timeout)
220
339
  # - Does NOT retry on: 4xx errors (except 429), 5xx errors (except 503, 504)
221
340
  #
@@ -225,7 +344,7 @@ module Langfuse
225
344
  max: 2,
226
345
  interval: 0.05,
227
346
  backoff_factor: 2,
228
- methods: %i[get post],
347
+ methods: %i[get post patch],
229
348
  retry_statuses: [429, 503, 504],
230
349
  exceptions: [Faraday::TimeoutError, Faraday::ConnectionFailed]
231
350
  }
@@ -278,7 +397,7 @@ module Langfuse
278
397
  # @raise [ApiError] for other error statuses
279
398
  def handle_response(response)
280
399
  case response.status
281
- when 200
400
+ when 200, 201
282
401
  response.body
283
402
  when 401
284
403
  raise UnauthorizedError, "Authentication failed. Check your API keys."
@@ -327,4 +446,3 @@ module Langfuse
327
446
  end
328
447
  end
329
448
  end
330
- # rubocop:enable Metrics/ClassLength