langfuse-rb 0.10.1 → 0.12.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 +47 -1
- data/lib/langfuse/api_client.rb +75 -16
- data/lib/langfuse/app_root_tracking.rb +164 -0
- data/lib/langfuse/chat_prompt_client.rb +18 -1
- data/lib/langfuse/client.rb +114 -32
- data/lib/langfuse/config.rb +306 -63
- data/lib/langfuse/evaluation.rb +8 -4
- data/lib/langfuse/exit_hook.rb +77 -0
- data/lib/langfuse/fork_safety.rb +71 -0
- data/lib/langfuse/masking_exporter.rb +98 -0
- data/lib/langfuse/observations.rb +2 -1
- data/lib/langfuse/otel_attributes.rb +1 -0
- data/lib/langfuse/otel_setup.rb +32 -44
- data/lib/langfuse/otel_span_batch.rb +113 -0
- data/lib/langfuse/otel_span_masking.rb +89 -0
- data/lib/langfuse/otel_span_patch_applier.rb +97 -0
- data/lib/langfuse/pending_score_queue.rb +62 -0
- data/lib/langfuse/prompt_cache.rb +8 -0
- data/lib/langfuse/prompt_variables.rb +54 -0
- data/lib/langfuse/propagation.rb +101 -34
- data/lib/langfuse/rails_cache_adapter.rb +17 -1
- data/lib/langfuse/read_api.rb +242 -0
- data/lib/langfuse/resilient_metrics_reporter.rb +60 -0
- data/lib/langfuse/score_client.rb +201 -80
- data/lib/langfuse/score_value.rb +58 -0
- data/lib/langfuse/span_processor.rb +53 -4
- data/lib/langfuse/stale_while_revalidate.rb +3 -4
- data/lib/langfuse/text_prompt_client.rb +13 -1
- data/lib/langfuse/trace_export_guard.rb +47 -0
- data/lib/langfuse/traced_execution.rb +18 -12
- data/lib/langfuse/types.rb +15 -1
- data/lib/langfuse/version.rb +1 -1
- data/lib/langfuse.rb +156 -44
- metadata +50 -8
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "opentelemetry/sdk"
|
|
4
|
+
require_relative "otel_span_batch"
|
|
5
|
+
require_relative "otel_span_patch_applier"
|
|
6
|
+
|
|
7
|
+
module Langfuse
|
|
8
|
+
# Export-stage masking wrapper around the Langfuse OTLP exporter.
|
|
9
|
+
#
|
|
10
|
+
# Only the copy exported to Langfuse is transformed. Original span data and
|
|
11
|
+
# any other OpenTelemetry exporter remain unchanged.
|
|
12
|
+
#
|
|
13
|
+
# @api private
|
|
14
|
+
class MaskingExporter
|
|
15
|
+
SUCCESS = OpenTelemetry::SDK::Trace::Export::SUCCESS
|
|
16
|
+
private_constant :SUCCESS
|
|
17
|
+
|
|
18
|
+
# Distinguishes "the hook raised" from a hook that legitimately returned nil.
|
|
19
|
+
# A constant rather than a memoized ivar because BatchSpanProcessor#force_flush
|
|
20
|
+
# exports on the caller's thread while the background thread may also be exporting.
|
|
21
|
+
HOOK_FAILURE = Object.new.freeze
|
|
22
|
+
private_constant :HOOK_FAILURE
|
|
23
|
+
|
|
24
|
+
# @param delegate [#export, #force_flush, #shutdown] Langfuse OTLP exporter
|
|
25
|
+
# @param hook [#call] configured mask_otel_spans callable
|
|
26
|
+
# @param logger [Logger]
|
|
27
|
+
def initialize(delegate:, hook:, logger:)
|
|
28
|
+
@delegate = delegate
|
|
29
|
+
@hook = hook
|
|
30
|
+
@logger = logger
|
|
31
|
+
@patch_applier = OtelSpanPatchApplier.new(logger: logger)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Mask the batch and delegate export.
|
|
35
|
+
#
|
|
36
|
+
# @param span_data [Enumerable<OpenTelemetry::SDK::Trace::SpanData>]
|
|
37
|
+
# @param timeout [Numeric, nil]
|
|
38
|
+
# @return [Integer] OpenTelemetry export result code
|
|
39
|
+
def export(span_data, timeout: nil)
|
|
40
|
+
masked_spans = mask_batch(span_data.to_a)
|
|
41
|
+
return SUCCESS if masked_spans.nil? || masked_spans.empty?
|
|
42
|
+
|
|
43
|
+
@delegate.export(masked_spans, timeout: timeout)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [Object] delegate's force_flush result
|
|
47
|
+
def force_flush(timeout: nil)
|
|
48
|
+
@delegate.force_flush(timeout: timeout)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Object] delegate's shutdown result
|
|
52
|
+
def shutdown(timeout: nil)
|
|
53
|
+
@delegate.shutdown(timeout: timeout)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# Returns nil to drop the whole Langfuse batch, otherwise the spans to export.
|
|
59
|
+
def mask_batch(span_data)
|
|
60
|
+
batch = OtelSpanBatch.new(span_data: span_data, logger: @logger)
|
|
61
|
+
return [] if batch.empty?
|
|
62
|
+
|
|
63
|
+
# Build snapshots outside call_hook so a snapshot failure is never reported as a hook failure.
|
|
64
|
+
result = call_hook(batch.masking_params, batch)
|
|
65
|
+
return if result.equal?(HOOK_FAILURE)
|
|
66
|
+
return batch.spans if result.nil?
|
|
67
|
+
return unless valid_result?(result, batch)
|
|
68
|
+
|
|
69
|
+
batch.apply(result.span_patches, patch_applier: @patch_applier)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def call_hook(params, batch)
|
|
73
|
+
@hook.call(params: params)
|
|
74
|
+
rescue StandardError => e
|
|
75
|
+
# Hook exception messages can contain the sensitive values being masked.
|
|
76
|
+
@logger.error("Langfuse mask_otel_spans raised #{e.class}; #{dropping_batch(batch)}")
|
|
77
|
+
HOOK_FAILURE
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def valid_result?(result, batch)
|
|
81
|
+
unless result.is_a?(MaskOtelSpansResult) && result.span_patches.is_a?(Hash)
|
|
82
|
+
@logger.error("Langfuse mask_otel_spans returned an invalid result; #{dropping_batch(batch)}")
|
|
83
|
+
return false
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
return true if result.span_patches.each_key.all? { |identifier| batch.include_identifier?(identifier) }
|
|
87
|
+
|
|
88
|
+
@logger.error(
|
|
89
|
+
"Langfuse mask_otel_spans returned a patch for an unknown span identifier; #{dropping_batch(batch)}"
|
|
90
|
+
)
|
|
91
|
+
false
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def dropping_batch(batch)
|
|
95
|
+
"dropping the Langfuse export batch of #{batch.size} spans"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -201,7 +201,7 @@ module Langfuse
|
|
|
201
201
|
# @param value [Numeric, String, Boolean] score value
|
|
202
202
|
# @param comment [String, nil] optional comment
|
|
203
203
|
# @param metadata [Hash, nil] optional metadata
|
|
204
|
-
# @param data_type [Symbol] one of :numeric, :boolean, :categorical
|
|
204
|
+
# @param data_type [Symbol] one of :numeric, :boolean, :categorical, :text, :correction
|
|
205
205
|
# @return [Hash] created score data from the API
|
|
206
206
|
def score_trace(name:, value:, comment: nil, metadata: nil, data_type: :numeric)
|
|
207
207
|
Langfuse.create_score(
|
|
@@ -254,6 +254,7 @@ module Langfuse
|
|
|
254
254
|
def run_in_context
|
|
255
255
|
parent_ctx = OpenTelemetry::Context.current
|
|
256
256
|
span_ctx = OpenTelemetry::Trace.context_with_span(@otel_span, parent_context: parent_ctx)
|
|
257
|
+
span_ctx = Propagation._set_langfuse_trace_id_in_baggage(trace_id, context: span_ctx)
|
|
257
258
|
OpenTelemetry::Context.with_current(span_ctx) { yield self }
|
|
258
259
|
ensure
|
|
259
260
|
safe_end
|
|
@@ -53,6 +53,7 @@ module Langfuse
|
|
|
53
53
|
OBSERVATION_PROMPT_NAME = "langfuse.observation.prompt.name"
|
|
54
54
|
OBSERVATION_PROMPT_VERSION = "langfuse.observation.prompt.version"
|
|
55
55
|
OBSERVATION_COMPLETION_START_TIME = "langfuse.observation.completion_start_time"
|
|
56
|
+
IS_APP_ROOT = "langfuse.internal.is_app_root"
|
|
56
57
|
|
|
57
58
|
# Common attributes
|
|
58
59
|
VERSION = "langfuse.version"
|
data/lib/langfuse/otel_setup.rb
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require "opentelemetry/sdk"
|
|
4
4
|
require "opentelemetry/exporter/otlp"
|
|
5
5
|
require "base64"
|
|
6
|
+
require_relative "masking_exporter"
|
|
7
|
+
require_relative "trace_export_guard"
|
|
6
8
|
|
|
7
9
|
module Langfuse
|
|
8
10
|
# OpenTelemetry initialization and setup for Langfuse tracing.
|
|
@@ -16,6 +18,9 @@ module Langfuse
|
|
|
16
18
|
release
|
|
17
19
|
sample_rate
|
|
18
20
|
should_export_span
|
|
21
|
+
mask_otel_spans
|
|
22
|
+
metrics_reporter
|
|
23
|
+
span_exporter
|
|
19
24
|
tracing_async
|
|
20
25
|
batch_size
|
|
21
26
|
flush_interval
|
|
@@ -31,20 +36,10 @@ module Langfuse
|
|
|
31
36
|
# @param config [Langfuse::Config] The Langfuse configuration
|
|
32
37
|
# @return [OpenTelemetry::SDK::Trace::TracerProvider]
|
|
33
38
|
def setup(config)
|
|
34
|
-
|
|
35
|
-
|
|
39
|
+
config.validate_tracing!
|
|
40
|
+
provider, created = setup_mutex.synchronize { setup_locked(config) }
|
|
36
41
|
|
|
37
|
-
|
|
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)
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
log_initialized(config)
|
|
42
|
+
log_initialized(config) if created
|
|
48
43
|
provider
|
|
49
44
|
rescue StandardError
|
|
50
45
|
rollback_provider(provider) if created
|
|
@@ -95,23 +90,13 @@ module Langfuse
|
|
|
95
90
|
@tracer_provider
|
|
96
91
|
end
|
|
97
92
|
|
|
98
|
-
def
|
|
99
|
-
|
|
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
|
|
93
|
+
def setup_locked(config)
|
|
94
|
+
return [existing_provider_for(config), false] if @tracer_provider
|
|
113
95
|
|
|
114
|
-
|
|
96
|
+
provider = build_tracer_provider(config)
|
|
97
|
+
@tracer_provider = provider
|
|
98
|
+
@config_snapshot = tracing_config_snapshot(config)
|
|
99
|
+
[provider, true]
|
|
115
100
|
end
|
|
116
101
|
|
|
117
102
|
def rollback_provider(provider)
|
|
@@ -131,12 +116,22 @@ module Langfuse
|
|
|
131
116
|
sampler: build_sampler(config.sample_rate)
|
|
132
117
|
)
|
|
133
118
|
provider.add_span_processor(
|
|
134
|
-
SpanProcessor.new(
|
|
119
|
+
SpanProcessor.new(
|
|
120
|
+
config: config,
|
|
121
|
+
exporter: TraceExportGuard.new(delegate: build_exporter(config), config: config)
|
|
122
|
+
)
|
|
135
123
|
)
|
|
136
124
|
provider
|
|
137
125
|
end
|
|
138
126
|
|
|
139
127
|
def build_exporter(config)
|
|
128
|
+
exporter = config.span_exporter || build_otlp_exporter(config)
|
|
129
|
+
return exporter unless config.mask_otel_spans
|
|
130
|
+
|
|
131
|
+
MaskingExporter.new(delegate: exporter, hook: config.mask_otel_spans, logger: config.logger)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def build_otlp_exporter(config)
|
|
140
135
|
OpenTelemetry::Exporter::OTLP::Exporter.new(
|
|
141
136
|
endpoint: "#{config.base_url}/api/public/otel/v1/traces",
|
|
142
137
|
headers: build_headers(config.public_key, config.secret_key),
|
|
@@ -149,15 +144,6 @@ module Langfuse
|
|
|
149
144
|
config.logger.info("Langfuse tracing initialized with OpenTelemetry (#{mode} mode)")
|
|
150
145
|
end
|
|
151
146
|
|
|
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
147
|
def tracing_config_snapshot(config)
|
|
162
148
|
TRACING_CONFIG_FIELDS.to_h { |field| [field, config.public_send(field)] }.freeze
|
|
163
149
|
end
|
|
@@ -166,14 +152,16 @@ module Langfuse
|
|
|
166
152
|
@setup_mutex ||= Mutex.new
|
|
167
153
|
end
|
|
168
154
|
|
|
169
|
-
def blank?(value)
|
|
170
|
-
value.nil? || value.empty?
|
|
171
|
-
end
|
|
172
|
-
|
|
173
155
|
def build_headers(public_key, secret_key)
|
|
174
156
|
credentials = "#{public_key}:#{secret_key}"
|
|
175
157
|
encoded = Base64.strict_encode64(credentials)
|
|
176
|
-
{
|
|
158
|
+
{
|
|
159
|
+
"Authorization" => "Basic #{encoded}",
|
|
160
|
+
"x-langfuse-ingestion-version" => "4",
|
|
161
|
+
"x-langfuse-sdk-name" => "ruby",
|
|
162
|
+
"x-langfuse-sdk-version" => Langfuse::VERSION,
|
|
163
|
+
"x-langfuse-public-key" => public_key
|
|
164
|
+
}
|
|
177
165
|
end
|
|
178
166
|
|
|
179
167
|
def build_sampler(sample_rate)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "opentelemetry/sdk"
|
|
4
|
+
require_relative "otel_span_masking"
|
|
5
|
+
|
|
6
|
+
module Langfuse
|
|
7
|
+
# Builds immutable hook input while retaining original spans for export.
|
|
8
|
+
# @api private
|
|
9
|
+
class OtelSpanBatch
|
|
10
|
+
attr_reader :spans
|
|
11
|
+
|
|
12
|
+
def initialize(span_data:, logger:)
|
|
13
|
+
@logger = logger
|
|
14
|
+
@spans_by_identifier = index_spans(span_data)
|
|
15
|
+
@spans = @spans_by_identifier.values.freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def empty?
|
|
19
|
+
@spans_by_identifier.empty?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def size
|
|
23
|
+
@spans_by_identifier.size
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def include_identifier?(identifier)
|
|
27
|
+
@spans_by_identifier.key?(identifier)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def masking_params
|
|
31
|
+
MaskOtelSpansParams.new(spans: snapshots)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def apply(patches, patch_applier:)
|
|
35
|
+
@spans_by_identifier.filter_map do |identifier, span|
|
|
36
|
+
patch = patches[identifier]
|
|
37
|
+
patch.nil? ? span : patch_applier.apply(span, patch)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def index_spans(span_data)
|
|
44
|
+
span_data.each_with_object({}) do |span, indexed_spans|
|
|
45
|
+
unless valid_span_context?(span)
|
|
46
|
+
# No usable IDs here, so the span name is the only way to identify the drop.
|
|
47
|
+
@logger.warn("Langfuse mask_otel_spans dropped a span with an invalid span context (name=#{span.name})")
|
|
48
|
+
next
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
identifier = span_identifier(span)
|
|
52
|
+
# Delete before re-inserting so a duplicated identifier takes the position of
|
|
53
|
+
# its last occurrence rather than its first, matching the Python SDK.
|
|
54
|
+
indexed_spans.delete(identifier)
|
|
55
|
+
indexed_spans[identifier] = span
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def valid_span_context?(span)
|
|
60
|
+
span.trace_id.is_a?(String) && span.trace_id.bytesize == 16 &&
|
|
61
|
+
span.span_id.is_a?(String) && span.span_id.bytesize == 8 &&
|
|
62
|
+
span.trace_id != OpenTelemetry::Trace::INVALID_TRACE_ID &&
|
|
63
|
+
span.span_id != OpenTelemetry::Trace::INVALID_SPAN_ID
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def span_identifier(span)
|
|
67
|
+
OtelSpanIdentifier.new(trace_id: span.hex_trace_id, span_id: span.hex_span_id)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def snapshots
|
|
71
|
+
@snapshots ||= @spans_by_identifier.to_h do |identifier, span|
|
|
72
|
+
[identifier, snapshot_span(identifier, span)]
|
|
73
|
+
end.freeze
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def snapshot_span(identifier, span)
|
|
77
|
+
scope = span.instrumentation_scope
|
|
78
|
+
OtelSpanData.new(
|
|
79
|
+
trace_id: identifier.trace_id,
|
|
80
|
+
span_id: identifier.span_id,
|
|
81
|
+
parent_span_id: parent_span_id(span),
|
|
82
|
+
name: frozen_copy(span.name),
|
|
83
|
+
instrumentation_scope_name: frozen_copy(scope&.name),
|
|
84
|
+
instrumentation_scope_version: frozen_copy(scope&.version),
|
|
85
|
+
attributes: frozen_attributes(span.attributes),
|
|
86
|
+
resource_attributes: frozen_attributes(span.resource&.attribute_enumerator&.to_h)
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def parent_span_id(span)
|
|
91
|
+
return if span.parent_span_id.nil? || span.parent_span_id == OpenTelemetry::Trace::INVALID_SPAN_ID
|
|
92
|
+
|
|
93
|
+
span.hex_parent_span_id.freeze
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def frozen_attributes(attributes)
|
|
97
|
+
(attributes || {}).to_h do |key, value|
|
|
98
|
+
[frozen_copy(key), frozen_copy(value)]
|
|
99
|
+
end.freeze
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Attribute payloads can be large serialized prompts, so skip copying anything
|
|
103
|
+
# already immutable rather than duplicating it on every export.
|
|
104
|
+
def frozen_copy(value)
|
|
105
|
+
case value
|
|
106
|
+
when String then value.frozen? ? value : value.dup.freeze
|
|
107
|
+
when Array then value.map { |element| frozen_copy(element) }.freeze
|
|
108
|
+
else value
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
private_constant :OtelSpanBatch
|
|
113
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Langfuse
|
|
4
|
+
# Stable key for one OpenTelemetry span in a masking batch.
|
|
5
|
+
#
|
|
6
|
+
# Reuse identifiers from {MaskOtelSpansParams#spans} when returning patches.
|
|
7
|
+
#
|
|
8
|
+
# @!attribute [r] trace_id
|
|
9
|
+
# @return [String] 32-character lowercase hexadecimal trace ID
|
|
10
|
+
# @!attribute [r] span_id
|
|
11
|
+
# @return [String] 16-character lowercase hexadecimal span ID
|
|
12
|
+
OtelSpanIdentifier = Data.define(:trace_id, :span_id) do
|
|
13
|
+
# @param trace_id [String] 32-character lowercase hexadecimal trace ID
|
|
14
|
+
# @param span_id [String] 16-character lowercase hexadecimal span ID
|
|
15
|
+
# @return [OtelSpanIdentifier]
|
|
16
|
+
# @raise [ArgumentError] if an unknown keyword is given
|
|
17
|
+
def initialize(trace_id:, span_id:)
|
|
18
|
+
super(trace_id: trace_id.dup.freeze, span_id: span_id.dup.freeze)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Read-only OpenTelemetry span snapshot passed to +mask_otel_spans+.
|
|
23
|
+
#
|
|
24
|
+
# The hook can inspect every field but can only patch +attributes+.
|
|
25
|
+
#
|
|
26
|
+
# @!attribute [r] trace_id
|
|
27
|
+
# @return [String] 32-character lowercase hexadecimal trace ID
|
|
28
|
+
# @!attribute [r] span_id
|
|
29
|
+
# @return [String] 16-character lowercase hexadecimal span ID
|
|
30
|
+
# @!attribute [r] parent_span_id
|
|
31
|
+
# @return [String, nil] 16-character lowercase hexadecimal parent span ID
|
|
32
|
+
# @!attribute [r] name
|
|
33
|
+
# @return [String] span name
|
|
34
|
+
# @!attribute [r] instrumentation_scope_name
|
|
35
|
+
# @return [String, nil] instrumentation scope name
|
|
36
|
+
# @!attribute [r] instrumentation_scope_version
|
|
37
|
+
# @return [String, nil] instrumentation scope version
|
|
38
|
+
# @!attribute [r] attributes
|
|
39
|
+
# @return [Hash] frozen span attributes
|
|
40
|
+
# @!attribute [r] resource_attributes
|
|
41
|
+
# @return [Hash] frozen resource attributes
|
|
42
|
+
OtelSpanData = Data.define(
|
|
43
|
+
:trace_id,
|
|
44
|
+
:span_id,
|
|
45
|
+
:parent_span_id,
|
|
46
|
+
:name,
|
|
47
|
+
:instrumentation_scope_name,
|
|
48
|
+
:instrumentation_scope_version,
|
|
49
|
+
:attributes,
|
|
50
|
+
:resource_attributes
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Input passed to the export-stage masking hook.
|
|
54
|
+
#
|
|
55
|
+
# @!attribute [r] spans
|
|
56
|
+
# @return [Hash{OtelSpanIdentifier => OtelSpanData}] frozen batch snapshot
|
|
57
|
+
MaskOtelSpansParams = Data.define(:spans)
|
|
58
|
+
|
|
59
|
+
# Attribute changes for one exported OpenTelemetry span.
|
|
60
|
+
#
|
|
61
|
+
# Deletes run before sets, so a set wins when the same key is in both fields.
|
|
62
|
+
#
|
|
63
|
+
# @!attribute [r] set_attributes
|
|
64
|
+
# @return [Hash] attributes to add or replace
|
|
65
|
+
# @!attribute [r] delete_attributes
|
|
66
|
+
# @return [Array<String>] attribute keys to remove
|
|
67
|
+
OtelSpanPatch = Data.define(:set_attributes, :delete_attributes) do
|
|
68
|
+
# @param set_attributes [Object] attributes to add or replace
|
|
69
|
+
# @param delete_attributes [Object] attribute keys to remove
|
|
70
|
+
# @return [OtelSpanPatch]
|
|
71
|
+
# @raise [ArgumentError] if an unknown keyword is given
|
|
72
|
+
def initialize(set_attributes: {}, delete_attributes: [])
|
|
73
|
+
super
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Sparse patches returned by an export-stage masking hook.
|
|
78
|
+
#
|
|
79
|
+
# @!attribute [r] span_patches
|
|
80
|
+
# @return [Hash{OtelSpanIdentifier => OtelSpanPatch, nil}]
|
|
81
|
+
MaskOtelSpansResult = Data.define(:span_patches) do
|
|
82
|
+
# @param span_patches [Object] sparse patches keyed by batch identifiers
|
|
83
|
+
# @return [MaskOtelSpansResult]
|
|
84
|
+
# @raise [ArgumentError] if an unknown keyword is given
|
|
85
|
+
def initialize(span_patches: {})
|
|
86
|
+
super
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "opentelemetry/sdk"
|
|
4
|
+
require_relative "otel_span_masking"
|
|
5
|
+
|
|
6
|
+
module Langfuse
|
|
7
|
+
# Validates and applies one sparse patch to a cloned OpenTelemetry span.
|
|
8
|
+
# @api private
|
|
9
|
+
class OtelSpanPatchApplier
|
|
10
|
+
def initialize(logger:)
|
|
11
|
+
@logger = logger
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def apply(span, patch)
|
|
15
|
+
unless valid_patch_containers?(patch)
|
|
16
|
+
@logger.error(
|
|
17
|
+
"Langfuse mask_otel_spans produced an invalid span patch; dropping the span " \
|
|
18
|
+
"from the Langfuse export (#{span_identity(span)})"
|
|
19
|
+
)
|
|
20
|
+
return nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
clone_with_attributes(span, patched_attributes(span, patch))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def valid_patch_containers?(patch)
|
|
29
|
+
patch.is_a?(OtelSpanPatch) &&
|
|
30
|
+
patch.delete_attributes.is_a?(Array) &&
|
|
31
|
+
patch.set_attributes.is_a?(Hash)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def patched_attributes(span, patch)
|
|
35
|
+
attributes = (span.attributes || {}).dup
|
|
36
|
+
patch.delete_attributes.each do |key|
|
|
37
|
+
next warn_invalid_key("delete", span) unless valid_attribute_key?(key)
|
|
38
|
+
|
|
39
|
+
attributes.delete(key)
|
|
40
|
+
end
|
|
41
|
+
patch.set_attributes.each do |key, value|
|
|
42
|
+
next warn_invalid_key("set", span) unless valid_attribute_key?(key)
|
|
43
|
+
|
|
44
|
+
apply_replacement(attributes, key, value, span)
|
|
45
|
+
end
|
|
46
|
+
attributes.freeze
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def warn_invalid_key(operation, span)
|
|
50
|
+
@logger.warn(
|
|
51
|
+
"Langfuse mask_otel_spans ignored an invalid #{operation} attribute key (#{span_identity(span)})"
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def apply_replacement(attributes, key, value, span)
|
|
56
|
+
if OpenTelemetry::SDK::Internal.valid_value?(value)
|
|
57
|
+
attributes[key] = frozen_copy(value)
|
|
58
|
+
else
|
|
59
|
+
attributes.delete(key)
|
|
60
|
+
@logger.warn(
|
|
61
|
+
"Langfuse mask_otel_spans replacement for attribute '#{key}' is not a valid " \
|
|
62
|
+
"OpenTelemetry attribute value (#{value.class}); omitting the attribute (#{span_identity(span)})"
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Trace and span IDs are opaque hex, unlike the attribute values being masked.
|
|
68
|
+
def span_identity(span)
|
|
69
|
+
"trace_id=#{span.hex_trace_id} span_id=#{span.hex_span_id}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def valid_attribute_key?(key)
|
|
73
|
+
OpenTelemetry::SDK::Internal.valid_key?(key) && !key.empty?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Replacement payloads can be large, so skip copying anything already immutable.
|
|
77
|
+
def frozen_copy(value)
|
|
78
|
+
case value
|
|
79
|
+
when String then value.frozen? ? value : value.dup.freeze
|
|
80
|
+
when Array then value.map { |element| frozen_copy(element) }.freeze
|
|
81
|
+
else value
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def clone_with_attributes(span, attributes)
|
|
86
|
+
copy = span.dup
|
|
87
|
+
copy.attributes = attributes
|
|
88
|
+
copy.total_recorded_attributes = attributes.size + dropped_attribute_count(span)
|
|
89
|
+
copy
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def dropped_attribute_count(span)
|
|
93
|
+
[span.total_recorded_attributes.to_i - (span.attributes || {}).size, 0].max
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
private_constant :OtelSpanPatchApplier
|
|
97
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Langfuse
|
|
4
|
+
# Thread-safe bounded queue that keeps scores until delivery succeeds.
|
|
5
|
+
#
|
|
6
|
+
# @api private
|
|
7
|
+
class PendingScoreQueue
|
|
8
|
+
# @return [Integer] Maximum number of pending scores
|
|
9
|
+
attr_reader :capacity
|
|
10
|
+
|
|
11
|
+
# @param capacity [Integer] Maximum number of pending scores
|
|
12
|
+
def initialize(capacity:)
|
|
13
|
+
@capacity = capacity
|
|
14
|
+
@events = []
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Add an event without waiting for queue capacity.
|
|
19
|
+
#
|
|
20
|
+
# @param event [Hash] Score ingestion event
|
|
21
|
+
# @return [Boolean] true when accepted, false when full
|
|
22
|
+
def push(event)
|
|
23
|
+
@mutex.synchronize do
|
|
24
|
+
return false if @events.length >= capacity
|
|
25
|
+
|
|
26
|
+
@events << event
|
|
27
|
+
true
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [Array<Hash>] Stable copy of pending events in insertion order
|
|
32
|
+
def snapshot
|
|
33
|
+
@mutex.synchronize { @events.dup }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Return up to limit events from the front without removing them.
|
|
37
|
+
#
|
|
38
|
+
# @param limit [Integer] Maximum number of events to return
|
|
39
|
+
# @return [Array<Hash>] Stable copy of the pending prefix
|
|
40
|
+
def first(limit)
|
|
41
|
+
@mutex.synchronize { @events.first(limit) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Remove a delivered prefix while preserving newer events.
|
|
45
|
+
#
|
|
46
|
+
# @param count [Integer] Number of delivered events
|
|
47
|
+
# @return [void]
|
|
48
|
+
def remove_prefix(count)
|
|
49
|
+
@mutex.synchronize { @events.shift(count) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Integer] Number of pending events
|
|
53
|
+
def size
|
|
54
|
+
@mutex.synchronize { @events.size }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [Boolean] true when no events are pending
|
|
58
|
+
def empty?
|
|
59
|
+
@mutex.synchronize { @events.empty? }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "monitor"
|
|
4
4
|
require "base64"
|
|
5
5
|
require_relative "stale_while_revalidate"
|
|
6
|
+
require_relative "fork_safety"
|
|
6
7
|
|
|
7
8
|
module Langfuse
|
|
8
9
|
# Simple in-memory cache for prompt data with TTL
|
|
@@ -92,6 +93,7 @@ module Langfuse
|
|
|
92
93
|
@monitor = Monitor.new
|
|
93
94
|
@locks = {} # Track locks for in-memory locking
|
|
94
95
|
initialize_swr(refresh_threads: refresh_threads) if swr_enabled?
|
|
96
|
+
ForkSafety.register(self)
|
|
95
97
|
end
|
|
96
98
|
|
|
97
99
|
# Get a value from the cache
|
|
@@ -284,6 +286,12 @@ module Langfuse
|
|
|
284
286
|
|
|
285
287
|
private
|
|
286
288
|
|
|
289
|
+
# Replace inherited synchronization and worker state in the child process.
|
|
290
|
+
def reset_after_fork
|
|
291
|
+
@monitor = Monitor.new
|
|
292
|
+
@locks = {}
|
|
293
|
+
end
|
|
294
|
+
|
|
287
295
|
# Implementation of StaleWhileRevalidate abstract methods
|
|
288
296
|
|
|
289
297
|
# Get value from cache (SWR interface)
|