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.
@@ -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"
@@ -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
- validate_tracing_config!(config)
35
- return existing_provider_for(config) if initialized?
39
+ config.validate_tracing!
40
+ provider, created = setup_mutex.synchronize { setup_locked(config) }
36
41
 
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)
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 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
93
+ def setup_locked(config)
94
+ return [existing_provider_for(config), false] if @tracer_provider
113
95
 
114
- [current, created]
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(config: config, exporter: build_exporter(config))
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
- { "Authorization" => "Basic #{encoded}" }
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)