# SPDX-License-Identifier: Apache-2.0 """Tests for the anonymous usage telemetry package (lmcache/usage_telemetry/).""" # Standard from dataclasses import dataclass, field from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import json import threading import time # Third Party import pytest import torch # First Party from lmcache.usage_telemetry import ( USAGE_SCHEMA_VERSION, ContinuousUsageContext, InitializeMPUsageContext, InitializeUsageContext, MPServerMessage, MPUsageContext, UsageContext, UsageMessageSender, get_usage_identity, is_usage_tracking_enabled, ) from lmcache.usage_telemetry.guard import swallow_telemetry_errors from lmcache.v1.config import LMCacheEngineConfig from lmcache.v1.distributed.config import ( EvictionConfig, GdsL1Config, L1ManagerConfig, L1MemoryManagerConfig, StorageManagerConfig, ) from lmcache.v1.distributed.l2_adapters.config import ( L2AdaptersConfig, get_type_name_for_config, ) from lmcache.v1.metadata import LMCacheMetadata from lmcache.v1.multiprocess.config import MPServerConfig class RecordingSender(UsageMessageSender): """Transport stub that records payloads instead of POSTing them.""" def __init__(self) -> None: self.sent: list[tuple[str, dict[str, object]]] = [] def send(self, url: str, payload: dict[str, object]) -> None: self.sent.append((url, payload)) @dataclass class StubStats: """Minimal stand-in for LMCacheStats used by incr_or_send_stats.""" interval_hit_tokens: int = 0 interval_stored_tokens: int = 0 interval_request_cache_lifespan: list[float] = field(default_factory=list) class UsageSink(ThreadingHTTPServer): """In-process HTTP server recording every POSTed usage payload.""" def __init__(self) -> None: super().__init__(("127.0.0.1", 0), _UsageSinkHandler) self.received: list[tuple[str, dict[str, object]]] = [] def wait_for( self, count: int, timeout: float = 15.0 ) -> list[tuple[str, dict[str, object]]]: """Return received (path, payload) pairs once *count* arrived.""" deadline = time.monotonic() + timeout while time.monotonic() < deadline and len(self.received) < count: time.sleep(0.01) return list(self.received) class _UsageSinkHandler(BaseHTTPRequestHandler): def do_POST(self) -> None: length = int(self.headers.get("Content-Length", 0)) body = json.loads(self.rfile.read(length)) self.server.received.append((self.path, body)) # type: ignore[attr-defined] self.send_response(200) self.end_headers() def log_message(self, *args: object) -> None: pass @pytest.fixture def usage_env(monkeypatch, tmp_path): """Isolate usage-telemetry state: HOME, env vars, and singletons.""" monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("LMCACHE_USAGE_TRACK_URL", "http://stats.test") monkeypatch.delenv("LMCACHE_TRACK_USAGE", raising=False) monkeypatch.delenv("DO_NOT_TRACK", raising=False) monkeypatch.delenv("LMCACHE_USAGE_TRACK_INTERVAL", raising=False) monkeypatch.setattr("lmcache.usage_telemetry.identity._usage_identity", None) monkeypatch.setattr(ContinuousUsageContext, "_instance", None) return tmp_path @pytest.fixture def usage_sink(usage_env, monkeypatch): """Local HTTP sink; points LMCACHE_USAGE_TRACK_URL at it.""" sink = UsageSink() thread = threading.Thread(target=sink.serve_forever, daemon=True) thread.start() port = sink.server_address[1] monkeypatch.setenv("LMCACHE_USAGE_TRACK_URL", f"http://127.0.0.1:{port}") yield sink sink.shutdown() sink.server_close() def make_storage_manager_config() -> StorageManagerConfig: return StorageManagerConfig( l1_manager_config=L1ManagerConfig( memory_config=L1MemoryManagerConfig( size_in_bytes=1 << 30, use_lazy=False, shm_name="test_shm", ), ), eviction_config=EvictionConfig(eviction_policy="LRU"), ) def make_metadata() -> LMCacheMetadata: return LMCacheMetadata( model_name="test_model", world_size=1, local_world_size=1, worker_id=0, local_worker_id=0, kv_dtype=torch.bfloat16, kv_shape=(32, 2, 256, 32, 128), use_mla=False, role="worker", ) class TestOptOut: def test_enabled_by_default(self, usage_env): assert is_usage_tracking_enabled() def test_lmcache_track_usage_false(self, usage_env, monkeypatch): monkeypatch.setenv("LMCACHE_TRACK_USAGE", "false") assert not is_usage_tracking_enabled() @pytest.mark.parametrize("value", ["1", "true", "yes", "TRUE"]) def test_do_not_track_env(self, usage_env, monkeypatch, value): monkeypatch.setenv("DO_NOT_TRACK", value) assert not is_usage_tracking_enabled() def test_do_not_track_env_unset_values(self, usage_env, monkeypatch): monkeypatch.setenv("DO_NOT_TRACK", "0") assert is_usage_tracking_enabled() class TestIdentity: def test_identity_is_process_singleton(self, usage_env): first = get_usage_identity() second = get_usage_identity() assert first is second assert first.session_id assert first.machine_id def test_machine_id_persists_across_sessions(self, usage_env, monkeypatch): first = get_usage_identity() # Simulate a new process: the session singleton resets, the # machine_id file survives. monkeypatch.setattr("lmcache.usage_telemetry.identity._usage_identity", None) second = get_usage_identity() assert second.session_id != first.session_id assert second.machine_id == first.machine_id machine_id_file = usage_env / ".config" / "lmcache" / "machine_id" assert machine_id_file.read_text().strip() == first.machine_id def test_machine_id_empty_when_unwritable(self, usage_env): # Occupy the config-dir path with a regular file so the machine_id # file can be neither read nor created. (usage_env / ".config").mkdir() (usage_env / ".config" / "lmcache").touch() identity = get_usage_identity() assert identity.machine_id == "" assert identity.session_id class TestUsageContext: def test_report_once_sends_all_messages(self, usage_env): sender = RecordingSender() context = UsageContext( LMCacheEngineConfig.from_defaults(), make_metadata(), sender=sender, ) context.report_once() message_types = [payload["message_type"] for _, payload in sender.sent] assert message_types == ["EnvMessage", "EngineMessage", "MetadataMessage"] identity = get_usage_identity() for url, payload in sender.sent: assert url == "http://stats.test/context" assert payload["schema_version"] == USAGE_SCHEMA_VERSION assert payload["session_id"] == identity.session_id assert payload["machine_id"] == identity.machine_id assert payload["deployment_mode"] == "single_process" engine_payload = sender.sent[1][1] assert engine_payload["model_name"] == "test_model" assert engine_payload["kv_dtype"] == "torch.bfloat16" def test_local_log_written(self, usage_env, tmp_path): log_path = tmp_path / "usage.log" context = UsageContext( LMCacheEngineConfig.from_defaults(), make_metadata(), local_log=str(log_path), sender=RecordingSender(), ) context.report_once() content = log_path.read_text() assert "message_type: EnvMessage" in content assert "message_type: EngineMessage" in content assert "session_id:" in content class TestInitializeUsageContext: def test_returns_none_when_disabled(self, usage_env, monkeypatch): monkeypatch.setenv("LMCACHE_TRACK_USAGE", "false") context = InitializeUsageContext( LMCacheEngineConfig.from_defaults(), make_metadata() ) assert context is None def test_reports_from_background_thread(self, usage_env): sender = RecordingSender() context = InitializeUsageContext( LMCacheEngineConfig.from_defaults(), make_metadata(), sender=sender ) assert context is not None deadline = time.monotonic() + 10 while time.monotonic() < deadline and len(sender.sent) < 3: time.sleep(0.01) assert len(sender.sent) == 3 class TestContinuousUsageContext: def test_flush_and_reset(self, usage_env, monkeypatch): monkeypatch.setenv("LMCACHE_USAGE_TRACK_INTERVAL", "0") sender = RecordingSender() context = ContinuousUsageContext(make_metadata(), sender=sender) context.incr_or_send_stats( StubStats( interval_hit_tokens=100, interval_stored_tokens=200, interval_request_cache_lifespan=[0.5, 2.0], ) ) assert len(sender.sent) == 2 usage_url, usage_payload = sender.sent[0] assert usage_url.endswith("cache-usage") assert usage_payload["message_type"] == "ContinuousContextMessage" assert usage_payload["interval_num_hit_tokens"] == 100 assert usage_payload["interval_num_stored_tokens"] == 200 assert usage_payload["sequence_number"] == 1 assert usage_payload["session_id"] == get_usage_identity().session_id assert usage_payload["deployment_mode"] == "single_process" lifespan_url, lifespan_payload = sender.sent[1] assert lifespan_url.endswith("cache-lifespan") assert lifespan_payload["message_type"] == "CacheLifespanMessage" assert lifespan_payload["sequence_number"] == 1 # Counters reset after the flush; a second flush reports zeros with # the next sequence number. context.incr_or_send_stats(StubStats()) assert sender.sent[2][1]["interval_num_hit_tokens"] == 0 assert sender.sent[2][1]["sequence_number"] == 2 def test_disabled_is_noop(self, usage_env, monkeypatch): monkeypatch.setenv("LMCACHE_TRACK_USAGE", "false") monkeypatch.setenv("LMCACHE_USAGE_TRACK_INTERVAL", "0") sender = RecordingSender() context = ContinuousUsageContext(make_metadata(), sender=sender) context.incr_or_send_stats( StubStats( interval_hit_tokens=100, interval_request_cache_lifespan=[1.0], ) ) assert sender.sent == [] assert context.cache_lifespan_data == [] def test_list_to_histogram(self, usage_env): context = ContinuousUsageContext(make_metadata(), sender=RecordingSender()) histogram = context.list_to_histogram([0.5, 2.0, 3.0], [0, 1, 5, 10]) assert histogram == {0: 0, 1: 1, 5: 2, 10: 0} class TestMPUsage: def test_mp_server_message_from_configs(self, usage_env): message = MPServerMessage.from_configs( MPServerConfig(), make_storage_manager_config() ) assert message.chunk_size == 256 assert message.hash_algorithm == "blake3" assert message.engine_type == "default" assert message.supported_transfer_mode == "auto" assert not message.p2p_enabled assert message.l1_size_bytes == 1 << 30 assert message.l1_medium == "dram" assert message.l1_shm_enabled assert message.eviction_policy == "LRU" assert message.l2_adapter_types == "" assert message.l2_serde_types == "" assert message.l2_store_policy == "default" assert message.l2_prefetch_policy == "default" assert not message.enable_segmented_prefix assert message.lmcache_version def test_mp_server_message_serde_and_segmented_prefix(self, usage_env, tmp_path): # The FS adapter needs the native storage ops extension; skip on # builds without it. fs_l2_adapter = pytest.importorskip( "lmcache.v1.distributed.l2_adapters.fs_l2_adapter", reason="requires lmcache.native_storage_ops", ) serde = pytest.importorskip( "lmcache.v1.distributed.serde", reason="requires lmcache.native_storage_ops", ) fs_config = fs_l2_adapter.FSL2AdapterConfig( base_path=str(tmp_path), relative_tmp_dir=None, read_ahead_size=None, use_odirect=False, ) fs_config.serde_config = serde.SerdeConfig(type="fp8", kwargs={}) storage_config = make_storage_manager_config() storage_config.l2_adapter_config = L2AdaptersConfig([fs_config]) message = MPServerMessage.from_configs( MPServerConfig(enable_segmented_prefix=True), storage_config ) assert message.enable_segmented_prefix assert message.l2_adapter_types == get_type_name_for_config(fs_config) assert message.l2_serde_types == "fp8" def test_mp_server_message_gds_l1(self, usage_env, tmp_path): storage_config = StorageManagerConfig( l1_manager_config=L1ManagerConfig( memory_config=L1MemoryManagerConfig( size_in_bytes=1 << 30, use_lazy=False, shm_name="", ), gds_l1_config=GdsL1Config( file_location=str(tmp_path), size_in_bytes=2 << 30 ), ), eviction_config=EvictionConfig(eviction_policy="LRU"), ) message = MPServerMessage.from_configs(MPServerConfig(), storage_config) assert message.l1_medium == "gds" assert message.l1_size_bytes == 2 << 30 assert not message.l1_shm_enabled def test_mp_usage_context_sends_messages(self, usage_env): sender = RecordingSender() context = MPUsageContext( MPServerConfig(), make_storage_manager_config(), sender=sender, ) context.report_once() message_types = [payload["message_type"] for _, payload in sender.sent] assert message_types == ["EnvMessage", "MPServerMessage"] identity = get_usage_identity() for _, payload in sender.sent: assert payload["schema_version"] == USAGE_SCHEMA_VERSION assert payload["session_id"] == identity.session_id assert payload["deployment_mode"] == "mp_server" mp_payload = sender.sent[1][1] assert mp_payload["chunk_size"] == 256 assert "instance_id" not in mp_payload def test_initialize_mp_returns_none_when_disabled(self, usage_env, monkeypatch): monkeypatch.setenv("DO_NOT_TRACK", "1") context = InitializeMPUsageContext( MPServerConfig(), make_storage_manager_config() ) assert context is None class RaisingSender(UsageMessageSender): """Transport stub whose every send raises.""" def send(self, url: str, payload: dict[str, object]) -> None: raise RuntimeError("telemetry transport failure") class TestFailureIsolation: def test_swallow_telemetry_errors_returns_none(self): @swallow_telemetry_errors def boom() -> int: raise ValueError("telemetry bug") assert boom() is None def test_raising_sender_does_not_break_mp_report(self, usage_env): context = MPUsageContext( MPServerConfig(), make_storage_manager_config(), sender=RaisingSender() ) context.report_once() def test_raising_sender_does_not_break_continuous_flush( self, usage_env, monkeypatch ): monkeypatch.setenv("LMCACHE_USAGE_TRACK_INTERVAL", "0") context = ContinuousUsageContext(make_metadata(), sender=RaisingSender()) context.incr_or_send_stats(StubStats(interval_hit_tokens=1)) def test_unwritable_local_log_does_not_break_report(self, usage_env, tmp_path): context = UsageContext( LMCacheEngineConfig.from_defaults(), make_metadata(), local_log=str(tmp_path), # a directory: open() for append fails sender=RecordingSender(), ) context.report_once() def test_default_sender_swallows_unreachable_server(self, usage_env): # Port 1 is never listening; the send must not raise. UsageMessageSender().send("http://127.0.0.1:1/context", {"k": "v"}) def test_nonstandard_kv_shape_degrades_to_zero_bytes(self, usage_env, monkeypatch): monkeypatch.setenv("LMCACHE_USAGE_TRACK_INTERVAL", "0") metadata = make_metadata() metadata.kv_shape = () sender = RecordingSender() context = ContinuousUsageContext(metadata, sender=sender) context.incr_or_send_stats(StubStats(interval_stored_tokens=100)) assert sender.sent[0][1]["interval_stored_kv_size"] == 0 class TestSingletons: def test_get_or_create_returns_same_instance(self, usage_env): metadata = make_metadata() first = ContinuousUsageContext.GetOrCreate(metadata) second = ContinuousUsageContext.GetOrCreate(metadata) assert first is second def test_get_or_create_keeps_first_instance_on_metadata_mismatch(self, usage_env): first = ContinuousUsageContext.GetOrCreate(make_metadata()) other_metadata = make_metadata() other_metadata.model_name = "another_model" assert ContinuousUsageContext.GetOrCreate(other_metadata) is first class TestEndToEnd: """Exercise the real HTTP transport against a local sink (no stubs).""" def test_single_process_report(self, usage_sink): context = InitializeUsageContext( LMCacheEngineConfig.from_defaults(), make_metadata() ) assert context is not None received = usage_sink.wait_for(3) assert len(received) == 3 assert {path for path, _ in received} == {"/context"} assert [payload["message_type"] for _, payload in received] == [ "EnvMessage", "EngineMessage", "MetadataMessage", ] for _, payload in received: assert payload["deployment_mode"] == "single_process" def test_mp_report(self, usage_sink): context = InitializeMPUsageContext( MPServerConfig(), make_storage_manager_config() ) assert context is not None received = usage_sink.wait_for(2) assert [payload["message_type"] for _, payload in received] == [ "EnvMessage", "MPServerMessage", ] for path, payload in received: assert path == "/context" assert payload["deployment_mode"] == "mp_server" assert len({payload["session_id"] for _, payload in received}) == 1 def test_continuous_report(self, usage_sink, monkeypatch): monkeypatch.setenv("LMCACHE_USAGE_TRACK_INTERVAL", "0") context = ContinuousUsageContext(make_metadata()) context.incr_or_send_stats( StubStats( interval_hit_tokens=7, interval_stored_tokens=9, interval_request_cache_lifespan=[1.0], ) ) received = usage_sink.wait_for(2) assert [path for path, _ in received] == ["/cache-usage", "/cache-lifespan"] assert received[0][1]["interval_num_hit_tokens"] == 7 # Histogram keys are bucket bounds (stringified by JSON); the 1.0 s # sample falls in the [1, 5) bin, keyed "5". assert received[1][1]["cache_lifespan_histogram"]["5"] == 1