langfuse-rb 0.7.0 → 0.8.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 +14 -1
- data/README.md +28 -73
- data/lib/langfuse/api_client.rb +72 -0
- data/lib/langfuse/client.rb +70 -7
- data/lib/langfuse/config.rb +59 -6
- data/lib/langfuse/otel_setup.rb +140 -80
- data/lib/langfuse/sampling.rb +20 -0
- data/lib/langfuse/score_client.rb +43 -18
- data/lib/langfuse/span_filter.rb +81 -0
- data/lib/langfuse/span_processor.rb +37 -36
- data/lib/langfuse/version.rb +1 -1
- data/lib/langfuse.rb +73 -5
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ba92036fbe7b63b8355a113e44ff3f62cfcb11817ae3b3c1c444756af55ebf84
|
|
4
|
+
data.tar.gz: 44097d4d441ad6d2d8780a95cb836d0368ed0b772f16036e301ece7e018df0a1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6e8880c58683dc7ff719f0c966515841e9a3e3df024f88175297090949a6befaddf23528b24a65d3825fa40e33118348087c0dbe5312543c16bfef65856eaf12
|
|
7
|
+
data.tar.gz: a82f8be469125e99355c5b6a1bd747a1f2e395dd6e0eb098f8319a0976419dfd60087c6252e26c2570db15994ed084d3156069019541c3b32acc7f1827c92792
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.8.0] - 2026-04-24
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Probabilistic trace sampling with score parity (#60)
|
|
14
|
+
- Dataset run lifecycle methods: `get_dataset_run`, `list_dataset_runs`, `delete_dataset_run` (#62)
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- Tracing is now isolated-by-default with lazy setup and smart export filtering (#77)
|
|
18
|
+
|
|
19
|
+
### Documentation
|
|
20
|
+
- Align docs with implementation (#78)
|
|
21
|
+
|
|
10
22
|
## [0.7.0] - 2026-04-14
|
|
11
23
|
|
|
12
24
|
### Added
|
|
@@ -88,7 +100,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
88
100
|
- Migrated from legacy ingestion API to OTLP endpoint
|
|
89
101
|
- Removed `tracing_enabled` configuration flag (#2)
|
|
90
102
|
|
|
91
|
-
[Unreleased]: https://github.com/simplepractice/langfuse-rb/compare/v0.
|
|
103
|
+
[Unreleased]: https://github.com/simplepractice/langfuse-rb/compare/v0.8.0...HEAD
|
|
104
|
+
[0.8.0]: https://github.com/simplepractice/langfuse-rb/compare/v0.7.0...v0.8.0
|
|
92
105
|
[0.7.0]: https://github.com/simplepractice/langfuse-rb/compare/v0.6.0...v0.7.0
|
|
93
106
|
[0.6.0]: https://github.com/simplepractice/langfuse-rb/compare/v0.5.0...v0.6.0
|
|
94
107
|
[0.5.0]: https://github.com/simplepractice/langfuse-rb/compare/v0.4.0...v0.5.0
|
data/README.md
CHANGED
|
@@ -8,69 +8,49 @@
|
|
|
8
8
|
|
|
9
9
|
> Ruby SDK for [Langfuse](https://langfuse.com) - Open-source LLM observability and prompt management.
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
### Features
|
|
14
|
-
|
|
15
|
-
- 🎯 **Prompt Management** - Centralized prompt versioning with Mustache templating
|
|
16
|
-
- 📊 **LLM Tracing** - Zero-boilerplate observability built on OpenTelemetry
|
|
17
|
-
- ⚡ **Performance** - In-memory or Redis-backed caching with stampede protection, both supporting stale-while-revalidate cache strategy
|
|
18
|
-
- 💬 **Chat & Text Prompts** - First-class support for both formats
|
|
19
|
-
- 🔄 **Automatic Retries** - Built-in exponential backoff for resilient API calls
|
|
20
|
-
- 🛡️ **Fallback Support** - Graceful degradation when API unavailable
|
|
21
|
-
- 🚀 **Rails-Friendly** - Global configuration pattern, works with any Ruby project
|
|
22
|
-
|
|
23
|
-
<br>
|
|
24
|
-
|
|
25
|
-
### Installation
|
|
11
|
+
## Installation
|
|
26
12
|
|
|
27
13
|
```ruby
|
|
28
|
-
|
|
29
|
-
gem 'langfuse-rb'
|
|
14
|
+
gem "langfuse-rb"
|
|
30
15
|
```
|
|
31
16
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
### Quick Start
|
|
35
|
-
|
|
36
|
-
> Configure once at startup
|
|
17
|
+
## Quick Start
|
|
37
18
|
|
|
38
19
|
```ruby
|
|
39
|
-
# config/initializers/langfuse.rb (Rails)
|
|
40
|
-
# Or at the top of your script
|
|
41
20
|
Langfuse.configure do |config|
|
|
42
|
-
config.public_key = ENV[
|
|
43
|
-
config.secret_key = ENV[
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
config.cache_backend = :rails # or :memory
|
|
49
|
-
config.cache_stale_while_revalidate = true
|
|
21
|
+
config.public_key = ENV["LANGFUSE_PUBLIC_KEY"]
|
|
22
|
+
config.secret_key = ENV["LANGFUSE_SECRET_KEY"]
|
|
23
|
+
config.base_url = ENV.fetch("LANGFUSE_BASE_URL", "https://cloud.langfuse.com")
|
|
24
|
+
|
|
25
|
+
# Optional: sample traces and trace-linked scores deterministically
|
|
26
|
+
config.sample_rate = 1.0
|
|
50
27
|
end
|
|
28
|
+
|
|
29
|
+
message = Langfuse.client.compile_prompt(
|
|
30
|
+
"greeting",
|
|
31
|
+
variables: { name: "Alice" }
|
|
32
|
+
)
|
|
51
33
|
```
|
|
52
34
|
|
|
53
|
-
|
|
35
|
+
Langfuse tracing is isolated by default. `Langfuse.configure` stores configuration only; it does not replace `OpenTelemetry.tracer_provider`.
|
|
54
36
|
|
|
55
|
-
|
|
56
|
-
prompt = Langfuse.client.get_prompt("greeting")
|
|
57
|
-
message = prompt.compile(name: "Alice")
|
|
58
|
-
# => "Hello Alice!"
|
|
59
|
-
```
|
|
37
|
+
`sample_rate` is applied to traces and trace-linked scores. Rebuild the client with `Langfuse.reset!` before expecting runtime sampling changes to take effect.
|
|
60
38
|
|
|
61
|
-
|
|
39
|
+
## Trace an LLM Call
|
|
62
40
|
|
|
63
41
|
```ruby
|
|
64
42
|
Langfuse.observe("chat-completion", as_type: :generation) do |gen|
|
|
43
|
+
gen.model = "gpt-4.1-mini"
|
|
44
|
+
gen.input = [{ role: "user", content: "Hello!" }]
|
|
45
|
+
|
|
65
46
|
response = openai_client.chat(
|
|
66
47
|
parameters: {
|
|
67
|
-
model: "gpt-4",
|
|
48
|
+
model: "gpt-4.1-mini",
|
|
68
49
|
messages: [{ role: "user", content: "Hello!" }]
|
|
69
50
|
}
|
|
70
51
|
)
|
|
71
52
|
|
|
72
53
|
gen.update(
|
|
73
|
-
model: "gpt-4",
|
|
74
54
|
output: response.dig("choices", 0, "message", "content"),
|
|
75
55
|
usage_details: {
|
|
76
56
|
prompt_tokens: response.dig("usage", "prompt_tokens"),
|
|
@@ -80,39 +60,14 @@ Langfuse.observe("chat-completion", as_type: :generation) do |gen|
|
|
|
80
60
|
end
|
|
81
61
|
```
|
|
82
62
|
|
|
83
|
-
|
|
84
|
-
> For complete reference see [docs](./docs/) section.
|
|
85
|
-
|
|
86
|
-
<br>
|
|
87
|
-
|
|
88
|
-
### Requirements
|
|
89
|
-
|
|
90
|
-
- Ruby >= 3.2.0
|
|
91
|
-
- No Rails dependency (works with any Ruby project)
|
|
92
|
-
|
|
93
|
-
<br>
|
|
94
|
-
|
|
95
|
-
### Contributing
|
|
96
|
-
|
|
97
|
-
We welcome contributions! Please:
|
|
98
|
-
|
|
99
|
-
1. Check existing [issues](https://github.com/simplepractice/langfuse-rb/issues)
|
|
100
|
-
2. Open an issue to discuss your idea
|
|
101
|
-
3. Fork the repo and create a feature branch
|
|
102
|
-
4. Write tests (maintain >95% coverage)
|
|
103
|
-
5. Ensure `bundle exec rspec` and `bundle exec rubocop` pass
|
|
104
|
-
6. Submit a pull request
|
|
105
|
-
|
|
106
|
-
> [!TIP]
|
|
107
|
-
> See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines.
|
|
108
|
-
|
|
109
|
-
<br>
|
|
110
|
-
|
|
111
|
-
### Support
|
|
63
|
+
## Start Here
|
|
112
64
|
|
|
113
|
-
-
|
|
114
|
-
-
|
|
115
|
-
-
|
|
65
|
+
- [Documentation Hub](docs/README.md)
|
|
66
|
+
- [Getting Started](docs/GETTING_STARTED.md)
|
|
67
|
+
- [Prompts](docs/PROMPTS.md)
|
|
68
|
+
- [Tracing](docs/TRACING.md)
|
|
69
|
+
- [Scoring](docs/SCORING.md)
|
|
70
|
+
- [Rails Patterns](docs/RAILS.md)
|
|
116
71
|
|
|
117
72
|
## License
|
|
118
73
|
|
data/lib/langfuse/api_client.rb
CHANGED
|
@@ -260,6 +260,61 @@ module Langfuse
|
|
|
260
260
|
end
|
|
261
261
|
end
|
|
262
262
|
|
|
263
|
+
# Fetch a dataset run by dataset and run name
|
|
264
|
+
#
|
|
265
|
+
# @param dataset_name [String] Dataset name (required)
|
|
266
|
+
# @param run_name [String] Run name (required)
|
|
267
|
+
# @return [Hash] The dataset run data
|
|
268
|
+
# @raise [NotFoundError] if the dataset run is not found
|
|
269
|
+
# @raise [UnauthorizedError] if authentication fails
|
|
270
|
+
# @raise [ApiError] for other API errors
|
|
271
|
+
def get_dataset_run(dataset_name:, run_name:)
|
|
272
|
+
with_faraday_error_handling do
|
|
273
|
+
response = connection.get(dataset_run_path(dataset_name: dataset_name, run_name: run_name))
|
|
274
|
+
handle_response(response)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# List dataset runs in a dataset
|
|
279
|
+
#
|
|
280
|
+
# @param dataset_name [String] Dataset name (required)
|
|
281
|
+
# @param page [Integer, nil] Optional page number for pagination
|
|
282
|
+
# @param limit [Integer, nil] Optional limit per page
|
|
283
|
+
# @return [Array<Hash>] Array of dataset run hashes
|
|
284
|
+
# @raise [UnauthorizedError] if authentication fails
|
|
285
|
+
# @raise [ApiError] for other API errors
|
|
286
|
+
def list_dataset_runs(dataset_name:, page: nil, limit: nil)
|
|
287
|
+
result = list_dataset_runs_paginated(dataset_name: dataset_name, page: page, limit: limit)
|
|
288
|
+
result["data"] || []
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Full paginated response including "meta" for internal pagination use
|
|
292
|
+
#
|
|
293
|
+
# @api private
|
|
294
|
+
# @return [Hash] Full response hash with "data" array and "meta" pagination info
|
|
295
|
+
def list_dataset_runs_paginated(dataset_name:, page: nil, limit: nil)
|
|
296
|
+
with_faraday_error_handling do
|
|
297
|
+
response = connection.get(dataset_runs_path(dataset_name), build_dataset_runs_params(page: page, limit: limit))
|
|
298
|
+
handle_response(response)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Delete a dataset run by name
|
|
303
|
+
#
|
|
304
|
+
# @param dataset_name [String] Dataset name (required)
|
|
305
|
+
# @param run_name [String] Run name (required)
|
|
306
|
+
# @return [Hash, nil] Response body, or nil for 204 responses
|
|
307
|
+
# @raise [NotFoundError] if the dataset run is not found
|
|
308
|
+
# @raise [UnauthorizedError] if authentication fails
|
|
309
|
+
# @raise [ApiError] for other API errors
|
|
310
|
+
# @note 404 responses raise NotFoundError to preserve strict delete semantics
|
|
311
|
+
def delete_dataset_run(dataset_name:, run_name:)
|
|
312
|
+
with_faraday_error_handling do
|
|
313
|
+
response = connection.delete(dataset_run_path(dataset_name: dataset_name, run_name: run_name))
|
|
314
|
+
response.status == 204 ? nil : handle_response(response)
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
263
318
|
# Fetch projects accessible with the current API keys
|
|
264
319
|
#
|
|
265
320
|
# @return [Hash] The parsed response body containing project data
|
|
@@ -604,6 +659,23 @@ module Langfuse
|
|
|
604
659
|
}.compact
|
|
605
660
|
end
|
|
606
661
|
|
|
662
|
+
# Build params for list_dataset_runs
|
|
663
|
+
def build_dataset_runs_params(page:, limit:)
|
|
664
|
+
{ page: page, limit: limit }.compact
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
# Build endpoint path for dataset runs
|
|
668
|
+
def dataset_runs_path(dataset_name)
|
|
669
|
+
encoded_name = URI.encode_uri_component(dataset_name)
|
|
670
|
+
"/api/public/datasets/#{encoded_name}/runs"
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
# Build endpoint path for a specific dataset run
|
|
674
|
+
def dataset_run_path(dataset_name:, run_name:)
|
|
675
|
+
encoded_run_name = URI.encode_uri_component(run_name)
|
|
676
|
+
"#{dataset_runs_path(dataset_name)}/#{encoded_run_name}"
|
|
677
|
+
end
|
|
678
|
+
|
|
607
679
|
# Build query params for list_traces, mapping snake_case to camelCase
|
|
608
680
|
# rubocop:disable Metrics/ParameterLists
|
|
609
681
|
def build_traces_params(page:, limit:, user_id:, name:, session_id:,
|
data/lib/langfuse/client.rb
CHANGED
|
@@ -588,6 +588,51 @@ module Langfuse
|
|
|
588
588
|
)
|
|
589
589
|
end
|
|
590
590
|
|
|
591
|
+
# Fetch a dataset run by dataset and run name
|
|
592
|
+
#
|
|
593
|
+
# @param dataset_name [String] Dataset name (required)
|
|
594
|
+
# @param run_name [String] Run name (required)
|
|
595
|
+
# @return [Hash] The dataset run data, including linked run items
|
|
596
|
+
# @raise [NotFoundError] if the dataset run is not found
|
|
597
|
+
# @raise [UnauthorizedError] if authentication fails
|
|
598
|
+
# @raise [ApiError] for other API errors
|
|
599
|
+
def get_dataset_run(dataset_name:, run_name:)
|
|
600
|
+
api_client.get_dataset_run(dataset_name: dataset_name, run_name: run_name)
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
# List dataset runs for a dataset
|
|
604
|
+
#
|
|
605
|
+
# When page is nil (default), auto-paginates to fetch all runs.
|
|
606
|
+
# When page is provided, returns only that single page.
|
|
607
|
+
#
|
|
608
|
+
# @param dataset_name [String] Dataset name (required)
|
|
609
|
+
# @param page [Integer, nil] Optional page number for pagination
|
|
610
|
+
# @param limit [Integer, nil] Optional limit per page
|
|
611
|
+
# @return [Array<Hash>] Array of dataset run hashes
|
|
612
|
+
# @raise [UnauthorizedError] if authentication fails
|
|
613
|
+
# @raise [ApiError] for other API errors
|
|
614
|
+
def list_dataset_runs(dataset_name:, page: nil, limit: nil)
|
|
615
|
+
if page
|
|
616
|
+
fetch_dataset_runs_page(dataset_name: dataset_name, page: page, limit: limit)
|
|
617
|
+
else
|
|
618
|
+
fetch_all_dataset_runs(dataset_name: dataset_name, limit: limit)
|
|
619
|
+
end
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
# Delete a dataset run by name
|
|
623
|
+
#
|
|
624
|
+
# @param dataset_name [String] Dataset name (required)
|
|
625
|
+
# @param run_name [String] Run name (required)
|
|
626
|
+
# @return [nil]
|
|
627
|
+
# @raise [NotFoundError] if the dataset run is not found
|
|
628
|
+
# @raise [UnauthorizedError] if authentication fails
|
|
629
|
+
# @raise [ApiError] for other API errors
|
|
630
|
+
# @note 404 responses raise NotFoundError to preserve strict delete semantics
|
|
631
|
+
def delete_dataset_run(dataset_name:, run_name:)
|
|
632
|
+
api_client.delete_dataset_run(dataset_name: dataset_name, run_name: run_name)
|
|
633
|
+
nil
|
|
634
|
+
end
|
|
635
|
+
|
|
591
636
|
# Run an experiment against local data or a named dataset
|
|
592
637
|
#
|
|
593
638
|
# @param name [String] Experiment/run name (required)
|
|
@@ -656,17 +701,35 @@ module Langfuse
|
|
|
656
701
|
end
|
|
657
702
|
|
|
658
703
|
def fetch_all_dataset_items(limit:, **filters)
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
704
|
+
fetch_all_paginated_data(limit: limit) do |page:, per_page:|
|
|
705
|
+
api_client.list_dataset_items_paginated(page: page, limit: per_page, **filters)
|
|
706
|
+
end
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
def fetch_dataset_runs_page(dataset_name:, page:, limit:)
|
|
710
|
+
api_client.list_dataset_runs(dataset_name: dataset_name, page: page, limit: limit)
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
def fetch_all_dataset_runs(dataset_name:, limit:)
|
|
714
|
+
fetch_all_paginated_data(limit: limit) do |page:, per_page:|
|
|
715
|
+
api_client.list_dataset_runs_paginated(dataset_name: dataset_name, page: page, limit: per_page)
|
|
716
|
+
end
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
# Keep dataset collection pagination semantics in one place so edge cases
|
|
720
|
+
# like missing or zero totalPages stay consistent across APIs.
|
|
721
|
+
def fetch_all_paginated_data(limit:)
|
|
722
|
+
page_size = limit || DATASET_ITEMS_PAGE_SIZE
|
|
723
|
+
first_result = yield(page: 1, per_page: page_size)
|
|
724
|
+
records = first_result["data"] || []
|
|
662
725
|
total_pages = first_result.dig("meta", "totalPages") || 1
|
|
663
726
|
|
|
664
|
-
(2..total_pages).each do |
|
|
665
|
-
result =
|
|
666
|
-
|
|
727
|
+
(2..total_pages).each do |page|
|
|
728
|
+
result = yield(page: page, per_page: page_size)
|
|
729
|
+
records.concat(result["data"] || [])
|
|
667
730
|
end
|
|
668
731
|
|
|
669
|
-
|
|
732
|
+
records
|
|
670
733
|
end
|
|
671
734
|
|
|
672
735
|
def resolve_experiment_items(data, dataset_name)
|
data/lib/langfuse/config.rb
CHANGED
|
@@ -18,6 +18,7 @@ module Langfuse
|
|
|
18
18
|
# c.secret_key = "sk_..."
|
|
19
19
|
# end
|
|
20
20
|
#
|
|
21
|
+
# rubocop:disable Metrics/ClassLength
|
|
21
22
|
class Config
|
|
22
23
|
# @return [String, nil] Langfuse public API key
|
|
23
24
|
attr_accessor :public_key
|
|
@@ -74,6 +75,12 @@ module Langfuse
|
|
|
74
75
|
# @return [String, nil] Default release identifier applied to new traces/observations
|
|
75
76
|
attr_accessor :release
|
|
76
77
|
|
|
78
|
+
# @return [Float] Trace sampling rate from 0.0 to 1.0
|
|
79
|
+
attr_reader :sample_rate
|
|
80
|
+
|
|
81
|
+
# @return [#call, nil] Callback that decides whether an ended span should export to Langfuse.
|
|
82
|
+
attr_accessor :should_export_span
|
|
83
|
+
|
|
77
84
|
# @return [#call, nil] Mask callable applied to input, output, and metadata before serialization.
|
|
78
85
|
# Receives `data:` keyword argument. nil disables masking.
|
|
79
86
|
attr_accessor :mask
|
|
@@ -114,6 +121,9 @@ module Langfuse
|
|
|
114
121
|
# @return [Symbol] Default ActiveJob queue name
|
|
115
122
|
DEFAULT_JOB_QUEUE = :default
|
|
116
123
|
|
|
124
|
+
# @return [Float] Default trace sampling rate (sample all traces)
|
|
125
|
+
DEFAULT_SAMPLE_RATE = 1.0
|
|
126
|
+
|
|
117
127
|
# @return [Integer] Number of seconds representing indefinite cache duration (~1000 years)
|
|
118
128
|
INDEFINITE_SECONDS = 1000 * 365 * 24 * 60 * 60
|
|
119
129
|
|
|
@@ -136,7 +146,6 @@ module Langfuse
|
|
|
136
146
|
# @yield [config] Optional block for configuration
|
|
137
147
|
# @yieldparam config [Config] The config instance
|
|
138
148
|
# @return [Config] a new Config instance
|
|
139
|
-
# rubocop:disable Metrics/AbcSize
|
|
140
149
|
def initialize
|
|
141
150
|
@public_key = ENV.fetch("LANGFUSE_PUBLIC_KEY", nil)
|
|
142
151
|
@secret_key = ENV.fetch("LANGFUSE_SECRET_KEY", nil)
|
|
@@ -153,14 +162,11 @@ module Langfuse
|
|
|
153
162
|
@batch_size = DEFAULT_BATCH_SIZE
|
|
154
163
|
@flush_interval = DEFAULT_FLUSH_INTERVAL
|
|
155
164
|
@job_queue = DEFAULT_JOB_QUEUE
|
|
156
|
-
|
|
157
|
-
@release = env_value("LANGFUSE_RELEASE") || detect_release_from_ci_env
|
|
158
|
-
@mask = nil
|
|
165
|
+
initialize_tracing_defaults
|
|
159
166
|
@logger = default_logger
|
|
160
167
|
|
|
161
168
|
yield(self) if block_given?
|
|
162
169
|
end
|
|
163
|
-
# rubocop:enable Metrics/AbcSize
|
|
164
170
|
|
|
165
171
|
# Validate the configuration
|
|
166
172
|
#
|
|
@@ -183,7 +189,8 @@ module Langfuse
|
|
|
183
189
|
validate_swr_config!
|
|
184
190
|
|
|
185
191
|
validate_cache_backend!
|
|
186
|
-
|
|
192
|
+
validate_sample_rate!
|
|
193
|
+
validate_should_export_span!
|
|
187
194
|
validate_mask!
|
|
188
195
|
end
|
|
189
196
|
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
@@ -205,6 +212,15 @@ module Langfuse
|
|
|
205
212
|
cache_stale_ttl == :indefinite ? INDEFINITE_SECONDS : cache_stale_ttl
|
|
206
213
|
end
|
|
207
214
|
|
|
215
|
+
# Set trace sampling rate.
|
|
216
|
+
#
|
|
217
|
+
# @param value [Numeric, String] Sampling rate from 0.0 to 1.0
|
|
218
|
+
# @raise [ConfigurationError] if value is non-numeric or outside 0.0..1.0
|
|
219
|
+
# @return [Float]
|
|
220
|
+
def sample_rate=(value)
|
|
221
|
+
@sample_rate = coerce_sample_rate(value)
|
|
222
|
+
end
|
|
223
|
+
|
|
208
224
|
private
|
|
209
225
|
|
|
210
226
|
def default_logger
|
|
@@ -215,6 +231,14 @@ module Langfuse
|
|
|
215
231
|
end
|
|
216
232
|
end
|
|
217
233
|
|
|
234
|
+
def initialize_tracing_defaults
|
|
235
|
+
@environment = env_value("LANGFUSE_TRACING_ENVIRONMENT")
|
|
236
|
+
@release = env_value("LANGFUSE_RELEASE") || detect_release_from_ci_env
|
|
237
|
+
self.sample_rate = env_value("LANGFUSE_SAMPLE_RATE") || DEFAULT_SAMPLE_RATE
|
|
238
|
+
@should_export_span = nil
|
|
239
|
+
@mask = nil
|
|
240
|
+
end
|
|
241
|
+
|
|
218
242
|
def validate_cache_backend!
|
|
219
243
|
valid_backends = %i[memory rails]
|
|
220
244
|
return if valid_backends.include?(cache_backend)
|
|
@@ -255,12 +279,24 @@ module Langfuse
|
|
|
255
279
|
raise ConfigurationError, "cache_refresh_threads must be positive"
|
|
256
280
|
end
|
|
257
281
|
|
|
282
|
+
def validate_sample_rate!
|
|
283
|
+
return if sample_rate.is_a?(Numeric) && sample_rate.between?(0.0, 1.0)
|
|
284
|
+
|
|
285
|
+
raise ConfigurationError, "sample_rate must be between 0.0 and 1.0"
|
|
286
|
+
end
|
|
287
|
+
|
|
258
288
|
def validate_mask!
|
|
259
289
|
return if mask.nil? || mask.respond_to?(:call)
|
|
260
290
|
|
|
261
291
|
raise ConfigurationError, "mask must respond to #call"
|
|
262
292
|
end
|
|
263
293
|
|
|
294
|
+
def validate_should_export_span!
|
|
295
|
+
return if should_export_span.nil? || should_export_span.respond_to?(:call)
|
|
296
|
+
|
|
297
|
+
raise ConfigurationError, "should_export_span must respond to #call"
|
|
298
|
+
end
|
|
299
|
+
|
|
264
300
|
def detect_release_from_ci_env
|
|
265
301
|
COMMON_RELEASE_ENV_KEYS.each do |key|
|
|
266
302
|
value = env_value(key)
|
|
@@ -276,5 +312,22 @@ module Langfuse
|
|
|
276
312
|
|
|
277
313
|
value
|
|
278
314
|
end
|
|
315
|
+
|
|
316
|
+
def coerce_sample_rate(value)
|
|
317
|
+
numeric_value = if value.is_a?(Numeric)
|
|
318
|
+
value.to_f
|
|
319
|
+
elsif value.is_a?(String)
|
|
320
|
+
Float(value)
|
|
321
|
+
else
|
|
322
|
+
raise ConfigurationError, "sample_rate must be numeric"
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
return numeric_value if numeric_value.between?(0.0, 1.0)
|
|
326
|
+
|
|
327
|
+
raise ConfigurationError, "sample_rate must be between 0.0 and 1.0"
|
|
328
|
+
rescue ArgumentError, TypeError
|
|
329
|
+
raise ConfigurationError, "sample_rate must be numeric"
|
|
330
|
+
end
|
|
279
331
|
end
|
|
332
|
+
# rubocop:enable Metrics/ClassLength
|
|
280
333
|
end
|
data/lib/langfuse/otel_setup.rb
CHANGED
|
@@ -2,104 +2,78 @@
|
|
|
2
2
|
|
|
3
3
|
require "opentelemetry/sdk"
|
|
4
4
|
require "opentelemetry/exporter/otlp"
|
|
5
|
-
require "opentelemetry/trace/propagation/trace_context"
|
|
6
5
|
require "base64"
|
|
7
6
|
|
|
8
7
|
module Langfuse
|
|
9
|
-
# OpenTelemetry initialization and setup
|
|
10
|
-
#
|
|
11
|
-
# Handles configuration of the OTel SDK with Langfuse OTLP exporter
|
|
12
|
-
# when tracing is enabled.
|
|
13
|
-
#
|
|
8
|
+
# OpenTelemetry initialization and setup for Langfuse tracing.
|
|
9
|
+
# rubocop:disable Metrics/ModuleLength
|
|
14
10
|
module OtelSetup
|
|
11
|
+
TRACING_CONFIG_FIELDS = %i[
|
|
12
|
+
public_key
|
|
13
|
+
secret_key
|
|
14
|
+
base_url
|
|
15
|
+
environment
|
|
16
|
+
release
|
|
17
|
+
sample_rate
|
|
18
|
+
should_export_span
|
|
19
|
+
tracing_async
|
|
20
|
+
batch_size
|
|
21
|
+
flush_interval
|
|
22
|
+
].freeze
|
|
23
|
+
private_constant(:TRACING_CONFIG_FIELDS)
|
|
24
|
+
|
|
15
25
|
class << self
|
|
16
|
-
# @return [OpenTelemetry::SDK::Trace::TracerProvider, nil] The configured tracer provider
|
|
26
|
+
# @return [OpenTelemetry::SDK::Trace::TracerProvider, nil] The configured internal tracer provider
|
|
17
27
|
attr_reader :tracer_provider
|
|
18
28
|
|
|
19
|
-
# Initialize
|
|
29
|
+
# Initialize Langfuse's internal tracer provider without mutating global OpenTelemetry state.
|
|
20
30
|
#
|
|
21
31
|
# @param config [Langfuse::Config] The Langfuse configuration
|
|
22
|
-
# @return [
|
|
23
|
-
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
32
|
+
# @return [OpenTelemetry::SDK::Trace::TracerProvider]
|
|
24
33
|
def setup(config)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
# Async: BatchSpanProcessor batches and sends in background
|
|
37
|
-
OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
|
|
38
|
-
exporter,
|
|
39
|
-
max_queue_size: config.batch_size * 2, # Buffer more than batch_size
|
|
40
|
-
schedule_delay: config.flush_interval * 1000, # Convert seconds to milliseconds
|
|
41
|
-
max_export_batch_size: config.batch_size
|
|
42
|
-
)
|
|
43
|
-
else
|
|
44
|
-
# Sync: BatchSpanProcessor with minimal delay (flushes on force_flush)
|
|
45
|
-
# This collects spans from the same trace and exports them together,
|
|
46
|
-
# which is critical for correct parent_observation_id calculation
|
|
47
|
-
OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
|
|
48
|
-
exporter,
|
|
49
|
-
max_queue_size: config.batch_size * 2,
|
|
50
|
-
schedule_delay: 60_000, # 60 seconds (relies on explicit force_flush)
|
|
51
|
-
max_export_batch_size: config.batch_size
|
|
52
|
-
)
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
# Create TracerProvider with processor
|
|
56
|
-
@tracer_provider = OpenTelemetry::SDK::Trace::TracerProvider.new
|
|
57
|
-
@tracer_provider.add_span_processor(processor)
|
|
58
|
-
|
|
59
|
-
# Add span processor for propagated attributes and env/release defaults
|
|
60
|
-
# This must be added AFTER the BatchSpanProcessor so it runs before export and can
|
|
61
|
-
# apply all attributes (propagated IDs, environment, release) to the spans being sent
|
|
62
|
-
span_processor = SpanProcessor.new(config: config)
|
|
63
|
-
@tracer_provider.add_span_processor(span_processor)
|
|
64
|
-
|
|
65
|
-
# Set as global tracer provider
|
|
66
|
-
OpenTelemetry.tracer_provider = @tracer_provider
|
|
67
|
-
|
|
68
|
-
# Configure W3C TraceContext propagator if not already set
|
|
69
|
-
if OpenTelemetry.propagation.is_a?(OpenTelemetry::Context::Propagation::NoopTextMapPropagator)
|
|
70
|
-
OpenTelemetry.propagation = OpenTelemetry::Trace::Propagation::TraceContext::TextMapPropagator.new
|
|
71
|
-
config.logger.debug("Langfuse: Configured W3C TraceContext propagator")
|
|
72
|
-
else
|
|
73
|
-
config.logger.debug("Langfuse: Using existing propagator: #{OpenTelemetry.propagation.class}")
|
|
34
|
+
validate_tracing_config!(config)
|
|
35
|
+
return existing_provider_for(config) if initialized?
|
|
36
|
+
|
|
37
|
+
candidate_provider = nil
|
|
38
|
+
provider = nil
|
|
39
|
+
created = false
|
|
40
|
+
candidate_provider = build_tracer_provider(config)
|
|
41
|
+
provider, created = publish_provider(candidate_provider, tracing_config_snapshot(config))
|
|
42
|
+
unless created
|
|
43
|
+
candidate_provider.shutdown(timeout: 30)
|
|
44
|
+
return existing_provider_for(config)
|
|
74
45
|
end
|
|
75
46
|
|
|
76
|
-
|
|
77
|
-
|
|
47
|
+
log_initialized(config)
|
|
48
|
+
provider
|
|
49
|
+
rescue StandardError
|
|
50
|
+
rollback_provider(provider) if created
|
|
51
|
+
raise
|
|
78
52
|
end
|
|
79
|
-
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
80
53
|
|
|
81
|
-
# Shutdown the tracer provider and flush any pending spans
|
|
54
|
+
# Shutdown the internal tracer provider and flush any pending spans.
|
|
82
55
|
#
|
|
83
56
|
# @param timeout [Integer] Timeout in seconds
|
|
84
57
|
# @return [void]
|
|
85
58
|
def shutdown(timeout: 30)
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
59
|
+
provider = nil
|
|
60
|
+
setup_mutex.synchronize do
|
|
61
|
+
provider = @tracer_provider
|
|
62
|
+
@tracer_provider = nil
|
|
63
|
+
@config_snapshot = nil
|
|
64
|
+
end
|
|
65
|
+
provider&.shutdown(timeout: timeout)
|
|
90
66
|
end
|
|
91
67
|
|
|
92
|
-
# Force flush all pending spans
|
|
68
|
+
# Force flush all pending spans on the internal tracer provider.
|
|
93
69
|
#
|
|
94
70
|
# @param timeout [Integer] Timeout in seconds
|
|
95
71
|
# @return [void]
|
|
96
72
|
def force_flush(timeout: 30)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
@tracer_provider.force_flush(timeout: timeout)
|
|
73
|
+
@tracer_provider&.force_flush(timeout: timeout)
|
|
100
74
|
end
|
|
101
75
|
|
|
102
|
-
# Check if
|
|
76
|
+
# Check if Langfuse tracing has been initialized.
|
|
103
77
|
#
|
|
104
78
|
# @return [Boolean]
|
|
105
79
|
def initialized?
|
|
@@ -108,18 +82,104 @@ module Langfuse
|
|
|
108
82
|
|
|
109
83
|
private
|
|
110
84
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
85
|
+
def existing_provider_for(config)
|
|
86
|
+
snapshot = tracing_config_snapshot(config)
|
|
87
|
+
if @config_snapshot == snapshot
|
|
88
|
+
config.logger.debug("Langfuse tracing already initialized; reusing existing tracer provider")
|
|
89
|
+
else
|
|
90
|
+
config.logger.warn(
|
|
91
|
+
"Langfuse tracing is already initialized. Changes to #{TRACING_CONFIG_FIELDS.join(', ')} " \
|
|
92
|
+
"require Langfuse.reset! before they take effect."
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
@tracer_provider
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def publish_provider(provider, snapshot)
|
|
99
|
+
created = false
|
|
100
|
+
current = nil
|
|
101
|
+
|
|
102
|
+
# This mutex only guards publication so setup never exposes a half-built provider.
|
|
103
|
+
setup_mutex.synchronize do
|
|
104
|
+
if @tracer_provider
|
|
105
|
+
current = @tracer_provider
|
|
106
|
+
else
|
|
107
|
+
@tracer_provider = provider
|
|
108
|
+
@config_snapshot = snapshot
|
|
109
|
+
current = provider
|
|
110
|
+
created = true
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
[current, created]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def rollback_provider(provider)
|
|
118
|
+
setup_mutex.synchronize do
|
|
119
|
+
return unless @tracer_provider.equal?(provider)
|
|
120
|
+
|
|
121
|
+
@tracer_provider = nil
|
|
122
|
+
@config_snapshot = nil
|
|
123
|
+
end
|
|
124
|
+
provider.shutdown(timeout: 1)
|
|
125
|
+
rescue StandardError
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def build_tracer_provider(config)
|
|
130
|
+
provider = OpenTelemetry::SDK::Trace::TracerProvider.new(
|
|
131
|
+
sampler: build_sampler(config.sample_rate)
|
|
132
|
+
)
|
|
133
|
+
provider.add_span_processor(
|
|
134
|
+
SpanProcessor.new(config: config, exporter: build_exporter(config))
|
|
135
|
+
)
|
|
136
|
+
provider
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def build_exporter(config)
|
|
140
|
+
OpenTelemetry::Exporter::OTLP::Exporter.new(
|
|
141
|
+
endpoint: "#{config.base_url}/api/public/otel/v1/traces",
|
|
142
|
+
headers: build_headers(config.public_key, config.secret_key),
|
|
143
|
+
compression: "gzip"
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def log_initialized(config)
|
|
148
|
+
mode = config.tracing_async ? "async" : "sync"
|
|
149
|
+
config.logger.info("Langfuse tracing initialized with OpenTelemetry (#{mode} mode)")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def validate_tracing_config!(config)
|
|
153
|
+
raise ConfigurationError, "public_key is required" if blank?(config.public_key)
|
|
154
|
+
raise ConfigurationError, "secret_key is required" if blank?(config.secret_key)
|
|
155
|
+
raise ConfigurationError, "base_url cannot be empty" if blank?(config.base_url)
|
|
156
|
+
return if config.should_export_span.nil? || config.should_export_span.respond_to?(:call)
|
|
157
|
+
|
|
158
|
+
raise ConfigurationError, "should_export_span must respond to #call"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def tracing_config_snapshot(config)
|
|
162
|
+
TRACING_CONFIG_FIELDS.to_h { |field| [field, config.public_send(field)] }.freeze
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def setup_mutex
|
|
166
|
+
@setup_mutex ||= Mutex.new
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def blank?(value)
|
|
170
|
+
value.nil? || value.empty?
|
|
171
|
+
end
|
|
172
|
+
|
|
116
173
|
def build_headers(public_key, secret_key)
|
|
117
174
|
credentials = "#{public_key}:#{secret_key}"
|
|
118
175
|
encoded = Base64.strict_encode64(credentials)
|
|
119
|
-
{
|
|
120
|
-
|
|
121
|
-
|
|
176
|
+
{ "Authorization" => "Basic #{encoded}" }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def build_sampler(sample_rate)
|
|
180
|
+
Sampling.build_sampler(sample_rate) || OpenTelemetry::SDK::Trace::Samplers::ALWAYS_ON
|
|
122
181
|
end
|
|
123
182
|
end
|
|
124
183
|
end
|
|
184
|
+
# rubocop:enable Metrics/ModuleLength
|
|
125
185
|
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Langfuse
|
|
4
|
+
# Shared sampling helpers for trace and score emission.
|
|
5
|
+
#
|
|
6
|
+
# @api private
|
|
7
|
+
module Sampling
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Build the sampler used by both trace export and trace-linked score emission.
|
|
11
|
+
#
|
|
12
|
+
# @param sample_rate [Float] Sampling rate from 0.0 to 1.0
|
|
13
|
+
# @return [OpenTelemetry::SDK::Trace::Samplers::TraceIdRatioBased, nil]
|
|
14
|
+
def build_sampler(sample_rate)
|
|
15
|
+
return nil if sample_rate >= 1.0
|
|
16
|
+
|
|
17
|
+
OpenTelemetry::SDK::Trace::Samplers::TraceIdRatioBased.new(sample_rate)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -31,6 +31,8 @@ module Langfuse
|
|
|
31
31
|
# @return [Logger] Logger instance
|
|
32
32
|
attr_reader :logger
|
|
33
33
|
|
|
34
|
+
HEX_TRACE_ID_PATTERN = /\A[0-9a-f]{32}\z/
|
|
35
|
+
|
|
34
36
|
# Initialize a new ScoreClient
|
|
35
37
|
#
|
|
36
38
|
# @param api_client [ApiClient] The API client for sending batches
|
|
@@ -43,6 +45,9 @@ module Langfuse
|
|
|
43
45
|
@mutex = Mutex.new
|
|
44
46
|
@flush_thread = nil
|
|
45
47
|
@shutdown = false
|
|
48
|
+
# Match the immutable tracing setup contract: once this client exists, later config
|
|
49
|
+
# mutations must not change score sampling without rebuilding the client.
|
|
50
|
+
@score_sampler = Sampling.build_sampler(config.sample_rate)
|
|
46
51
|
|
|
47
52
|
start_flush_timer
|
|
48
53
|
end
|
|
@@ -76,28 +81,19 @@ module Langfuse
|
|
|
76
81
|
def create(name:, value:, id: nil, trace_id: nil, session_id: nil, observation_id: nil, comment: nil,
|
|
77
82
|
metadata: nil, environment: nil, data_type: :numeric, dataset_run_id: nil, config_id: nil)
|
|
78
83
|
validate_name(name)
|
|
79
|
-
# Keep identifier policy server-side to preserve cross-SDK parity and avoid blocking valid future payloads.
|
|
80
84
|
normalized_value = normalize_value(value, data_type)
|
|
81
85
|
data_type_str = Types::SCORE_DATA_TYPES[data_type] || raise(ArgumentError, "Invalid data_type: #{data_type}")
|
|
82
86
|
|
|
87
|
+
return unless enqueue_trace_linked_score?(trace_id)
|
|
88
|
+
|
|
83
89
|
event = build_score_event(
|
|
84
|
-
name: name,
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
session_id: session_id,
|
|
89
|
-
observation_id: observation_id,
|
|
90
|
-
comment: comment,
|
|
91
|
-
metadata: metadata,
|
|
92
|
-
environment: environment,
|
|
93
|
-
data_type: data_type_str,
|
|
94
|
-
dataset_run_id: dataset_run_id,
|
|
95
|
-
config_id: config_id
|
|
90
|
+
name: name, value: normalized_value, id: id, trace_id: trace_id,
|
|
91
|
+
session_id: session_id, observation_id: observation_id, comment: comment,
|
|
92
|
+
metadata: metadata, environment: environment, data_type: data_type_str,
|
|
93
|
+
dataset_run_id: dataset_run_id, config_id: config_id
|
|
96
94
|
)
|
|
97
95
|
|
|
98
96
|
@queue << event
|
|
99
|
-
|
|
100
|
-
# Trigger flush if batch size reached
|
|
101
97
|
flush if @queue.size >= config.batch_size
|
|
102
98
|
rescue StandardError => e
|
|
103
99
|
logger.error("Langfuse score creation failed: #{e.message}")
|
|
@@ -294,14 +290,43 @@ module Langfuse
|
|
|
294
290
|
# @return [Hash] Hash with :trace_id and :observation_id (may be nil)
|
|
295
291
|
def extract_ids_from_active_span
|
|
296
292
|
span = OpenTelemetry::Trace.current_span
|
|
297
|
-
|
|
293
|
+
span_context = span&.context
|
|
294
|
+
return { trace_id: nil, observation_id: nil } unless span_context&.valid?
|
|
298
295
|
|
|
299
296
|
{
|
|
300
|
-
trace_id:
|
|
301
|
-
observation_id:
|
|
297
|
+
trace_id: span_context.trace_id.unpack1("H*"),
|
|
298
|
+
observation_id: span_context.span_id.unpack1("H*")
|
|
302
299
|
}
|
|
303
300
|
end
|
|
304
301
|
|
|
302
|
+
# Score sampling is decided purely by the configured sampler on the trace_id hash,
|
|
303
|
+
# matching langfuse-python. Non-hex trace ids and session/dataset-only scores bypass sampling.
|
|
304
|
+
def enqueue_trace_linked_score?(trace_id)
|
|
305
|
+
return true if trace_id.nil?
|
|
306
|
+
return true unless HEX_TRACE_ID_PATTERN.match?(trace_id)
|
|
307
|
+
|
|
308
|
+
sampler = score_sampler
|
|
309
|
+
return true if sampler.nil?
|
|
310
|
+
return true unless sampler.respond_to?(:should_sample?)
|
|
311
|
+
|
|
312
|
+
sample_result = sampler.should_sample?(
|
|
313
|
+
trace_id: [trace_id].pack("H*"),
|
|
314
|
+
parent_context: nil,
|
|
315
|
+
links: [],
|
|
316
|
+
name: "score",
|
|
317
|
+
kind: OpenTelemetry::Trace::SpanKind::INTERNAL,
|
|
318
|
+
attributes: {}
|
|
319
|
+
)
|
|
320
|
+
sample_result.sampled?
|
|
321
|
+
rescue StandardError => e
|
|
322
|
+
logger.warn("Langfuse score sampling fallback for trace_id=#{trace_id}: #{e.message}")
|
|
323
|
+
true
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Sampler is pinned at ScoreClient construction to match the "sample_rate requires reset!"
|
|
327
|
+
# contract and to keep each client's sampling scoped to its own config.
|
|
328
|
+
attr_reader :score_sampler
|
|
329
|
+
|
|
305
330
|
# Send a batch of events to the API
|
|
306
331
|
#
|
|
307
332
|
# @param events [Array<Hash>] Array of event hashes
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Langfuse
|
|
4
|
+
# Instrumentation scope name used by module-level Langfuse tracing.
|
|
5
|
+
LANGFUSE_TRACER_NAME = "langfuse-rb"
|
|
6
|
+
|
|
7
|
+
# Conservative allowlist of instrumentation scope prefixes that clearly belong to LLM workflows.
|
|
8
|
+
KNOWN_LLM_INSTRUMENTATION_SCOPE_PREFIXES = [
|
|
9
|
+
LANGFUSE_TRACER_NAME,
|
|
10
|
+
"agent_framework",
|
|
11
|
+
"ai",
|
|
12
|
+
"haystack",
|
|
13
|
+
"langsmith",
|
|
14
|
+
"litellm",
|
|
15
|
+
"openinference",
|
|
16
|
+
"opentelemetry.instrumentation.anthropic",
|
|
17
|
+
"strands-agents",
|
|
18
|
+
"vllm"
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
# Matched per span in the export path, so avoid allocating the dotted form each call.
|
|
22
|
+
KNOWN_LLM_INSTRUMENTATION_SCOPE_DOTTED_PREFIXES =
|
|
23
|
+
KNOWN_LLM_INSTRUMENTATION_SCOPE_PREFIXES.map { |prefix| "#{prefix}." }.freeze
|
|
24
|
+
private_constant :KNOWN_LLM_INSTRUMENTATION_SCOPE_DOTTED_PREFIXES
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
# Return whether the span was created by Langfuse's tracer.
|
|
28
|
+
#
|
|
29
|
+
# @param span [#instrumentation_scope] Span or span data to inspect
|
|
30
|
+
# @return [Boolean]
|
|
31
|
+
def langfuse_span?(span)
|
|
32
|
+
instrumentation_scope_name(span) == LANGFUSE_TRACER_NAME
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Return whether the span contains `gen_ai.*` attributes.
|
|
36
|
+
#
|
|
37
|
+
# @param span [#attributes] Span or span data to inspect
|
|
38
|
+
# @return [Boolean]
|
|
39
|
+
def genai_span?(span)
|
|
40
|
+
attributes = span.attributes
|
|
41
|
+
return false unless attributes
|
|
42
|
+
|
|
43
|
+
attributes.keys.any? { |key| key.is_a?(String) && key.start_with?("gen_ai.") }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Return whether the span came from a known LLM instrumentation scope.
|
|
47
|
+
#
|
|
48
|
+
# @param span [#instrumentation_scope] Span or span data to inspect
|
|
49
|
+
# @return [Boolean]
|
|
50
|
+
def known_llm_instrumentor?(span)
|
|
51
|
+
scope_name = instrumentation_scope_name(span)
|
|
52
|
+
return false unless scope_name
|
|
53
|
+
|
|
54
|
+
return true if KNOWN_LLM_INSTRUMENTATION_SCOPE_PREFIXES.include?(scope_name)
|
|
55
|
+
|
|
56
|
+
KNOWN_LLM_INSTRUMENTATION_SCOPE_DOTTED_PREFIXES.any? do |dotted_prefix|
|
|
57
|
+
scope_name.start_with?(dotted_prefix)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Return whether a span should be exported when no custom filter is configured.
|
|
62
|
+
#
|
|
63
|
+
# @param span [#instrumentation_scope, #attributes] Span or span data to inspect
|
|
64
|
+
# @return [Boolean]
|
|
65
|
+
def default_export_span?(span)
|
|
66
|
+
langfuse_span?(span) || genai_span?(span) || known_llm_instrumentor?(span)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Cross-SDK parity keeps the `is_*` names public for compatibility.
|
|
70
|
+
alias is_langfuse_span langfuse_span?
|
|
71
|
+
alias is_genai_span genai_span?
|
|
72
|
+
alias is_known_llm_instrumentor known_llm_instrumentor?
|
|
73
|
+
alias is_default_export_span default_export_span?
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def instrumentation_scope_name(span)
|
|
78
|
+
span.instrumentation_scope&.name
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -3,22 +3,26 @@
|
|
|
3
3
|
require "opentelemetry/sdk"
|
|
4
4
|
|
|
5
5
|
module Langfuse
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# On span start, this processor first applies configured trace defaults
|
|
9
|
-
# (environment/release), then overlays attributes propagated in OpenTelemetry
|
|
10
|
-
# context (user/session/metadata/tags/version). This ensures consistent
|
|
11
|
-
# trace dimensions while still honoring per-request propagation.
|
|
6
|
+
# Batch span processor that owns Langfuse's enrichment and export filtering.
|
|
12
7
|
#
|
|
13
8
|
# @api private
|
|
14
|
-
class SpanProcessor < OpenTelemetry::SDK::Trace::
|
|
15
|
-
# @param config [Langfuse::Config
|
|
16
|
-
|
|
9
|
+
class SpanProcessor < OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor
|
|
10
|
+
# @param config [Langfuse::Config] SDK configuration used for defaults and filtering
|
|
11
|
+
# @param exporter [#export, #force_flush, #shutdown] Span exporter used by the batch processor
|
|
12
|
+
def initialize(config:, exporter:)
|
|
13
|
+
@logger = config.logger
|
|
17
14
|
@default_trace_attributes = build_default_trace_attributes(config).freeze
|
|
18
|
-
|
|
15
|
+
@should_export_span = config.should_export_span || Langfuse.method(:default_export_span?)
|
|
16
|
+
|
|
17
|
+
super(
|
|
18
|
+
exporter,
|
|
19
|
+
max_queue_size: config.batch_size * 2,
|
|
20
|
+
schedule_delay: schedule_delay_for(config),
|
|
21
|
+
max_export_batch_size: config.batch_size
|
|
22
|
+
)
|
|
19
23
|
end
|
|
20
24
|
|
|
21
|
-
#
|
|
25
|
+
# Apply Langfuse trace defaults and propagated attributes before a span records work.
|
|
22
26
|
#
|
|
23
27
|
# @param span [OpenTelemetry::SDK::Trace::Span] The span that started
|
|
24
28
|
# @param parent_context [OpenTelemetry::Context] The parent context
|
|
@@ -30,41 +34,28 @@ module Langfuse
|
|
|
30
34
|
apply_attributes(span, propagated_attributes(parent_context))
|
|
31
35
|
end
|
|
32
36
|
|
|
33
|
-
#
|
|
37
|
+
# Drop spans when the export filter rejects them or raises.
|
|
34
38
|
#
|
|
35
39
|
# @param span [OpenTelemetry::SDK::Trace::Span] The span that ended
|
|
36
40
|
# @return [void]
|
|
37
41
|
def on_finish(span)
|
|
38
|
-
|
|
39
|
-
end
|
|
42
|
+
return unless should_export_span?(span)
|
|
40
43
|
|
|
41
|
-
|
|
42
|
-
#
|
|
43
|
-
# @param timeout [Integer, nil] Timeout in seconds (unused for this processor)
|
|
44
|
-
# @return [Integer] Always returns 0 (no timeout needed for no-op)
|
|
45
|
-
def shutdown(timeout: nil)
|
|
46
|
-
# No-op - nothing to clean up
|
|
47
|
-
# Return 0 to match OpenTelemetry SDK expectation (it finds max timeout from processors)
|
|
48
|
-
_ = timeout # Suppress unused argument warning
|
|
49
|
-
0
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
# Force flush (no-op for this processor)
|
|
53
|
-
#
|
|
54
|
-
# @param timeout [Integer, nil] Timeout in seconds (unused for this processor)
|
|
55
|
-
# @return [Integer] Always returns 0 (no timeout needed for no-op)
|
|
56
|
-
def force_flush(timeout: nil)
|
|
57
|
-
# No-op - nothing to flush
|
|
58
|
-
# Return 0 to match OpenTelemetry SDK expectation (it finds max timeout from processors)
|
|
59
|
-
_ = timeout # Suppress unused argument warning
|
|
60
|
-
0
|
|
44
|
+
super
|
|
61
45
|
end
|
|
62
46
|
|
|
63
47
|
private
|
|
64
48
|
|
|
65
|
-
|
|
66
|
-
|
|
49
|
+
# Sync mode relies on explicit `force_flush` calls, so keep the background flush
|
|
50
|
+
# interval long enough that it rarely fires on its own.
|
|
51
|
+
SYNC_SCHEDULE_DELAY_MS = 60_000
|
|
52
|
+
private_constant :SYNC_SCHEDULE_DELAY_MS
|
|
53
|
+
|
|
54
|
+
def schedule_delay_for(config)
|
|
55
|
+
config.tracing_async ? config.flush_interval * 1000 : SYNC_SCHEDULE_DELAY_MS
|
|
56
|
+
end
|
|
67
57
|
|
|
58
|
+
def build_default_trace_attributes(config)
|
|
68
59
|
OtelAttributes.create_trace_attributes(
|
|
69
60
|
{ environment: config.environment, release: config.release }
|
|
70
61
|
)
|
|
@@ -79,5 +70,15 @@ module Langfuse
|
|
|
79
70
|
def apply_attributes(span, attributes)
|
|
80
71
|
attributes.each { |key, value| span.set_attribute(key, value) }
|
|
81
72
|
end
|
|
73
|
+
|
|
74
|
+
def should_export_span?(span)
|
|
75
|
+
@should_export_span.call(span)
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
@logger.error(
|
|
78
|
+
"Langfuse tracing dropped span '#{span.name}' because should_export_span raised: " \
|
|
79
|
+
"#{e.class}: #{e.message}"
|
|
80
|
+
)
|
|
81
|
+
false
|
|
82
|
+
end
|
|
82
83
|
end
|
|
83
84
|
end
|
data/lib/langfuse/version.rb
CHANGED
data/lib/langfuse.rb
CHANGED
|
@@ -44,6 +44,8 @@ require_relative "langfuse/prompt_cache"
|
|
|
44
44
|
require_relative "langfuse/rails_cache_adapter"
|
|
45
45
|
require_relative "langfuse/cache_warmer"
|
|
46
46
|
require_relative "langfuse/api_client"
|
|
47
|
+
require_relative "langfuse/span_filter"
|
|
48
|
+
require_relative "langfuse/sampling"
|
|
47
49
|
require_relative "langfuse/otel_setup"
|
|
48
50
|
require_relative "langfuse/masking"
|
|
49
51
|
require_relative "langfuse/otel_attributes"
|
|
@@ -91,10 +93,6 @@ module Langfuse
|
|
|
91
93
|
# end
|
|
92
94
|
def configure
|
|
93
95
|
yield(configuration)
|
|
94
|
-
|
|
95
|
-
# Auto-initialize OpenTelemetry
|
|
96
|
-
OtelSetup.setup(configuration)
|
|
97
|
-
|
|
98
96
|
configuration
|
|
99
97
|
end
|
|
100
98
|
|
|
@@ -105,6 +103,28 @@ module Langfuse
|
|
|
105
103
|
@client ||= Client.new(configuration)
|
|
106
104
|
end
|
|
107
105
|
|
|
106
|
+
# Return Langfuse's internal tracer provider for explicit global OpenTelemetry installation.
|
|
107
|
+
#
|
|
108
|
+
# @return [OpenTelemetry::SDK::Trace::TracerProvider]
|
|
109
|
+
# @raise [ConfigurationError] if tracing is not fully configured
|
|
110
|
+
#
|
|
111
|
+
# @example
|
|
112
|
+
# Langfuse.configure do |config|
|
|
113
|
+
# config.public_key = ENV["LANGFUSE_PUBLIC_KEY"]
|
|
114
|
+
# config.secret_key = ENV["LANGFUSE_SECRET_KEY"]
|
|
115
|
+
# end
|
|
116
|
+
#
|
|
117
|
+
# OpenTelemetry.tracer_provider = Langfuse.tracer_provider
|
|
118
|
+
def tracer_provider
|
|
119
|
+
unless tracing_config_ready?
|
|
120
|
+
raise ConfigurationError,
|
|
121
|
+
"Langfuse tracing is disabled until public_key, secret_key, and base_url are configured."
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
OtelSetup.setup(configuration) unless OtelSetup.initialized?
|
|
125
|
+
OtelSetup.tracer_provider
|
|
126
|
+
end
|
|
127
|
+
|
|
108
128
|
# Shutdown Langfuse and flush any pending traces and scores
|
|
109
129
|
#
|
|
110
130
|
# Call this when shutting down your application to ensure
|
|
@@ -323,10 +343,14 @@ module Langfuse
|
|
|
323
343
|
OtelSetup.shutdown(timeout: 5) if OtelSetup.initialized?
|
|
324
344
|
@configuration = nil
|
|
325
345
|
@client = nil
|
|
346
|
+
@noop_tracer = nil
|
|
347
|
+
@tracing_disabled_warning_emitted = false
|
|
326
348
|
rescue StandardError
|
|
327
349
|
# Ignore shutdown errors during reset (e.g., in tests)
|
|
328
350
|
@configuration = nil
|
|
329
351
|
@client = nil
|
|
352
|
+
@noop_tracer = nil
|
|
353
|
+
@tracing_disabled_warning_emitted = false
|
|
330
354
|
end
|
|
331
355
|
|
|
332
356
|
# Creates a new observation (root or child)
|
|
@@ -478,7 +502,10 @@ module Langfuse
|
|
|
478
502
|
#
|
|
479
503
|
# @return [OpenTelemetry::SDK::Trace::Tracer] The OTel tracer
|
|
480
504
|
def otel_tracer
|
|
481
|
-
|
|
505
|
+
return tracer_provider.tracer(LANGFUSE_TRACER_NAME, Langfuse::VERSION) if setup_tracing_if_ready
|
|
506
|
+
|
|
507
|
+
warn_tracing_disabled_once
|
|
508
|
+
noop_tracer
|
|
482
509
|
end
|
|
483
510
|
|
|
484
511
|
# Creates an OpenTelemetry span (root or child)
|
|
@@ -514,6 +541,47 @@ module Langfuse
|
|
|
514
541
|
observation_class = OBSERVATION_TYPE_REGISTRY[type_str] || Span
|
|
515
542
|
observation_class.new(otel_span, otel_tracer, attributes: attributes)
|
|
516
543
|
end
|
|
544
|
+
|
|
545
|
+
# rubocop:disable Naming/PredicateMethod
|
|
546
|
+
def setup_tracing_if_ready
|
|
547
|
+
return true if OtelSetup.initialized?
|
|
548
|
+
return false unless tracing_config_ready?
|
|
549
|
+
|
|
550
|
+
OtelSetup.setup(configuration)
|
|
551
|
+
true
|
|
552
|
+
end
|
|
553
|
+
# rubocop:enable Naming/PredicateMethod
|
|
554
|
+
|
|
555
|
+
def tracing_config_ready?
|
|
556
|
+
configured?(configuration.public_key) &&
|
|
557
|
+
configured?(configuration.secret_key) &&
|
|
558
|
+
configured?(configuration.base_url)
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
def configured?(value)
|
|
562
|
+
!value.nil? && !value.empty?
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def warn_tracing_disabled_once
|
|
566
|
+
return if @tracing_disabled_warning_emitted
|
|
567
|
+
|
|
568
|
+
tracing_warning_mutex.synchronize do
|
|
569
|
+
return if @tracing_disabled_warning_emitted
|
|
570
|
+
|
|
571
|
+
configuration.logger.warn(
|
|
572
|
+
"Langfuse tracing is disabled until public_key, secret_key, and base_url are configured."
|
|
573
|
+
)
|
|
574
|
+
@tracing_disabled_warning_emitted = true
|
|
575
|
+
end
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
def tracing_warning_mutex
|
|
579
|
+
@tracing_warning_mutex ||= Mutex.new
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
def noop_tracer
|
|
583
|
+
@noop_tracer ||= OpenTelemetry::Trace::TracerProvider.new.tracer(LANGFUSE_TRACER_NAME, Langfuse::VERSION)
|
|
584
|
+
end
|
|
517
585
|
end
|
|
518
586
|
# rubocop:enable Metrics/ClassLength
|
|
519
587
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: langfuse-rb
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- SimplePractice
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-04-
|
|
11
|
+
date: 2026-04-24 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: faraday
|
|
@@ -179,7 +179,9 @@ files:
|
|
|
179
179
|
- lib/langfuse/prompt_cache.rb
|
|
180
180
|
- lib/langfuse/propagation.rb
|
|
181
181
|
- lib/langfuse/rails_cache_adapter.rb
|
|
182
|
+
- lib/langfuse/sampling.rb
|
|
182
183
|
- lib/langfuse/score_client.rb
|
|
184
|
+
- lib/langfuse/span_filter.rb
|
|
183
185
|
- lib/langfuse/span_processor.rb
|
|
184
186
|
- lib/langfuse/stale_while_revalidate.rb
|
|
185
187
|
- lib/langfuse/text_prompt_client.rb
|