Skip to content

Commit ebec2c8

Browse files
BichengWangcopybara-github
authored andcommitted
fix: preserve span kinds in SQLite traces
Merge #7504 PiperOrigin-RevId: 1008863810
1 parent 34750dc commit ebec2c8

2 files changed

Lines changed: 227 additions & 7 deletions

File tree

‎src/google/adk/telemetry/sqlite_span_exporter.py‎

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from opentelemetry.sdk.trace.export import SpanExporter
3131
from opentelemetry.sdk.trace.export import SpanExportResult
3232
from opentelemetry.trace import SpanContext
33+
from opentelemetry.trace import SpanKind
3334
from opentelemetry.trace import TraceFlags
3435
from opentelemetry.trace import TraceState
3536
from opentelemetry.util.types import AttributeValue
@@ -58,6 +59,15 @@
5859
CREATE INDEX IF NOT EXISTS spans_trace_id_idx ON spans(trace_id);
5960
"""
6061

62+
# Ordered schema migrations: entry 0 creates the original schema and each later
63+
# entry upgrades the one before it. `PRAGMA user_version` records how many have
64+
# been applied, so opening a database runs only the ones it lacks. Append new
65+
# entries; never edit one that has shipped.
66+
_MIGRATIONS: tuple[tuple[str, ...], ...] = (
67+
(_CREATE_SPANS_TABLE, _CREATE_SESSION_INDEX, _CREATE_TRACE_INDEX),
68+
("ALTER TABLE spans ADD COLUMN kind TEXT NOT NULL DEFAULT 'INTERNAL'",),
69+
)
70+
6171
_INSERT_SPAN = """
6272
INSERT OR REPLACE INTO spans (
6373
span_id,
@@ -68,18 +78,45 @@
6878
end_time_unix_nano,
6979
session_id,
7080
invocation_id,
71-
attributes_json
72-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);
81+
attributes_json,
82+
kind
83+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
7384
"""
7485

7586
_DEFAULT_TIMEOUT_SECONDS = 30.0
7687

7788

89+
def _apply_migrations(
90+
conn: sqlite3.Connection, migrations: Sequence[Sequence[str]]
91+
) -> None:
92+
"""Applies the entries of `migrations` the database has not run yet."""
93+
# Checked outside any transaction first, so opening a current database never
94+
# waits on another connection's write lock.
95+
if conn.execute("PRAGMA user_version").fetchone()[0] >= len(migrations):
96+
return
97+
with conn:
98+
# CPython opens no transaction for DDL, so begin one explicitly. It
99+
# takes the write lock before the version is read, so two processes
100+
# opening the same file cannot both apply a migration.
101+
conn.execute("BEGIN IMMEDIATE")
102+
version = conn.execute("PRAGMA user_version").fetchone()[0]
103+
if version >= len(migrations):
104+
return
105+
for statements in migrations[version:]:
106+
for statement in statements:
107+
conn.execute(statement)
108+
# PRAGMA takes no bind parameters; the value is an int length.
109+
conn.execute(f"PRAGMA user_version = {len(migrations)}")
110+
111+
78112
class SqliteSpanExporter(SpanExporter):
79113
"""Exports spans to a local SQLite database.
80114
81115
This is intended for local development (e.g. `adk web`) to allow reloading
82116
traces for older sessions after process restart.
117+
118+
Span kinds survive export and reload. Existing databases are upgraded in
119+
place; spans saved before kind persistence retain the INTERNAL default.
83120
"""
84121

85122
def __init__(self, *, db_path: str):
@@ -100,11 +137,7 @@ def _get_connection(self) -> sqlite3.Connection:
100137

101138
def _ensure_schema(self) -> None:
102139
with self._lock:
103-
conn = self._get_connection()
104-
conn.execute(_CREATE_SPANS_TABLE)
105-
conn.execute(_CREATE_SESSION_INDEX)
106-
conn.execute(_CREATE_TRACE_INDEX)
107-
conn.commit()
140+
_apply_migrations(self._get_connection(), _MIGRATIONS)
108141

109142
def _serialize_attributes(
110143
self, attributes: Mapping[str, AttributeValue]
@@ -167,6 +200,7 @@ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
167200
session_id,
168201
invocation_id,
169202
self._serialize_attributes(attributes),
203+
span.kind.name,
170204
))
171205
conn.executemany(_INSERT_SPAN, rows)
172206
conn.commit()
@@ -221,6 +255,7 @@ def _row_to_readable_span(self, row: sqlite3.Row) -> ReadableSpan:
221255
name=row["name"] or "",
222256
context=context,
223257
parent=parent,
258+
kind=SpanKind[row["kind"]],
224259
attributes=attributes,
225260
start_time=row["start_time_unix_nano"],
226261
end_time=row["end_time_unix_nano"],

‎tests/unittests/telemetry/test_sqlite_span_exporter.py‎

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,17 @@
1616

1717
import json
1818
from pathlib import Path
19+
import sqlite3
1920

21+
from google.adk.telemetry.sqlite_span_exporter import _apply_migrations
2022
from google.adk.telemetry.sqlite_span_exporter import SqliteSpanExporter
2123
from opentelemetry.sdk.trace import ReadableSpan
2224
from opentelemetry.sdk.trace.export import SpanExportResult
2325
from opentelemetry.trace import SpanContext
26+
from opentelemetry.trace import SpanKind
2427
from opentelemetry.trace import TraceFlags
2528
from opentelemetry.trace import TraceState
29+
import pytest
2630

2731

2832
def _create_span(
@@ -32,6 +36,7 @@ def _create_span(
3236
parent_span_id: int | None = None,
3337
name: str = "test_span",
3438
attributes: dict | None = None,
39+
kind: SpanKind = SpanKind.INTERNAL,
3540
start_time: int = 1000,
3641
end_time: int = 2000,
3742
) -> ReadableSpan:
@@ -58,6 +63,7 @@ def _create_span(
5863
name=name,
5964
context=context,
6065
parent=parent,
66+
kind=kind,
6167
attributes=attributes or {},
6268
start_time=start_time,
6369
end_time=end_time,
@@ -227,6 +233,185 @@ def test_round_trip_preserves_span_attributes(tmp_path):
227233
assert retrieved.attributes == original_attributes
228234

229235

236+
def test_span_kinds_survive_exporter_restart(tmp_path: Path) -> None:
237+
db_path = tmp_path / "test.db"
238+
kinds = [
239+
SpanKind.CLIENT,
240+
SpanKind.SERVER,
241+
SpanKind.PRODUCER,
242+
SpanKind.CONSUMER,
243+
]
244+
exporter = SqliteSpanExporter(db_path=str(db_path))
245+
spans = [
246+
_create_span(
247+
span_id=index + 1,
248+
kind=kind,
249+
attributes={"gen_ai.conversation.id": "session-kind"},
250+
start_time=index,
251+
)
252+
for index, kind in enumerate(kinds)
253+
]
254+
assert exporter.export(spans) == SpanExportResult.SUCCESS
255+
exporter.shutdown()
256+
257+
reopened = SqliteSpanExporter(db_path=str(db_path))
258+
try:
259+
restored = reopened.get_all_spans_for_session("session-kind")
260+
assert [(span.context.span_id, span.kind) for span in restored] == [
261+
(index + 1, kind) for index, kind in enumerate(kinds)
262+
]
263+
finally:
264+
reopened.shutdown()
265+
266+
267+
def test_legacy_database_retains_spans_and_accepts_new_kinds(
268+
tmp_path: Path,
269+
) -> None:
270+
db_path = tmp_path / "legacy.db"
271+
with sqlite3.connect(db_path) as conn:
272+
conn.execute("""
273+
CREATE TABLE spans (
274+
span_id TEXT PRIMARY KEY, trace_id TEXT NOT NULL,
275+
parent_span_id TEXT, name TEXT NOT NULL,
276+
start_time_unix_nano INTEGER, end_time_unix_nano INTEGER,
277+
session_id TEXT, invocation_id TEXT, attributes_json TEXT
278+
)
279+
""")
280+
conn.execute(
281+
"INSERT INTO spans VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
282+
(
283+
"0000000000000001",
284+
"000000000000000000000000000def45",
285+
None,
286+
"legacy_operation",
287+
1000,
288+
2000,
289+
"session-legacy",
290+
None,
291+
'{"gen_ai.conversation.id": "session-legacy"}',
292+
),
293+
)
294+
295+
exporter = SqliteSpanExporter(db_path=str(db_path))
296+
try:
297+
assert (
298+
exporter.export([
299+
_create_span(
300+
span_id=2,
301+
name="new_operation",
302+
kind=SpanKind.CLIENT,
303+
attributes={"gen_ai.conversation.id": "session-legacy"},
304+
start_time=3000,
305+
)
306+
])
307+
== SpanExportResult.SUCCESS
308+
)
309+
restored = exporter.get_all_spans_for_session("session-legacy")
310+
assert [(span.name, span.kind) for span in restored] == [
311+
("legacy_operation", SpanKind.INTERNAL),
312+
("new_operation", SpanKind.CLIENT),
313+
]
314+
finally:
315+
exporter.shutdown()
316+
317+
318+
def test_database_from_newer_schema_version_is_not_downgraded(
319+
tmp_path: Path,
320+
) -> None:
321+
db_path = tmp_path / "newer.db"
322+
SqliteSpanExporter(db_path=str(db_path)).shutdown()
323+
with sqlite3.connect(db_path) as conn:
324+
conn.execute("PRAGMA user_version = 99")
325+
326+
SqliteSpanExporter(db_path=str(db_path)).shutdown()
327+
328+
with sqlite3.connect(db_path) as conn:
329+
assert conn.execute("PRAGMA user_version").fetchone()[0] == 99
330+
331+
332+
def _column_names(conn: sqlite3.Connection, table: str) -> list[str]:
333+
return [row[1] for row in conn.execute(f"PRAGMA table_info({table})")]
334+
335+
336+
def test_failing_migration_rolls_back_and_can_be_retried(
337+
tmp_path: Path,
338+
) -> None:
339+
conn = sqlite3.connect(tmp_path / "migrations.db")
340+
try:
341+
create = ("CREATE TABLE t (a TEXT)",)
342+
_apply_migrations(conn, [create])
343+
344+
with pytest.raises(sqlite3.OperationalError):
345+
_apply_migrations(
346+
conn,
347+
[
348+
create,
349+
("ALTER TABLE t ADD COLUMN b TEXT", "SELECT * FROM missing"),
350+
],
351+
)
352+
353+
assert conn.execute("PRAGMA user_version").fetchone()[0] == 1
354+
assert _column_names(conn, "t") == ["a"]
355+
356+
_apply_migrations(conn, [create, ("ALTER TABLE t ADD COLUMN b TEXT",)])
357+
358+
assert conn.execute("PRAGMA user_version").fetchone()[0] == 2
359+
assert _column_names(conn, "t") == ["a", "b"]
360+
finally:
361+
conn.close()
362+
363+
364+
def test_apply_migrations_skips_entries_already_applied(tmp_path: Path) -> None:
365+
conn = sqlite3.connect(tmp_path / "migrations.db")
366+
try:
367+
conn.execute("CREATE TABLE t (a TEXT)")
368+
conn.execute("PRAGMA user_version = 1")
369+
370+
# Entry 0 would fail if re-run because the table already exists.
371+
_apply_migrations(
372+
conn,
373+
[("CREATE TABLE t (a TEXT)",), ("ALTER TABLE t ADD COLUMN b TEXT",)],
374+
)
375+
376+
assert conn.execute("PRAGMA user_version").fetchone()[0] == 2
377+
assert _column_names(conn, "t") == ["a", "b"]
378+
finally:
379+
conn.close()
380+
381+
382+
def test_current_database_opens_while_another_writer_holds_lock(
383+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
384+
) -> None:
385+
db_path = tmp_path / "locked.db"
386+
exporter = SqliteSpanExporter(db_path=str(db_path))
387+
assert (
388+
exporter.export(
389+
[_create_span(attributes={"gen_ai.conversation.id": "session-lock"})]
390+
)
391+
== SpanExportResult.SUCCESS
392+
)
393+
exporter.shutdown()
394+
monkeypatch.setattr(
395+
"google.adk.telemetry.sqlite_span_exporter._DEFAULT_TIMEOUT_SECONDS",
396+
0.1,
397+
)
398+
399+
writer = sqlite3.connect(db_path, isolation_level=None)
400+
try:
401+
writer.execute("BEGIN IMMEDIATE")
402+
writer.execute("UPDATE spans SET name = 'uncommitted'")
403+
404+
reopened = SqliteSpanExporter(db_path=str(db_path))
405+
try:
406+
restored = reopened.get_all_spans_for_session("session-lock")
407+
assert [span.name for span in restored] == ["test_span"]
408+
finally:
409+
reopened.shutdown()
410+
finally:
411+
writer.rollback()
412+
writer.close()
413+
414+
230415
def test_spans_with_parent_context_exported_correctly(tmp_path):
231416
db_path = tmp_path / "test.db"
232417
exporter = SqliteSpanExporter(db_path=str(db_path))

0 commit comments

Comments
 (0)