mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-13 08:15:14 +02:00
feat(telemetry): collect PostHog $exception error reports in CLI and daemon (#262)
* feat(telemetry): add node exception reporter * feat(telemetry): report node cli exceptions * feat(telemetry): add daemon exception reporter * feat(telemetry): report daemon exceptions * docs(telemetry): document error reports * fix(telemetry): pass redaction snapshots from node call sites * test(telemetry): verify prepared node exception payload * fix(telemetry): close daemon exception lifecycle gaps * test(telemetry): verify prepared daemon exception payload * test(telemetry): close error collection acceptance gaps * test(telemetry): close posthog exception acceptance gaps
This commit is contained in:
parent
c3d8cedb0b
commit
fb7b94b60e
36 changed files with 2870 additions and 140 deletions
118
python/ktx-daemon/tests/test_exception_payload.py
Normal file
118
python/ktx-daemon/tests/test_exception_payload.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import json
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ktx_daemon.telemetry.identity import reset_identity_cache
|
||||
|
||||
|
||||
class CaptureHandler(BaseHTTPRequestHandler):
|
||||
payloads: list[dict[str, Any]] = []
|
||||
|
||||
def do_POST(self) -> None:
|
||||
length = int(self.headers.get("content-length", "0"))
|
||||
raw = self.rfile.read(length)
|
||||
if self.headers.get("content-encoding") == "gzip":
|
||||
raw = gzip.decompress(raw)
|
||||
self.payloads.append(json.loads(raw.decode("utf-8")))
|
||||
self.send_response(200)
|
||||
self.send_header("content-type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(b"{}")
|
||||
|
||||
def log_message(self, _format: str, *_args: object) -> None:
|
||||
return
|
||||
|
||||
|
||||
def write_identity(home: Path) -> None:
|
||||
target = home / ".ktx" / "telemetry.json"
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"installId": "00000000-0000-4000-8000-000000000000",
|
||||
"enabled": True,
|
||||
"createdAt": "2026-06-05T00:00:00.000Z",
|
||||
}
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def find_exception_event(payloads: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
for payload in payloads:
|
||||
batch = payload.get("batch")
|
||||
events = batch if isinstance(batch, list) else [payload]
|
||||
for event in events:
|
||||
if isinstance(event, dict) and event.get("event") == "$exception":
|
||||
return event
|
||||
raise AssertionError(f"No $exception payload found: {payloads}")
|
||||
|
||||
|
||||
def test_prepared_python_exception_payload_groups_and_redacts(tmp_path: Path) -> None:
|
||||
from ktx_daemon.telemetry.exception import report_exception
|
||||
|
||||
reset_identity_cache()
|
||||
write_identity(tmp_path)
|
||||
CaptureHandler.payloads.clear()
|
||||
server = HTTPServer(("127.0.0.1", 0), CaptureHandler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
snapshot_secret = "-".join(["plain", "secret", "value"])
|
||||
db_password = "-".join(["db", "url", "secret"])
|
||||
auth_token = "".join(["abc", "123"])
|
||||
report_exception(
|
||||
RuntimeError(
|
||||
f"{snapshot_secret} postgres://svc:{db_password}@db.example.test/analytics "
|
||||
f"Authorization: Basic {auth_token}"
|
||||
),
|
||||
source="database-introspect",
|
||||
handled=True,
|
||||
fatal=False,
|
||||
project_id="a" * 64,
|
||||
home_dir=tmp_path,
|
||||
env={"KTX_TELEMETRY_ENDPOINT": f"http://127.0.0.1:{server.server_port}"},
|
||||
redaction_secrets=[snapshot_secret],
|
||||
)
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=2)
|
||||
|
||||
event = find_exception_event(CaptureHandler.payloads)
|
||||
properties = event["properties"]
|
||||
assert event.get("$groups") == {"project": "a" * 64} or properties.get(
|
||||
"$groups"
|
||||
) == {"project": "a" * 64}
|
||||
serialized = json.dumps(properties.get("$exception_list", []))
|
||||
assert "[redacted]" in serialized
|
||||
assert snapshot_secret not in serialized
|
||||
assert db_password not in serialized
|
||||
assert auth_token not in serialized
|
||||
forbidden_keys = {
|
||||
"argv",
|
||||
"args",
|
||||
"env",
|
||||
"environment",
|
||||
"sql",
|
||||
"query",
|
||||
"prompt",
|
||||
"mcpArguments",
|
||||
"tableName",
|
||||
"schemaName",
|
||||
"columnName",
|
||||
"databaseUrl",
|
||||
"connectionString",
|
||||
"url",
|
||||
"password",
|
||||
"token",
|
||||
"apiKey",
|
||||
"authorization",
|
||||
}
|
||||
assert forbidden_keys.isdisjoint(properties.keys())
|
||||
Loading…
Add table
Add a link
Reference in a new issue