ec2b666284
Continuous Integration / Pre-commit Linter (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.10) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.11) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.12) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.10) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.11) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.12) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.14) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Waiting to run
Copybara PR Handler / close-imported-pr (push) Waiting to run
463 lines
13 KiB
Python
463 lines
13 KiB
Python
# Copyright 2026 Google LLC
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from google.adk.telemetry.sqlite_span_exporter import SqliteSpanExporter
|
|
from opentelemetry.sdk.trace import ReadableSpan
|
|
from opentelemetry.sdk.trace.export import SpanExportResult
|
|
from opentelemetry.trace import SpanContext
|
|
from opentelemetry.trace import TraceFlags
|
|
from opentelemetry.trace import TraceState
|
|
|
|
|
|
def _create_span(
|
|
*,
|
|
span_id: int = 0x00000000000ABC12,
|
|
trace_id: int = 0x000000000000000000000000000DEF45,
|
|
parent_span_id: int | None = None,
|
|
name: str = "test_span",
|
|
attributes: dict | None = None,
|
|
start_time: int = 1000,
|
|
end_time: int = 2000,
|
|
) -> ReadableSpan:
|
|
"""Helper to create ReadableSpan instances for testing."""
|
|
context = SpanContext(
|
|
trace_id=trace_id,
|
|
span_id=span_id,
|
|
is_remote=False,
|
|
trace_flags=TraceFlags(TraceFlags.SAMPLED),
|
|
trace_state=TraceState(),
|
|
)
|
|
|
|
parent = None
|
|
if parent_span_id is not None:
|
|
parent = SpanContext(
|
|
trace_id=trace_id,
|
|
span_id=parent_span_id,
|
|
is_remote=False,
|
|
trace_flags=TraceFlags(TraceFlags.SAMPLED),
|
|
trace_state=TraceState(),
|
|
)
|
|
|
|
return ReadableSpan(
|
|
name=name,
|
|
context=context,
|
|
parent=parent,
|
|
attributes=attributes or {},
|
|
start_time=start_time,
|
|
end_time=end_time,
|
|
)
|
|
|
|
|
|
def test_export_single_span_returns_success(tmp_path):
|
|
db_path = tmp_path / "test.db"
|
|
exporter = SqliteSpanExporter(db_path=str(db_path))
|
|
|
|
span = _create_span(
|
|
name="test_operation",
|
|
attributes={"gcp.vertex.agent.session_id": "session-123"},
|
|
)
|
|
|
|
result = exporter.export([span])
|
|
|
|
assert result == SpanExportResult.SUCCESS
|
|
assert db_path.exists()
|
|
|
|
|
|
def test_export_empty_list_returns_success(tmp_path):
|
|
db_path = tmp_path / "test.db"
|
|
exporter = SqliteSpanExporter(db_path=str(db_path))
|
|
|
|
result = exporter.export([])
|
|
|
|
assert result == SpanExportResult.SUCCESS
|
|
|
|
|
|
def test_get_all_spans_for_session_returns_matching_spans(tmp_path):
|
|
db_path = tmp_path / "test.db"
|
|
exporter = SqliteSpanExporter(db_path=str(db_path))
|
|
|
|
span1 = _create_span(
|
|
span_id=0x111,
|
|
trace_id=0xAAA111, # Different trace for session-123
|
|
attributes={"gcp.vertex.agent.session_id": "session-123"},
|
|
name="span1",
|
|
)
|
|
span2 = _create_span(
|
|
span_id=0x222,
|
|
trace_id=0xAAA222, # Different trace for session-123
|
|
attributes={"gcp.vertex.agent.session_id": "session-123"},
|
|
name="span2",
|
|
)
|
|
span3 = _create_span(
|
|
span_id=0x333,
|
|
trace_id=0xBBB333, # Different trace for session-456
|
|
attributes={"gcp.vertex.agent.session_id": "session-456"},
|
|
name="span3",
|
|
)
|
|
|
|
exporter.export([span1, span2, span3])
|
|
|
|
result = exporter.get_all_spans_for_session("session-123")
|
|
|
|
assert len(result) == 2
|
|
names = [span.name for span in result]
|
|
assert "span1" in names
|
|
assert "span2" in names
|
|
assert "span3" not in names
|
|
|
|
|
|
def test_get_all_spans_for_session_includes_sibling_spans_without_session_id(
|
|
tmp_path,
|
|
):
|
|
db_path = tmp_path / "test.db"
|
|
exporter = SqliteSpanExporter(db_path=str(db_path))
|
|
|
|
# Parent span without session_id (e.g., invocation span)
|
|
parent_span = _create_span(
|
|
span_id=0x100,
|
|
trace_id=0xAAA,
|
|
name="invocation",
|
|
attributes={}, # No session_id
|
|
)
|
|
|
|
# Child span with session_id
|
|
child_span = _create_span(
|
|
span_id=0x200,
|
|
trace_id=0xAAA, # Same trace
|
|
parent_span_id=0x100,
|
|
name="call_llm",
|
|
attributes={"gcp.vertex.agent.session_id": "session-789"},
|
|
)
|
|
|
|
# Sibling span without session_id (should be included)
|
|
sibling_span = _create_span(
|
|
span_id=0x300,
|
|
trace_id=0xAAA, # Same trace
|
|
parent_span_id=0x100,
|
|
name="tool_call",
|
|
attributes={}, # No session_id
|
|
)
|
|
|
|
# Unrelated span with different trace_id (should not be included)
|
|
unrelated_span = _create_span(
|
|
span_id=0x400,
|
|
trace_id=0xBBB, # Different trace
|
|
name="unrelated",
|
|
attributes={},
|
|
)
|
|
|
|
exporter.export([parent_span, child_span, sibling_span, unrelated_span])
|
|
|
|
result = exporter.get_all_spans_for_session("session-789")
|
|
|
|
assert len(result) == 3
|
|
names = [span.name for span in result]
|
|
assert "invocation" in names
|
|
assert "call_llm" in names
|
|
assert "tool_call" in names
|
|
assert "unrelated" not in names
|
|
|
|
|
|
def test_get_all_spans_for_unknown_session_returns_empty_list(tmp_path):
|
|
db_path = tmp_path / "test.db"
|
|
exporter = SqliteSpanExporter(db_path=str(db_path))
|
|
|
|
span = _create_span(
|
|
attributes={"gcp.vertex.agent.session_id": "session-123"},
|
|
)
|
|
exporter.export([span])
|
|
|
|
result = exporter.get_all_spans_for_session("unknown-session")
|
|
|
|
assert result == []
|
|
|
|
|
|
def test_round_trip_preserves_span_attributes(tmp_path):
|
|
db_path = tmp_path / "test.db"
|
|
exporter = SqliteSpanExporter(db_path=str(db_path))
|
|
|
|
original_attributes = {
|
|
"gcp.vertex.agent.session_id": "session-123",
|
|
"gcp.vertex.agent.invocation_id": "invocation-456",
|
|
"gen_ai.conversation.id": "conv-789",
|
|
"custom.attribute": "test_value",
|
|
"numeric.value": 42,
|
|
"boolean.value": True,
|
|
"list.value": [1, 2, 3],
|
|
"dict.value": {"nested": "data"},
|
|
}
|
|
|
|
original_span = _create_span(
|
|
span_id=0x12345678,
|
|
trace_id=0xABCDEF123456789,
|
|
name="test_operation",
|
|
attributes=original_attributes,
|
|
start_time=1000000,
|
|
end_time=2000000,
|
|
)
|
|
|
|
exporter.export([original_span])
|
|
|
|
retrieved_spans = exporter.get_all_spans_for_session("session-123")
|
|
|
|
assert len(retrieved_spans) == 1
|
|
retrieved = retrieved_spans[0]
|
|
|
|
assert retrieved.name == "test_operation"
|
|
assert retrieved.context.span_id == 0x12345678
|
|
assert retrieved.context.trace_id == 0xABCDEF123456789
|
|
assert retrieved.start_time == 1000000
|
|
assert retrieved.end_time == 2000000
|
|
assert retrieved.attributes == original_attributes
|
|
|
|
|
|
def test_spans_with_parent_context_exported_correctly(tmp_path):
|
|
db_path = tmp_path / "test.db"
|
|
exporter = SqliteSpanExporter(db_path=str(db_path))
|
|
|
|
parent_span = _create_span(
|
|
span_id=0xAAA,
|
|
trace_id=0x123,
|
|
name="parent",
|
|
attributes={"gcp.vertex.agent.session_id": "session-001"},
|
|
)
|
|
|
|
child_span = _create_span(
|
|
span_id=0xBBB,
|
|
trace_id=0x123,
|
|
parent_span_id=0xAAA,
|
|
name="child",
|
|
attributes={"gcp.vertex.agent.session_id": "session-001"},
|
|
)
|
|
|
|
exporter.export([parent_span, child_span])
|
|
|
|
retrieved_spans = exporter.get_all_spans_for_session("session-001")
|
|
|
|
assert len(retrieved_spans) == 2
|
|
|
|
# Find child span in results
|
|
child = next(s for s in retrieved_spans if s.name == "child")
|
|
assert child.parent is not None
|
|
assert child.parent.span_id == 0xAAA
|
|
assert child.parent.trace_id == 0x123
|
|
|
|
# Find parent span in results
|
|
parent = next(s for s in retrieved_spans if s.name == "parent")
|
|
assert parent.parent is None
|
|
|
|
|
|
def test_shutdown_closes_connection(tmp_path):
|
|
db_path = tmp_path / "test.db"
|
|
exporter = SqliteSpanExporter(db_path=str(db_path))
|
|
|
|
# Create a span to ensure connection is open
|
|
span = _create_span()
|
|
exporter.export([span])
|
|
|
|
# Verify connection exists
|
|
assert exporter._conn is not None
|
|
|
|
exporter.shutdown()
|
|
|
|
# Verify connection is closed
|
|
assert exporter._conn is None
|
|
|
|
|
|
def test_force_flush_returns_true(tmp_path):
|
|
db_path = tmp_path / "test.db"
|
|
exporter = SqliteSpanExporter(db_path=str(db_path))
|
|
|
|
result = exporter.force_flush()
|
|
|
|
assert result is True
|
|
|
|
# Also test with timeout parameter
|
|
result_with_timeout = exporter.force_flush(timeout_millis=5000)
|
|
assert result_with_timeout is True
|
|
|
|
|
|
def test_export_handles_spans_with_none_attributes(tmp_path):
|
|
db_path = tmp_path / "test.db"
|
|
exporter = SqliteSpanExporter(db_path=str(db_path))
|
|
|
|
span = _create_span(attributes=None)
|
|
|
|
result = exporter.export([span])
|
|
|
|
assert result == SpanExportResult.SUCCESS
|
|
|
|
# Verify the span was stored correctly
|
|
rows = exporter._query("SELECT attributes_json FROM spans", [])
|
|
assert len(rows) == 1
|
|
attributes_json = rows[0]["attributes_json"]
|
|
assert json.loads(attributes_json) == {}
|
|
|
|
|
|
def test_duplicate_span_id_replaces_previous_row(tmp_path):
|
|
db_path = tmp_path / "test.db"
|
|
exporter = SqliteSpanExporter(db_path=str(db_path))
|
|
|
|
# Export first version of span
|
|
span1 = _create_span(
|
|
span_id=0x999,
|
|
name="first_version",
|
|
attributes={"version": 1, "gcp.vertex.agent.session_id": "session-dup"},
|
|
)
|
|
exporter.export([span1])
|
|
|
|
# Export second version with same span_id
|
|
span2 = _create_span(
|
|
span_id=0x999,
|
|
name="second_version",
|
|
attributes={"version": 2, "gcp.vertex.agent.session_id": "session-dup"},
|
|
)
|
|
exporter.export([span2])
|
|
|
|
# Verify only one row exists with updated data
|
|
retrieved_spans = exporter.get_all_spans_for_session("session-dup")
|
|
assert len(retrieved_spans) == 1
|
|
assert retrieved_spans[0].name == "second_version"
|
|
assert retrieved_spans[0].attributes["version"] == 2
|
|
|
|
|
|
def test_non_serializable_attributes_use_fallback(tmp_path):
|
|
db_path = tmp_path / "test.db"
|
|
exporter = SqliteSpanExporter(db_path=str(db_path))
|
|
|
|
# Create a non-serializable object
|
|
class NonSerializable:
|
|
pass
|
|
|
|
attributes = {
|
|
"gcp.vertex.agent.session_id": "session-nonser",
|
|
"normal_attr": "value",
|
|
"non_serializable": NonSerializable(),
|
|
}
|
|
|
|
span = _create_span(attributes=attributes)
|
|
|
|
result = exporter.export([span])
|
|
|
|
assert result == SpanExportResult.SUCCESS
|
|
|
|
# Verify the span was stored and non-serializable attribute has fallback
|
|
retrieved_spans = exporter.get_all_spans_for_session("session-nonser")
|
|
assert len(retrieved_spans) == 1
|
|
assert retrieved_spans[0].attributes["normal_attr"] == "value"
|
|
assert (
|
|
retrieved_spans[0].attributes["non_serializable"] == "<not serializable>"
|
|
)
|
|
|
|
|
|
def test_export_multiple_spans_in_batch(tmp_path):
|
|
db_path = tmp_path / "test.db"
|
|
exporter = SqliteSpanExporter(db_path=str(db_path))
|
|
|
|
spans = [
|
|
_create_span(
|
|
span_id=i,
|
|
name=f"span_{i}",
|
|
attributes={"gcp.vertex.agent.session_id": "batch-session"},
|
|
)
|
|
for i in range(10)
|
|
]
|
|
|
|
result = exporter.export(spans)
|
|
|
|
assert result == SpanExportResult.SUCCESS
|
|
|
|
retrieved_spans = exporter.get_all_spans_for_session("batch-session")
|
|
assert len(retrieved_spans) == 10
|
|
names = {span.name for span in retrieved_spans}
|
|
assert names == {f"span_{i}" for i in range(10)}
|
|
|
|
|
|
def test_export_with_alternative_session_id_attribute(tmp_path):
|
|
db_path = tmp_path / "test.db"
|
|
exporter = SqliteSpanExporter(db_path=str(db_path))
|
|
|
|
# Test using gen_ai.conversation.id as fallback for session_id
|
|
span = _create_span(
|
|
attributes={"gen_ai.conversation.id": "conv-session-123"},
|
|
)
|
|
|
|
exporter.export([span])
|
|
|
|
# Should be queryable by the conversation id
|
|
result = exporter.get_all_spans_for_session("conv-session-123")
|
|
|
|
assert len(result) == 1
|
|
assert result[0].attributes["gen_ai.conversation.id"] == "conv-session-123"
|
|
|
|
|
|
def test_deserialize_handles_invalid_json(tmp_path):
|
|
db_path = tmp_path / "test.db"
|
|
exporter = SqliteSpanExporter(db_path=str(db_path))
|
|
|
|
# Manually insert a row with invalid JSON
|
|
conn = exporter._get_connection()
|
|
conn.execute(
|
|
"INSERT INTO spans (span_id, trace_id, name, attributes_json) VALUES (?,"
|
|
" ?, ?, ?)",
|
|
("abc123", "def456", "test", "not valid json"),
|
|
)
|
|
conn.commit()
|
|
|
|
# Try to retrieve the span - should not raise, but attributes should be empty
|
|
rows = exporter._query("SELECT * FROM spans", [])
|
|
span = exporter._row_to_readable_span(rows[0])
|
|
|
|
assert span.name == "test"
|
|
assert span.attributes == {}
|
|
|
|
|
|
def test_get_spans_ordered_by_start_time(tmp_path):
|
|
db_path = tmp_path / "test.db"
|
|
exporter = SqliteSpanExporter(db_path=str(db_path))
|
|
|
|
# Create spans with different start times
|
|
spans = [
|
|
_create_span(
|
|
span_id=0x300,
|
|
start_time=3000,
|
|
attributes={"gcp.vertex.agent.session_id": "session-order"},
|
|
),
|
|
_create_span(
|
|
span_id=0x100,
|
|
start_time=1000,
|
|
attributes={"gcp.vertex.agent.session_id": "session-order"},
|
|
),
|
|
_create_span(
|
|
span_id=0x200,
|
|
start_time=2000,
|
|
attributes={"gcp.vertex.agent.session_id": "session-order"},
|
|
),
|
|
]
|
|
|
|
exporter.export(spans)
|
|
|
|
result = exporter.get_all_spans_for_session("session-order")
|
|
|
|
# Verify spans are ordered by start_time
|
|
assert len(result) == 3
|
|
assert result[0].context.span_id == 0x100
|
|
assert result[1].context.span_id == 0x200
|
|
assert result[2].context.span_id == 0x300
|