-
Notifications
You must be signed in to change notification settings - Fork 6
Mask exported third party spans #96
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
kxzk
wants to merge
3
commits into
main
Choose a base branch
from
feature/aai-236-mask-exported-third-party-spans
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 1 commit
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,217 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| require "opentelemetry/sdk" | ||
|
|
||
| module Langfuse | ||
| # Immutable snapshot of one span in a Langfuse export batch. | ||
| # | ||
| # Passed to the {Config#mask_otel_spans} hook as the values of the +spans:+ | ||
| # mapping. Exposes only the fields needed to make a masking decision; the | ||
| # mutable OpenTelemetry SDK span objects are never handed to the hook. | ||
| # | ||
| # @!attribute [r] trace_id | ||
| # @return [String] 32-hex-character lowercase trace ID | ||
| # @!attribute [r] span_id | ||
| # @return [String] 16-hex-character lowercase span ID | ||
| # @!attribute [r] name | ||
| # @return [String] span name | ||
| # @!attribute [r] scope_name | ||
| # @return [String, nil] instrumentation scope name (e.g. "ruby-openai") | ||
| # @!attribute [r] attributes | ||
| # @return [Hash] frozen span attributes | ||
| # @!attribute [r] resource_attributes | ||
| # @return [Hash] frozen resource attributes | ||
| OtelSpanSnapshot = Data.define( | ||
| :trace_id, :span_id, :name, :scope_name, :attributes, :resource_attributes | ||
| ) | ||
|
|
||
| # Export-stage masking wrapper around the Langfuse OTLP exporter. | ||
| # | ||
| # Applies the {Config#mask_otel_spans} hook to every export batch after | ||
| # +should_export_span+ has selected spans and the batch processor has built | ||
| # the batch. Only the copy exported to Langfuse is transformed; original | ||
| # span data (and therefore any other OpenTelemetry exporter) is untouched. | ||
| # | ||
| # Fail-closed behavior: | ||
| # - A hook exception or an invalid top-level result drops the whole batch. | ||
| # - A malformed per-span patch drops that span from the Langfuse export. | ||
| # - An invalid replacement attribute value omits that attribute entirely | ||
| # rather than exporting its original value. | ||
| # | ||
| # @api private | ||
| class MaskingExporter | ||
| SUCCESS = OpenTelemetry::SDK::Trace::Export::SUCCESS | ||
| FAILURE = OpenTelemetry::SDK::Trace::Export::FAILURE | ||
| private_constant :SUCCESS, :FAILURE | ||
|
|
||
| # Allowed keys of one sparse span patch. | ||
| PATCH_KEYS = %i[delete set].freeze | ||
| private_constant :PATCH_KEYS | ||
|
|
||
| # @param delegate [#export, #force_flush, #shutdown] Langfuse OTLP exporter | ||
| # @param hook [#call] The configured mask_otel_spans callable | ||
| # @param logger [Logger] | ||
| def initialize(delegate:, hook:, logger:) | ||
| @delegate = delegate | ||
| @hook = hook | ||
| @logger = logger | ||
| end | ||
|
|
||
| # Mask the batch and delegate export. Drops the whole batch (fail closed) | ||
| # when the hook raises or returns an invalid result. | ||
| # | ||
| # @param span_data [Enumerable<OpenTelemetry::SDK::Trace::SpanData>] | ||
| # @param timeout [Numeric, nil] | ||
| # @return [Integer] OpenTelemetry export result code | ||
| def export(span_data, timeout: nil) | ||
| batch = span_data.to_a | ||
| masked = mask_batch(batch) | ||
| return FAILURE unless masked | ||
|
|
||
| @delegate.export(masked, timeout: timeout) | ||
| end | ||
|
|
||
| # @return [Object] delegate's force_flush result | ||
| def force_flush(timeout: nil) | ||
| @delegate.force_flush(timeout: timeout) | ||
| end | ||
|
|
||
| # @return [Object] delegate's shutdown result | ||
| def shutdown(timeout: nil) | ||
| @delegate.shutdown(timeout: timeout) | ||
| end | ||
|
|
||
| private | ||
|
|
||
| # @return [Array<SpanData>, nil] the masked batch, or nil to drop it | ||
| def mask_batch(batch) | ||
| patches = @hook.call(spans: snapshot_batch(batch)) | ||
| return batch if patches.nil? | ||
|
|
||
| unless patches.is_a?(Hash) | ||
| @logger.error( | ||
| "Langfuse mask_otel_spans returned #{patches.class} instead of nil or a Hash " \ | ||
| "of patches; dropping the Langfuse export batch" | ||
| ) | ||
| return nil | ||
| end | ||
|
|
||
| apply_patches(batch, patches) | ||
| rescue StandardError => e | ||
| @logger.error("Langfuse mask_otel_spans raised #{e.class}: #{e.message}; dropping the Langfuse export batch") | ||
| nil | ||
| end | ||
|
|
||
| def snapshot_batch(batch) | ||
| batch.to_h { |span| [span_identifier(span), snapshot(span)] }.freeze | ||
| end | ||
|
|
||
| # Stable identifier the hook uses to key sparse patches. | ||
| def span_identifier(span) | ||
| "#{span.hex_trace_id}:#{span.hex_span_id}" | ||
| end | ||
|
|
||
| def snapshot(span) | ||
| OtelSpanSnapshot.new( | ||
| trace_id: span.hex_trace_id, | ||
| span_id: span.hex_span_id, | ||
| name: span.name, | ||
| scope_name: span.instrumentation_scope&.name, | ||
| attributes: frozen_attributes(span.attributes), | ||
| resource_attributes: frozen_attributes(span.resource&.attribute_enumerator&.to_h) | ||
| ) | ||
| end | ||
|
|
||
| # Copies mutable values so freezing the snapshot never freezes objects | ||
| # still referenced by the original span data. | ||
| def frozen_attributes(attributes) | ||
| (attributes || {}).transform_values { |value| frozen_copy(value) }.freeze | ||
| end | ||
|
|
||
| def frozen_copy(value) | ||
| case value | ||
| when String, Array then value.dup.freeze | ||
| else value | ||
| end | ||
|
cursor[bot] marked this conversation as resolved.
Outdated
|
||
| end | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in cb77bfe — |
||
|
|
||
| # Unpatched spans are passed through untouched; patched spans are cloned | ||
| # so the originals stay pristine for any other exporter. | ||
| def apply_patches(batch, patches) | ||
| batch.filter_map do |span| | ||
| patch = patches[span_identifier(span)] | ||
| next span unless patch | ||
|
|
||
| apply_patch(span, patch) | ||
| end | ||
| end | ||
|
|
||
| # @return [SpanData, nil] the cloned masked span, or nil to drop it | ||
| def apply_patch(span, patch) | ||
| unless valid_patch?(patch) | ||
| @logger.error( | ||
| "Langfuse mask_otel_spans produced a malformed patch for span '#{span.name}'; " \ | ||
| "dropping the span from the Langfuse export" | ||
| ) | ||
| return nil | ||
| end | ||
|
|
||
| clone_with_attributes(span, patched_attributes(span, patch)) | ||
| end | ||
|
|
||
| def valid_patch?(patch) | ||
| patch.is_a?(Hash) && | ||
| (patch.keys - PATCH_KEYS).empty? && | ||
| (patch[:delete].nil? || patch[:delete].is_a?(Array)) && | ||
| (patch[:set].nil? || patch[:set].is_a?(Hash)) | ||
| end | ||
|
|
||
| # Deletes run before sets so a replacement wins when a key appears in both. | ||
| def patched_attributes(span, patch) | ||
| attributes = (span.attributes || {}).dup | ||
| Array(patch[:delete]).each { |key| attributes.delete(key.to_s) } | ||
| (patch[:set] || {}).each do |key, value| | ||
| apply_replacement(attributes, key.to_s, value) | ||
| end | ||
| attributes.freeze | ||
| end | ||
|
|
||
| # Invalid replacements delete the key: never export the original value of | ||
| # an attribute the hook intended to overwrite. The value itself is never | ||
| # logged. | ||
| def apply_replacement(attributes, key, value) | ||
| if valid_attribute_value?(value) | ||
| attributes[key] = value | ||
| else | ||
| attributes.delete(key) | ||
| @logger.warn( | ||
| "Langfuse mask_otel_spans replacement for attribute '#{key}' is not a valid " \ | ||
| "OpenTelemetry attribute value (#{value.class}); omitting the attribute" | ||
| ) | ||
| end | ||
| end | ||
|
|
||
| def valid_attribute_value?(value) | ||
| case value | ||
| when String, Integer, Float, true, false then true | ||
| when Array then homogeneous_scalar_array?(value) | ||
| else false | ||
| end | ||
| end | ||
|
|
||
| def homogeneous_scalar_array?(array) | ||
| return true if array.empty? | ||
| # Booleans mix TrueClass and FalseClass but form one OpenTelemetry type. | ||
| return array.all? { |element| [true, false].include?(element) } if [true, false].include?(array.first) | ||
|
|
||
| type = array.first.class | ||
| [String, Integer, Float].include?(type) && array.all? { |element| element.instance_of?(type) } | ||
| end | ||
|
|
||
| def clone_with_attributes(span, attributes) | ||
| copy = span.dup | ||
| copy.attributes = attributes | ||
| copy | ||
| end | ||
| end | ||
| end | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in cb77bfe — the rescue now logs only the exception class; a spec asserts the exception message never reaches the log.