chore: import upstream snapshot with attribution
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
Docker image release / Build base image (push) Has been cancelled
Sync docs with Docusaurus / sync (push) Has been cancelled
Tests / Check if changed (push) Has been cancelled
Tests / format (push) Has been cancelled
Tests / check-imports (push) Has been cancelled
Tests / Unit / macos-latest (push) Has been cancelled
Tests / Unit / ubuntu-latest (push) Has been cancelled
Tests / Unit / windows-latest (push) Has been cancelled
Tests / mypy (push) Has been cancelled
Tests / Integration / ubuntu-latest (push) Has been cancelled
Tests / Integration / macos-latest (push) Has been cancelled
Tests / Integration / windows-latest (push) Has been cancelled
Tests / notify-slack-on-failure (push) Has been cancelled
Tests / Mark tests as completed (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
Docker image release / Build base image (push) Has been cancelled
Sync docs with Docusaurus / sync (push) Has been cancelled
Tests / Check if changed (push) Has been cancelled
Tests / format (push) Has been cancelled
Tests / check-imports (push) Has been cancelled
Tests / Unit / macos-latest (push) Has been cancelled
Tests / Unit / ubuntu-latest (push) Has been cancelled
Tests / Unit / windows-latest (push) Has been cancelled
Tests / mypy (push) Has been cancelled
Tests / Integration / ubuntu-latest (push) Has been cancelled
Tests / Integration / macos-latest (push) Has been cancelled
Tests / Integration / windows-latest (push) Has been cancelled
Tests / notify-slack-on-failure (push) Has been cancelled
Tests / Mark tests as completed (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
@@ -0,0 +1,200 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import logging
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from haystack import Pipeline, component, tracing
|
||||
from haystack.tracing.logging_tracer import LoggingTracer
|
||||
|
||||
|
||||
@component
|
||||
class Hello:
|
||||
@component.output_types(output=str)
|
||||
def run(self, word: str | None) -> dict[str, str]:
|
||||
return {"output": f"Hello, {word}!"}
|
||||
|
||||
|
||||
@component
|
||||
class FailingComponent:
|
||||
@component.output_types(output=str)
|
||||
def run(self, word: str | None) -> dict[str, str]:
|
||||
raise Exception("Failing component")
|
||||
|
||||
|
||||
class TestLoggingTracer:
|
||||
def test_init(self) -> None:
|
||||
tracer = LoggingTracer()
|
||||
assert tracer.tags_color_strings == {}
|
||||
|
||||
tracer = LoggingTracer(tags_color_strings={"tag_name": "color_string"})
|
||||
assert tracer.tags_color_strings == {"tag_name": "color_string"}
|
||||
|
||||
def test_logging_tracer(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
tracer = LoggingTracer()
|
||||
|
||||
caplog.set_level(logging.DEBUG)
|
||||
with tracer.trace("test") as span:
|
||||
span.set_tag("key", "value")
|
||||
|
||||
assert "Operation: test" in caplog.text
|
||||
assert "key=value" in caplog.text
|
||||
assert len(caplog.records) == 2
|
||||
|
||||
# structured logging - LoggingTracer dynamically adds these attributes to LogRecord
|
||||
assert caplog.records[0].operation_name == "test" # type: ignore[attr-defined]
|
||||
assert caplog.records[1].tag_name == "key" # type: ignore[attr-defined]
|
||||
assert caplog.records[1].tag_value == "value" # type: ignore[attr-defined]
|
||||
|
||||
def test_concurrent_spans_do_not_interleave(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Spans closing from parallel threads keep each span's operation-name and tag records contiguous."""
|
||||
tracer = LoggingTracer()
|
||||
caplog.set_level(logging.DEBUG)
|
||||
|
||||
num_threads = 4
|
||||
num_tags = 3
|
||||
# Line every thread up so their span-exit emissions race, maximizing the chance of interleaving without a lock.
|
||||
barrier = threading.Barrier(num_threads)
|
||||
|
||||
def emit_span(idx: int) -> None:
|
||||
with tracer.trace(f"op-{idx}") as span:
|
||||
barrier.wait()
|
||||
for tag_i in range(num_tags):
|
||||
span.set_tag(f"tag-{tag_i}", str(idx))
|
||||
|
||||
threads = [threading.Thread(target=emit_span, args=(i,)) for i in range(num_threads)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
# Bucket each tag record under the operation record that precedes it.
|
||||
groups: list[tuple[str, list[str]]] = []
|
||||
for record in caplog.records:
|
||||
if hasattr(record, "operation_name"):
|
||||
groups.append((record.operation_name, []))
|
||||
elif hasattr(record, "tag_name") and groups:
|
||||
groups[-1][1].append(record.tag_value) # type: ignore[attr-defined]
|
||||
|
||||
# One contiguous group per span, and every tag inside a group belongs to that span (no interleaving).
|
||||
assert len(groups) == num_threads
|
||||
for operation_name, tag_values in groups:
|
||||
idx = operation_name.removeprefix("op-")
|
||||
assert tag_values == [idx] * num_tags
|
||||
|
||||
def test_tracing_complex_values(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
tracer = LoggingTracer()
|
||||
|
||||
caplog.set_level(logging.DEBUG)
|
||||
|
||||
with tracer.trace("test") as span:
|
||||
span.set_tag("key", {"a": 1, "b": [2, 3, 4]})
|
||||
|
||||
assert "Operation: test" in caplog.text
|
||||
assert 'key={"a": 1, "b": [2, 3, 4]}' in caplog.text
|
||||
assert len(caplog.records) == 2
|
||||
|
||||
# structured logging - LoggingTracer dynamically adds these attributes to LogRecord
|
||||
assert caplog.records[0].operation_name == "test" # type: ignore[attr-defined]
|
||||
assert caplog.records[1].tag_name == "key" # type: ignore[attr-defined]
|
||||
assert caplog.records[1].tag_value == '{"a": 1, "b": [2, 3, 4]}' # type: ignore[attr-defined]
|
||||
|
||||
def test_apply_color_strings(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
tracer = LoggingTracer(tags_color_strings={"key": "color_string"})
|
||||
|
||||
caplog.set_level(logging.DEBUG)
|
||||
|
||||
with tracer.trace("test") as span:
|
||||
span.set_tag("key", "value")
|
||||
|
||||
assert "color_string" in caplog.text
|
||||
|
||||
def test_logging_pipeline(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
pipeline = Pipeline()
|
||||
pipeline.add_component("hello", Hello())
|
||||
pipeline.add_component("hello2", Hello())
|
||||
pipeline.connect("hello.output", "hello2.word")
|
||||
|
||||
tracing.enable_tracing(LoggingTracer())
|
||||
caplog.set_level(logging.DEBUG)
|
||||
|
||||
pipeline.run(data={"word": "world"})
|
||||
|
||||
records = caplog.records
|
||||
|
||||
assert any(
|
||||
record.operation_name == "haystack.component.run" for record in records if hasattr(record, "operation_name")
|
||||
)
|
||||
assert any(
|
||||
record.operation_name == "haystack.pipeline.run" for record in records if hasattr(record, "operation_name")
|
||||
)
|
||||
|
||||
tags_records = [record for record in records if hasattr(record, "tag_name")]
|
||||
assert any(record.tag_name == "haystack.component.name" for record in tags_records)
|
||||
assert any(record.tag_value == "hello" for record in tags_records) # type: ignore[attr-defined]
|
||||
|
||||
tracing.disable_tracing()
|
||||
|
||||
def test_logging_pipeline_with_content_tracing(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
pipeline = Pipeline()
|
||||
pipeline.add_component("hello", Hello())
|
||||
|
||||
tracing.tracer.is_content_tracing_enabled = True
|
||||
tracing.enable_tracing(LoggingTracer())
|
||||
|
||||
caplog.set_level(logging.DEBUG)
|
||||
|
||||
pipeline.run(data={"word": "world"})
|
||||
records = caplog.records
|
||||
|
||||
tags_records = [record for record in records if hasattr(record, "tag_name")]
|
||||
|
||||
input_tag_value = [
|
||||
record.tag_value # type: ignore[attr-defined]
|
||||
for record in tags_records
|
||||
if record.tag_name == "haystack.component.input"
|
||||
][0]
|
||||
assert input_tag_value == '{"word": "world"}'
|
||||
|
||||
output_tag_value = [
|
||||
record.tag_value # type: ignore[attr-defined]
|
||||
for record in tags_records
|
||||
if record.tag_name == "haystack.component.output"
|
||||
][0]
|
||||
assert output_tag_value == '{"output": "Hello, world!"}'
|
||||
|
||||
tracing.tracer.is_content_tracing_enabled = False
|
||||
tracing.disable_tracing()
|
||||
|
||||
def test_logging_pipeline_on_failure(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""
|
||||
Test that the LoggingTracer also logs events when a component fails.
|
||||
"""
|
||||
pipeline = Pipeline()
|
||||
pipeline.add_component("failing_component", FailingComponent())
|
||||
|
||||
tracing.enable_tracing(LoggingTracer())
|
||||
caplog.set_level(logging.DEBUG)
|
||||
|
||||
try:
|
||||
pipeline.run(data={"word": "world"})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
records = caplog.records
|
||||
|
||||
assert any(
|
||||
record.operation_name == "haystack.component.run" for record in records if hasattr(record, "operation_name")
|
||||
)
|
||||
assert any(
|
||||
record.operation_name == "haystack.pipeline.run" for record in records if hasattr(record, "operation_name")
|
||||
)
|
||||
|
||||
tags_records = [record for record in records if hasattr(record, "tag_name")]
|
||||
assert any(record.tag_name == "haystack.component.name" for record in tags_records)
|
||||
assert any(record.tag_value == "failing_component" for record in tags_records) # type: ignore[attr-defined]
|
||||
|
||||
tracing.disable_tracing()
|
||||
@@ -0,0 +1,93 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
from _pytest.monkeypatch import MonkeyPatch
|
||||
|
||||
from haystack.tracing.tracer import (
|
||||
HAYSTACK_CONTENT_TRACING_ENABLED_ENV_VAR,
|
||||
NullSpan,
|
||||
NullTracer,
|
||||
ProxyTracer,
|
||||
Tracer,
|
||||
disable_tracing,
|
||||
enable_tracing,
|
||||
is_tracing_enabled,
|
||||
tracer,
|
||||
)
|
||||
from test.tracing.utils import SpyingSpan, SpyingTracer
|
||||
|
||||
|
||||
class TestNullTracer:
|
||||
def test_tracing(self) -> None:
|
||||
assert isinstance(tracer.actual_tracer, NullTracer)
|
||||
|
||||
# None of this raises
|
||||
with tracer.trace("operation", {"key": "value"}, parent_span=None) as span:
|
||||
span.set_tag("key", "value")
|
||||
span.set_tags({"key": "value"})
|
||||
|
||||
assert isinstance(tracer.current_span(), NullSpan)
|
||||
current_span = tracer.current_span()
|
||||
assert current_span is not None
|
||||
assert isinstance(current_span.raw_span(), NullSpan)
|
||||
|
||||
|
||||
class TestProxyTracer:
|
||||
def test_tracing(self) -> None:
|
||||
spying_tracer = SpyingTracer()
|
||||
my_tracer = ProxyTracer(provided_tracer=spying_tracer)
|
||||
|
||||
parent_span = Mock(spec=SpyingSpan)
|
||||
with my_tracer.trace("operation", {"key": "value"}, parent_span=parent_span) as span:
|
||||
span.set_tag("key", "value")
|
||||
span.set_tags({"key2": "value2"})
|
||||
|
||||
assert len(spying_tracer.spans) == 1
|
||||
assert spying_tracer.spans[0].operation_name == "operation"
|
||||
assert spying_tracer.spans[0].parent_span == parent_span
|
||||
assert spying_tracer.spans[0].tags == {"key": "value", "key2": "value2"}
|
||||
|
||||
|
||||
class TestConfigureTracer:
|
||||
def test_enable_tracer(self) -> None:
|
||||
my_tracer = Mock(spec=Tracer) # anything else than `NullTracer` works for this test
|
||||
|
||||
enable_tracing(my_tracer)
|
||||
|
||||
assert isinstance(tracer, ProxyTracer)
|
||||
assert tracer.actual_tracer is my_tracer
|
||||
assert is_tracing_enabled()
|
||||
|
||||
def test_disable_tracing(self) -> None:
|
||||
my_tracker = Mock(spec=Tracer) # anything else than `NullTracer` works for this test
|
||||
|
||||
enable_tracing(my_tracker)
|
||||
assert tracer.actual_tracer is my_tracker
|
||||
|
||||
disable_tracing()
|
||||
assert isinstance(tracer.actual_tracer, NullTracer)
|
||||
assert is_tracing_enabled() is False
|
||||
|
||||
|
||||
class TestTracingContent:
|
||||
def test_set_content_tag_with_enabled_content_tracing(self, spying_tracer: SpyingTracer) -> None:
|
||||
# SpyingTracer supports content tracing by default
|
||||
|
||||
enable_tracing(spying_tracer)
|
||||
with tracer.trace("test") as span:
|
||||
span.set_content_tag("my_content", "my_content")
|
||||
|
||||
assert len(spying_tracer.spans) == 1
|
||||
span = spying_tracer.spans[0]
|
||||
assert span.tags == {"my_content": "my_content"}
|
||||
|
||||
def test_set_content_tag_when_disabled_via_env_variable(self, monkeypatch: MonkeyPatch) -> None:
|
||||
# we test if content tracing is disabled when the env variable is set to false
|
||||
monkeypatch.setenv(HAYSTACK_CONTENT_TRACING_ENABLED_ENV_VAR, "false")
|
||||
|
||||
proxy_tracer = ProxyTracer(provided_tracer=SpyingTracer())
|
||||
|
||||
assert proxy_tracer.is_content_tracing_enabled is False
|
||||
@@ -0,0 +1,121 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from haystack import Document
|
||||
from haystack.tracing import utils
|
||||
from haystack.tracing.utils import _serializable_value
|
||||
|
||||
|
||||
class NonSerializableClass:
|
||||
def __str__(self) -> str:
|
||||
return "NonSerializableClass"
|
||||
|
||||
|
||||
class ClassWithToDict:
|
||||
def __init__(self, value: str):
|
||||
self.value = value
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"value": self.value}
|
||||
|
||||
|
||||
class ClassWithBothMethods:
|
||||
"""Class with both to_dict and _to_trace_dict methods."""
|
||||
|
||||
def __init__(self, data: bytes):
|
||||
self.data = data
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"data": list(self.data)}
|
||||
|
||||
def _to_trace_dict(self) -> dict:
|
||||
return {"data": f"Binary ({len(self.data)} bytes)"}
|
||||
|
||||
|
||||
class TestSerializableValue:
|
||||
@pytest.mark.parametrize("value", [1, 1.0, True, False, "string", None])
|
||||
def test_primitive_types(self, value: Any) -> None:
|
||||
assert _serializable_value(value) == value
|
||||
|
||||
def test_list_serialized_recursively(self) -> None:
|
||||
result = _serializable_value([1, "two", 3.0])
|
||||
assert result == [1, "two", 3.0]
|
||||
|
||||
def test_dict_serialized_recursively(self) -> None:
|
||||
result = _serializable_value({"a": 1, "b": "two"})
|
||||
assert result == {"a": 1, "b": "two"}
|
||||
|
||||
def test_nested_list_and_dict(self) -> None:
|
||||
value = {"items": [1, 2, {"nested": "value"}]}
|
||||
result = _serializable_value(value)
|
||||
assert result == {"items": [1, 2, {"nested": "value"}]}
|
||||
|
||||
def test_object_with_to_dict(self) -> None:
|
||||
obj = ClassWithToDict("test")
|
||||
result = _serializable_value(obj)
|
||||
assert result == {"value": "test"}
|
||||
|
||||
def test_object_with_to_trace_dict_placeholders(self) -> None:
|
||||
obj = ClassWithBothMethods(b"hello")
|
||||
result = _serializable_value(obj, use_placeholders=True)
|
||||
assert result == {"data": "Binary (5 bytes)"}
|
||||
|
||||
def test_object_with_to_trace_dict_no_placeholders(self) -> None:
|
||||
obj = ClassWithBothMethods(b"hello")
|
||||
result = _serializable_value(obj, use_placeholders=False)
|
||||
assert result == {"data": [104, 101, 108, 108, 111]}
|
||||
|
||||
def test_list_of_objects_with_to_dict(self) -> None:
|
||||
objs = [ClassWithToDict("a"), ClassWithToDict("b")]
|
||||
result = _serializable_value(objs)
|
||||
assert result == [{"value": "a"}, {"value": "b"}]
|
||||
|
||||
def test_dict_with_object_values(self) -> None:
|
||||
value = {"obj": ClassWithToDict("test")}
|
||||
result = _serializable_value(value)
|
||||
assert result == {"obj": {"value": "test"}}
|
||||
|
||||
def test_object_without_serialization_methods(self) -> None:
|
||||
obj = NonSerializableClass()
|
||||
result = _serializable_value(obj)
|
||||
assert result is obj
|
||||
|
||||
|
||||
class TestTypeCoercion:
|
||||
@pytest.mark.parametrize(
|
||||
"raw_value,expected_tag_value",
|
||||
[
|
||||
(1, 1),
|
||||
(1.0, 1.0),
|
||||
(True, True),
|
||||
(None, ""),
|
||||
("string", "string"),
|
||||
([1, 2, 3], "[1, 2, 3]"),
|
||||
({"key": "value"}, '{"key": "value"}'),
|
||||
(NonSerializableClass(), "NonSerializableClass"),
|
||||
(
|
||||
Document(id="1", content="text"),
|
||||
'{"id": "1", "content": "text", "blob": null, "score": null, "embedding": null, '
|
||||
'"sparse_embedding": null}',
|
||||
),
|
||||
(
|
||||
[Document(id="1", content="text")],
|
||||
'[{"id": "1", "content": "text", "blob": null, "score": null, "embedding": null, '
|
||||
'"sparse_embedding": null}]',
|
||||
),
|
||||
(
|
||||
{"key": Document(id="1", content="text")},
|
||||
'{"key": {"id": "1", "content": "text", "blob": null, "score": null, "embedding": null, '
|
||||
'"sparse_embedding": null}}',
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_type_coercion(self, raw_value: Any, expected_tag_value: bool | str | int | float) -> None:
|
||||
coerced_value = utils.coerce_tag_value(raw_value)
|
||||
|
||||
assert coerced_value == expected_tag_value
|
||||
@@ -0,0 +1,54 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import contextlib
|
||||
import dataclasses
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
from haystack.tracing import Span, Tracer
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class SpyingSpan(Span):
|
||||
operation_name: str
|
||||
parent_span: Span | None = None
|
||||
|
||||
tags: dict[str, Any] = dataclasses.field(default_factory=dict)
|
||||
|
||||
trace_id: str | None = dataclasses.field(default_factory=lambda: str(uuid.uuid4()))
|
||||
span_id: str | None = dataclasses.field(default_factory=lambda: str(uuid.uuid4()))
|
||||
|
||||
def set_tag(self, key: str, value: Any) -> None:
|
||||
self.tags[key] = value
|
||||
|
||||
def get_correlation_data_for_logs(self) -> dict[str, Any]:
|
||||
return {"trace_id": self.trace_id, "span_id": self.span_id}
|
||||
|
||||
def set_content_tag(self, key: str, value: Any) -> None:
|
||||
"""
|
||||
Set a content tag, but only if content tracing is enabled in the tracer.
|
||||
"""
|
||||
self.set_tag(key, value)
|
||||
|
||||
|
||||
class SpyingTracer(Tracer):
|
||||
def current_span(self) -> Span | None:
|
||||
return self.spans[-1] if self.spans else None
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.spans: list[SpyingSpan] = []
|
||||
|
||||
@contextlib.contextmanager
|
||||
def trace(
|
||||
self, operation_name: str, tags: dict[str, Any] | None = None, parent_span: Span | None = None
|
||||
) -> Iterator[Span]:
|
||||
new_span = SpyingSpan(operation_name, parent_span)
|
||||
for key, value in (tags or {}).items():
|
||||
new_span.set_tag(key, value)
|
||||
|
||||
self.spans.append(new_span)
|
||||
|
||||
yield new_span
|
||||
Reference in New Issue
Block a user