Skip to content

Commit 82a8eb9

Browse files
committed
test(tracing): cover export-stage span masking
1 parent 7b67d48 commit 82a8eb9

1 file changed

Lines changed: 305 additions & 1 deletion

File tree

tests/unit/test_mask_otel_spans.py

Lines changed: 305 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import Sequence
66
from unittest.mock import Mock
77

8+
import pytest
89
from opentelemetry.sdk.resources import Resource
910
from opentelemetry.sdk.trace import ReadableSpan, TracerProvider
1011
from 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+
211328
def 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+
244411
def 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+
291482
def 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+
351618
def 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+
423727
def _find_identifier_by_attribute(
424728
params: MaskOtelSpansParams, attribute_key: str
425729
) -> OtelSpanIdentifier:

0 commit comments

Comments
 (0)