Files
basicmachines-co-basic-memory/tests/sync/test_sync_service_telemetry.py
T
2026-04-16 18:04:17 -05:00

220 lines
7.3 KiB
Python

"""Telemetry coverage for sync phase spans."""
from __future__ import annotations
import importlib
from contextlib import contextmanager
from pathlib import Path
from textwrap import dedent
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from basic_memory.sync.sync_service import SyncReport
batch_indexer_module = importlib.import_module("basic_memory.indexing.batch_indexer")
sync_service_module = importlib.import_module("basic_memory.sync.sync_service")
def _capture_sync_telemetry():
operations: list[tuple[str, dict]] = []
spans: list[tuple[str, dict]] = []
@contextmanager
def fake_operation(name: str, **attrs):
operations.append((name, attrs))
yield
@contextmanager
def fake_span(name: str, **attrs):
spans.append((name, attrs))
yield
return operations, spans, fake_operation, fake_span
def _write_markdown(project_root: Path, relative_path: str, content: str) -> Path:
"""Create one markdown file under the test project."""
file_path = project_root / relative_path
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content, encoding="utf-8")
return file_path
@pytest.mark.asyncio
async def test_sync_emits_phase_spans(sync_service, project_config, monkeypatch) -> None:
operations, spans, fake_operation, fake_span = _capture_sync_telemetry()
report = SyncReport(
new={"new.md"},
modified={"modified.md"},
deleted={"deleted.md"},
moves={"old.md": "moved.md"},
)
async def fake_scan(directory, force_full=False):
return report
async def fake_handle_move(old_path, new_path):
return None
async def fake_handle_delete(path):
return None
async def fake_index_changed_files(changed_paths, checksums_by_path, progress_callback=None):
return [], []
async def fake_resolve_relations(entity_id=None):
return set()
async def fake_quick_count_files(directory):
return 3
async def fake_find_by_id(project_id):
return SimpleNamespace(id=project_id)
async def fake_update(project_id, values):
return None
monkeypatch.setattr(sync_service_module.telemetry, "operation", fake_operation)
monkeypatch.setattr(sync_service_module.telemetry, "span", fake_span)
monkeypatch.setattr(sync_service, "scan", fake_scan)
monkeypatch.setattr(sync_service, "handle_move", fake_handle_move)
monkeypatch.setattr(sync_service, "handle_delete", fake_handle_delete)
monkeypatch.setattr(sync_service, "_index_changed_files", fake_index_changed_files)
monkeypatch.setattr(sync_service, "resolve_relations", fake_resolve_relations)
monkeypatch.setattr(sync_service, "_quick_count_files", fake_quick_count_files)
monkeypatch.setattr(sync_service.project_repository, "find_by_id", fake_find_by_id)
monkeypatch.setattr(sync_service.project_repository, "update", fake_update)
sync_service.app_config.semantic_search_enabled = False
result = await sync_service.sync(
project_config.home,
project_name=project_config.name,
force_full=True,
)
assert result is report
assert operations == [
(
"sync.project.run",
{"project_name": project_config.name, "force_full": True},
)
]
assert [name for name, _ in spans] == [
"sync.project.scan",
"sync.project.apply_changes",
"sync.project.resolve_relations",
"sync.project.update_watermark",
]
@pytest.mark.asyncio
async def test_sync_one_markdown_file_emits_index_phase_spans(
sync_service, test_project, monkeypatch
) -> None:
_, spans, _, fake_span = _capture_sync_telemetry()
monkeypatch.setattr(batch_indexer_module.telemetry, "span", fake_span)
monkeypatch.setattr(sync_service.search_service, "index_entity_data", AsyncMock())
_write_markdown(
Path(test_project.path),
"notes/telemetry.md",
dedent(
f"""\
---
title: Telemetry Note
type: note
permalink: {test_project.name}/notes/telemetry
---
# Telemetry Note
Body content.
"""
),
)
result = await sync_service.sync_one_markdown_file("notes/telemetry.md")
index_spans = [(name, attrs) for name, attrs in spans if name.startswith("index.markdown_file")]
assert [name for name, _ in index_spans] == [
"index.markdown_file.prepare",
"index.markdown_file.load_permalink_map",
"index.markdown_file.normalize",
"index.markdown_file.persist",
"index.markdown_file.reload_entity",
]
assert index_spans[0][1] == {"path": "notes/telemetry.md"}
assert index_spans[3][1] == {"path": "notes/telemetry.md", "is_new": True}
assert index_spans[4][1]["entity_id"] == result.entity.id
@pytest.mark.asyncio
async def test_sync_file_emits_failure_span(sync_service, monkeypatch) -> None:
_, spans, _, fake_span = _capture_sync_telemetry()
recorded_failures: list[tuple[str, str]] = []
async def fake_record_failure(path, error):
recorded_failures.append((path, error))
async def fail_sync_markdown_file(path, new=True):
raise ValueError("boom")
monkeypatch.setattr(sync_service_module.telemetry, "span", fake_span)
monkeypatch.setattr(sync_service, "_should_skip_file", lambda path: _false_async())
monkeypatch.setattr(sync_service.file_service, "is_markdown", lambda path: True)
monkeypatch.setattr(sync_service, "sync_markdown_file", fail_sync_markdown_file)
monkeypatch.setattr(sync_service, "_record_failure", fake_record_failure)
result = await sync_service.sync_file("notes/broken.md", new=True)
assert result == (None, None)
assert spans == [
(
"sync.file.failure",
{
"failure_type": "ValueError",
"path": "notes/broken.md",
"file_kind": "markdown",
"is_new": True,
"is_fatal": False,
},
)
]
assert recorded_failures == [("notes/broken.md", "boom")]
@pytest.mark.asyncio
async def test_sync_file_logs_slow_operation(sync_service, monkeypatch) -> None:
warning_messages: list[str] = []
async def fake_sync_regular_file(path, new=True):
return SimpleNamespace(id=1), "deadbeef"
times = iter([10.0, 10.8])
monkeypatch.setattr(sync_service, "_should_skip_file", lambda path: _false_async())
monkeypatch.setattr(sync_service.file_service, "is_markdown", lambda path: False)
monkeypatch.setattr(sync_service, "sync_regular_file", fake_sync_regular_file)
monkeypatch.setattr(sync_service.search_service, "index_entity", _none_async)
monkeypatch.setattr(sync_service, "_clear_failure", lambda path: None)
monkeypatch.setattr(sync_service_module.time, "time", lambda: next(times))
monkeypatch.setattr(sync_service_module.logger, "warning", warning_messages.append)
entity, checksum = await sync_service.sync_file("assets/large.bin", new=False)
assert entity.id == 1
assert checksum == "deadbeef"
assert warning_messages == [
"Slow file sync detected: path=assets/large.bin, file_kind=regular, duration_ms=800"
]
async def _false_async() -> bool:
return False
async def _none_async(*args, **kwargs) -> None:
return None