diff --git a/python/ktx-daemon/src/ktx_daemon/__main__.py b/python/ktx-daemon/src/ktx_daemon/__main__.py index e7ae779a..2fc00186 100644 --- a/python/ktx-daemon/src/ktx_daemon/__main__.py +++ b/python/ktx-daemon/src/ktx_daemon/__main__.py @@ -5,6 +5,7 @@ from __future__ import annotations import argparse import json import sys +import time from typing import Any from pydantic import ValidationError @@ -100,8 +101,12 @@ def run_http_server( from ktx_daemon.app import create_app + started_at = time.perf_counter() uvicorn.run( - create_app(enable_code_execution=enable_code_execution), + create_app( + enable_code_execution=enable_code_execution, + telemetry_started_at=started_at, + ), host=host, port=port, log_level=log_level, diff --git a/python/ktx-daemon/src/ktx_daemon/app.py b/python/ktx-daemon/src/ktx_daemon/app.py index 3208264c..7a3fa950 100644 --- a/python/ktx-daemon/src/ktx_daemon/app.py +++ b/python/ktx-daemon/src/ktx_daemon/app.py @@ -4,6 +4,9 @@ from __future__ import annotations import logging import os +import sys +import time +from contextlib import asynccontextmanager from collections.abc import Callable from typing import Any @@ -62,6 +65,7 @@ from ktx_daemon.table_identifier import ( ParseTableIdentifierBatchResponse, parse_table_identifier_response, ) +from ktx_daemon.telemetry import track_telemetry_event logger = logging.getLogger(__name__) @@ -81,11 +85,38 @@ def create_app( ] | None = None, enable_code_execution: bool = False, + telemetry_started_at: float | None = None, + clock: Callable[[], float] = time.perf_counter, ) -> FastAPI: + started_at = telemetry_started_at or clock() + + @asynccontextmanager + async def lifespan(_: FastAPI): + track_telemetry_event( + "daemon_started", + { + "daemonVersion": os.environ.get("KTX_DAEMON_VERSION", VERSION), + "pythonVersion": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", + "runtimeVersion": VERSION, + "startupDurationMs": max(0, (clock() - started_at) * 1000), + }, + ) + try: + yield + finally: + track_telemetry_event( + "daemon_stopped", + { + "reason": "request", + "uptimeMs": max(0, (clock() - started_at) * 1000), + }, + ) + app = FastAPI( title="KTX Daemon", description="Stateless portable compute server for KTX.", version=VERSION, + lifespan=lifespan, ) @app.get("/health") diff --git a/python/ktx-daemon/tests/test_app.py b/python/ktx-daemon/tests/test_app.py index 3c1ce18d..f87c7329 100644 --- a/python/ktx-daemon/tests/test_app.py +++ b/python/ktx-daemon/tests/test_app.py @@ -1,5 +1,8 @@ from __future__ import annotations +import json +from pathlib import Path + from fastapi.testclient import TestClient from ktx_daemon.app import create_app @@ -79,6 +82,41 @@ def test_health_endpoint_returns_managed_runtime_version(monkeypatch) -> None: assert response.json() == {"status": "healthy", "version": "0.2.0"} +def test_app_lifespan_emits_daemon_lifecycle_debug_events( + tmp_path: Path, + monkeypatch, + capsys, +) -> None: + from ktx_daemon.telemetry.identity import reset_identity_cache + + reset_identity_cache() + identity_path = tmp_path / ".ktx" / "telemetry.json" + identity_path.parent.mkdir(parents=True) + identity_path.write_text( + json.dumps( + { + "installId": "00000000-0000-4000-8000-000000000000", + "enabled": True, + "createdAt": "2026-05-22T14:33:02.000Z", + } + ) + + "\n", + encoding="utf-8", + ) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("KTX_TELEMETRY_DEBUG", "1") + monkeypatch.setenv("KTX_DAEMON_VERSION", "0.4.1") + + with TestClient( + create_app(telemetry_started_at=100.0, clock=lambda: 100.125) + ) as client: + assert client.get("/health").status_code == 200 + + captured = capsys.readouterr() + assert '"event": "daemon_started"' in captured.err + assert '"event": "daemon_stopped"' in captured.err + + def test_database_introspect_endpoint_returns_snapshot() -> None: calls = []