Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:25:13 +08:00

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