1616
1717import json
1818from pathlib import Path
19+ import sqlite3
1920
21+ from google .adk .telemetry .sqlite_span_exporter import _apply_migrations
2022from google .adk .telemetry .sqlite_span_exporter import SqliteSpanExporter
2123from opentelemetry .sdk .trace import ReadableSpan
2224from opentelemetry .sdk .trace .export import SpanExportResult
2325from opentelemetry .trace import SpanContext
26+ from opentelemetry .trace import SpanKind
2427from opentelemetry .trace import TraceFlags
2528from opentelemetry .trace import TraceState
29+ import pytest
2630
2731
2832def _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+
230415def 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