55from typing import Sequence
66from unittest .mock import Mock
77
8+ import pytest
89from opentelemetry .sdk .resources import Resource
910from opentelemetry .sdk .trace import ReadableSpan , TracerProvider
1011from opentelemetry .sdk .trace .export import SpanExporter , SpanExportResult
@@ -54,8 +55,14 @@ def _tracer_provider(
5455 exporter : InMemorySpanExporter ,
5556 media_manager : MediaManager ,
5657 mask_otel_spans = None ,
58+ resource_attributes = None ,
59+ should_export_span = None ,
5760) -> TracerProvider :
58- provider = TracerProvider (resource = Resource .create ({"service.name" : "test" }))
61+ provider = TracerProvider (
62+ resource = Resource .create (
63+ {"service.name" : "test" , ** (resource_attributes or {})}
64+ )
65+ )
5966 provider .add_span_processor (
6067 LangfuseSpanProcessor (
6168 public_key = "test-public-key" ,
@@ -66,6 +73,7 @@ def _tracer_provider(
6673 span_exporter = exporter ,
6774 media_manager = media_manager ,
6875 mask_otel_spans = mask_otel_spans ,
76+ should_export_span = should_export_span ,
6977 )
7078 )
7179
@@ -208,6 +216,115 @@ def test_export_stage_media_processes_direct_data_uri_string():
208216 assert not media_queue .empty ()
209217
210218
219+ def test_export_stage_media_processes_string_sequence_attributes ():
220+ exporter = InMemorySpanExporter ()
221+ media_manager , media_queue = _media_manager ()
222+ image_base64 = base64 .b64encode (b"image-bytes" ).decode ("utf-8" )
223+
224+ provider = _tracer_provider (exporter = exporter , media_manager = media_manager )
225+ tracer = provider .get_tracer ("openinference.instrumentation.openai" )
226+
227+ inline_data_payload = json .dumps (
228+ {
229+ "inline_data" : {
230+ "mime_type" : "image/png" ,
231+ "data" : image_base64 ,
232+ }
233+ }
234+ )
235+
236+ with tracer .start_as_current_span ("third-party-sequence-media-span" ) as span :
237+ span .set_attribute (
238+ "gen_ai.prompt" ,
239+ [
240+ f"data:image/jpeg;base64,{ image_base64 } " ,
241+ inline_data_payload ,
242+ "plain text" ,
243+ ],
244+ )
245+
246+ provider .force_flush ()
247+
248+ exported_span = exporter .get_finished_spans ()[0 ]
249+ exported_sequence = exported_span .attributes ["gen_ai.prompt" ]
250+ exported_payload = json .loads (exported_sequence [1 ])
251+
252+ assert isinstance (exported_sequence , tuple )
253+ assert exported_sequence [0 ].startswith ("@@@langfuseMedia:" )
254+ assert exported_payload ["inline_data" ]["data" ].startswith ("@@@langfuseMedia:" )
255+ assert exported_sequence [2 ] == "plain text"
256+ assert media_queue .qsize () == 2
257+
258+
259+ def test_export_stage_media_fail_open_leaves_invalid_media_attribute_unchanged ():
260+ exporter = InMemorySpanExporter ()
261+ media_manager , media_queue = _media_manager ()
262+ invalid_data_uri = "data:image/jpeg;base64,"
263+
264+ provider = _tracer_provider (exporter = exporter , media_manager = media_manager )
265+ tracer = provider .get_tracer ("openinference.instrumentation.openai" )
266+
267+ with tracer .start_as_current_span ("third-party-invalid-media-span" ) as span :
268+ span .set_attribute ("gen_ai.prompt" , invalid_data_uri )
269+
270+ provider .force_flush ()
271+
272+ exported_span = exporter .get_finished_spans ()[0 ]
273+
274+ assert exported_span .attributes ["gen_ai.prompt" ] == invalid_data_uri
275+ assert media_queue .empty ()
276+
277+
278+ def test_mask_otel_spans_receives_whole_span_snapshot ():
279+ exporter = InMemorySpanExporter ()
280+ media_manager , _ = _media_manager ()
281+ seen_params : list [MaskOtelSpansParams ] = []
282+
283+ def mask_otel_spans (* , params : MaskOtelSpansParams ):
284+ seen_params .append (params )
285+
286+ return None
287+
288+ provider = _tracer_provider (
289+ exporter = exporter ,
290+ media_manager = media_manager ,
291+ mask_otel_spans = mask_otel_spans ,
292+ resource_attributes = {"deployment.environment.name" : "ci" },
293+ should_export_span = lambda span : True ,
294+ )
295+ tracer = provider .get_tracer ("snapshot.scope" , "1.2.3" )
296+
297+ with tracer .start_as_current_span ("parent-span" ) as parent_span :
298+ parent_span .set_attribute ("parent.attr" , "visible" )
299+
300+ with tracer .start_as_current_span ("child-span" ) as child_span :
301+ child_span .set_attribute ("secret" , "raw" )
302+
303+ provider .force_flush ()
304+
305+ params = seen_params [0 ]
306+ parent_identifier = _find_identifier_by_name (params , "parent-span" )
307+ child_identifier = _find_identifier_by_name (params , "child-span" )
308+ child_data = params .spans [child_identifier ]
309+
310+ assert len (params .spans ) == 2
311+ assert child_data .trace_id == child_identifier .trace_id
312+ assert child_data .span_id == child_identifier .span_id
313+ assert child_data .parent_span_id == parent_identifier .span_id
314+ assert child_data .name == "child-span"
315+ assert child_data .instrumentation_scope_name == "snapshot.scope"
316+ assert child_data .instrumentation_scope_version == "1.2.3"
317+ assert child_data .attributes ["secret" ] == "raw"
318+ assert child_data .resource_attributes ["service.name" ] == "test"
319+ assert child_data .resource_attributes ["deployment.environment.name" ] == "ci"
320+
321+ with pytest .raises (TypeError ):
322+ params .spans [child_identifier ] = child_data
323+
324+ with pytest .raises (TypeError ):
325+ child_data .attributes ["secret" ] = "changed"
326+
327+
211328def test_mask_otel_spans_runs_for_langfuse_sdk_spans ():
212329 exporter = InMemorySpanExporter ()
213330 media_manager , _ = _media_manager ()
@@ -241,6 +358,56 @@ def mask_otel_spans(*, params: MaskOtelSpansParams):
241358 assert exported_span .attributes ["secret" ] == "masked"
242359
243360
361+ def test_mask_otel_spans_none_result_leaves_batch_unchanged ():
362+ exporter = InMemorySpanExporter ()
363+ media_manager , _ = _media_manager ()
364+
365+ def mask_otel_spans (* , params : MaskOtelSpansParams ):
366+ return None
367+
368+ provider = _tracer_provider (
369+ exporter = exporter ,
370+ media_manager = media_manager ,
371+ mask_otel_spans = mask_otel_spans ,
372+ )
373+ tracer = provider .get_tracer ("openinference.instrumentation.openai" )
374+
375+ with tracer .start_as_current_span ("third-party-span" ) as span :
376+ span .set_attribute ("secret" , "raw" )
377+
378+ provider .force_flush ()
379+
380+ exported_span = exporter .get_finished_spans ()[0 ]
381+
382+ assert exported_span .attributes ["secret" ] == "raw"
383+
384+
385+ def test_mask_otel_spans_none_patch_leaves_span_unchanged ():
386+ exporter = InMemorySpanExporter ()
387+ media_manager , _ = _media_manager ()
388+
389+ def mask_otel_spans (* , params : MaskOtelSpansParams ):
390+ identifier = next (iter (params .spans ))
391+
392+ return MaskOtelSpansResult (span_patches = {identifier : None })
393+
394+ provider = _tracer_provider (
395+ exporter = exporter ,
396+ media_manager = media_manager ,
397+ mask_otel_spans = mask_otel_spans ,
398+ )
399+ tracer = provider .get_tracer ("openinference.instrumentation.openai" )
400+
401+ with tracer .start_as_current_span ("third-party-span" ) as span :
402+ span .set_attribute ("secret" , "raw" )
403+
404+ provider .force_flush ()
405+
406+ exported_span = exporter .get_finished_spans ()[0 ]
407+
408+ assert exported_span .attributes ["secret" ] == "raw"
409+
410+
244411def test_mask_otel_spans_exception_drops_batch ():
245412 exporter = InMemorySpanExporter ()
246413 media_manager , _ = _media_manager ()
@@ -288,6 +455,30 @@ def mask_otel_spans(*, params: MaskOtelSpansParams):
288455 assert exporter .get_finished_spans () == []
289456
290457
458+ def test_mask_otel_spans_invalid_span_patches_container_drops_batch ():
459+ exporter = InMemorySpanExporter ()
460+ media_manager , _ = _media_manager ()
461+ invalid_result = MaskOtelSpansResult ()
462+ object .__setattr__ (invalid_result , "span_patches" , [])
463+
464+ def mask_otel_spans (* , params : MaskOtelSpansParams ):
465+ return invalid_result
466+
467+ provider = _tracer_provider (
468+ exporter = exporter ,
469+ media_manager = media_manager ,
470+ mask_otel_spans = mask_otel_spans ,
471+ )
472+ tracer = provider .get_tracer ("openinference.instrumentation.openai" )
473+
474+ with tracer .start_as_current_span ("third-party-span" ) as span :
475+ span .set_attribute ("gen_ai.request.model" , "gpt-4o" )
476+
477+ provider .force_flush ()
478+
479+ assert exporter .get_finished_spans () == []
480+
481+
291482def test_mask_otel_spans_unknown_identifier_drops_batch ():
292483 exporter = InMemorySpanExporter ()
293484 media_manager , _ = _media_manager ()
@@ -348,6 +539,82 @@ def mask_otel_spans(*, params: MaskOtelSpansParams):
348539 assert [span .name for span in exported_spans ] == ["keep-me" ]
349540
350541
542+ @pytest .mark .parametrize ("invalid_field" , ["set_attributes" , "delete_attributes" ])
543+ def test_mask_otel_spans_invalid_patch_containers_drop_only_that_span (invalid_field ):
544+ exporter = InMemorySpanExporter ()
545+ media_manager , _ = _media_manager ()
546+
547+ def mask_otel_spans (* , params : MaskOtelSpansParams ):
548+ target_identifier = _find_identifier_by_name (params , "drop-me" )
549+ patch = OtelSpanPatch ()
550+
551+ if invalid_field == "set_attributes" :
552+ object .__setattr__ (patch , "set_attributes" , ["secret" ])
553+ else :
554+ object .__setattr__ (patch , "delete_attributes" , "secret" )
555+
556+ return MaskOtelSpansResult (span_patches = {target_identifier : patch })
557+
558+ provider = _tracer_provider (
559+ exporter = exporter ,
560+ media_manager = media_manager ,
561+ mask_otel_spans = mask_otel_spans ,
562+ )
563+ tracer = provider .get_tracer ("openinference.instrumentation.openai" )
564+
565+ with tracer .start_as_current_span ("drop-me" ) as span :
566+ span .set_attribute ("gen_ai.request.model" , "gpt-4o" )
567+
568+ with tracer .start_as_current_span ("keep-me" ) as span :
569+ span .set_attribute ("gen_ai.request.model" , "gpt-4o-mini" )
570+
571+ provider .force_flush ()
572+
573+ exported_spans = exporter .get_finished_spans ()
574+
575+ assert [span .name for span in exported_spans ] == ["keep-me" ]
576+
577+
578+ def test_mask_otel_spans_invalid_patch_keys_are_ignored ():
579+ exporter = InMemorySpanExporter ()
580+ media_manager , _ = _media_manager ()
581+
582+ def mask_otel_spans (* , params : MaskOtelSpansParams ):
583+ identifier = next (iter (params .spans ))
584+
585+ return MaskOtelSpansResult (
586+ span_patches = {
587+ identifier : OtelSpanPatch (
588+ delete_attributes = [None , "secret" ],
589+ set_attributes = {
590+ None : "ignored" ,
591+ "masked" : "value" ,
592+ },
593+ )
594+ }
595+ )
596+
597+ provider = _tracer_provider (
598+ exporter = exporter ,
599+ media_manager = media_manager ,
600+ mask_otel_spans = mask_otel_spans ,
601+ )
602+ tracer = provider .get_tracer ("openinference.instrumentation.openai" )
603+
604+ with tracer .start_as_current_span ("third-party-span" ) as span :
605+ span .set_attribute ("secret" , "raw" )
606+ span .set_attribute ("kept" , "value" )
607+
608+ provider .force_flush ()
609+
610+ exported_span = exporter .get_finished_spans ()[0 ]
611+
612+ assert "secret" not in exported_span .attributes
613+ assert None not in exported_span .attributes
614+ assert exported_span .attributes ["kept" ] == "value"
615+ assert exported_span .attributes ["masked" ] == "value"
616+
617+
351618def test_mask_otel_spans_invalid_set_value_deletes_attribute ():
352619 exporter = InMemorySpanExporter ()
353620 media_manager , _ = _media_manager ()
@@ -420,6 +687,43 @@ def mask_otel_spans(*, params: MaskOtelSpansParams):
420687 assert exported_span .attributes ["secret" ] == "masked"
421688
422689
690+ def test_mask_otel_spans_runs_after_should_export_span_filter ():
691+ exporter = InMemorySpanExporter ()
692+ media_manager , media_queue = _media_manager ()
693+ seen_params : list [MaskOtelSpansParams ] = []
694+ image_base64 = base64 .b64encode (b"image-bytes" ).decode ("utf-8" )
695+
696+ def mask_otel_spans (* , params : MaskOtelSpansParams ):
697+ seen_params .append (params )
698+
699+ return MaskOtelSpansResult ()
700+
701+ provider = _tracer_provider (
702+ exporter = exporter ,
703+ media_manager = media_manager ,
704+ mask_otel_spans = mask_otel_spans ,
705+ should_export_span = lambda span : span .name == "keep-me" ,
706+ )
707+ tracer = provider .get_tracer ("openinference.instrumentation.openai" )
708+
709+ with tracer .start_as_current_span ("drop-me" ) as span :
710+ span .set_attribute ("gen_ai.prompt" , f"data:image/jpeg;base64,{ image_base64 } " )
711+
712+ with tracer .start_as_current_span ("keep-me" ) as span :
713+ span .set_attribute ("gen_ai.request.model" , "gpt-4o" )
714+
715+ provider .force_flush ()
716+
717+ exported_spans = exporter .get_finished_spans ()
718+
719+ assert len (seen_params ) == 1
720+ assert [span_data .name for span_data in seen_params [0 ].spans .values ()] == [
721+ "keep-me"
722+ ]
723+ assert [span .name for span in exported_spans ] == ["keep-me" ]
724+ assert media_queue .empty ()
725+
726+
423727def _find_identifier_by_attribute (
424728 params : MaskOtelSpansParams , attribute_key : str
425729) -> OtelSpanIdentifier :
0 commit comments