Files
adam-pond-agent da71f8264f fix(querylog): make the query log opt-in, off by default (#1797)
querylog wrote every query/path/explain question + corpus path (and full
responses under GRAPHIFY_QUERY_LOG_RESPONSES) to a default-on, unbounded,
fail-silent plaintext file at ~/.cache/graphify-queries.log — outside any repo's
.gitignore/retention, and undocumented. A default-on plaintext record of
proprietary queries contradicts graphify's on-device / no-telemetry posture.

Flip to opt-in: _log_path() returns None unless GRAPHIFY_QUERY_LOG_ENABLE=1
(default path) or GRAPHIFY_QUERY_LOG=<path> is set; GRAPHIFY_QUERY_LOG_DISABLE=1
still forces it off (back-compat, wins). Document all four env vars in the
README (the old entries implied default-on). Regression tests cover
default-off, both enable paths, disable-wins, and that log_query writes nothing
without opt-in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 10:36:00 +01:00

224 lines
7.8 KiB
Python

"""Tests for graphify.querylog."""
import json
import os
import pytest
from pathlib import Path
from graphify.querylog import log_query, nodes_from_result
# ---------------------------------------------------------------------------
# nodes_from_result
# ---------------------------------------------------------------------------
def test_nodes_from_result_parses_header():
result = "Traversal: BFS depth=2 | Start: ['foo'] | 7 nodes found\n\nNODE foo"
assert nodes_from_result(result) == 7
def test_nodes_from_result_singular():
assert nodes_from_result("1 node found") == 1
def test_nodes_from_result_missing():
assert nodes_from_result("no match here") is None
def test_nodes_from_result_empty():
assert nodes_from_result("") is None
# ---------------------------------------------------------------------------
# log_query — basic write
# ---------------------------------------------------------------------------
def test_log_query_writes_jsonl(tmp_path, monkeypatch):
log_file = tmp_path / "q.log"
monkeypatch.setenv("GRAPHIFY_QUERY_LOG", str(log_file))
monkeypatch.delenv("GRAPHIFY_QUERY_LOG_DISABLE", raising=False)
log_query(kind="query", question="what is X", corpus="/some/graph.json",
result="3 nodes found\nNODE a", duration_ms=12.5, mode="bfs", depth=2)
lines = log_file.read_text().splitlines()
assert len(lines) == 1
rec = json.loads(lines[0])
assert rec["kind"] == "query"
assert rec["question"] == "what is X"
assert rec["corpus"] == "/some/graph.json"
assert rec["nodes_returned"] == 3
assert rec["result_chars"] > 0
assert rec["duration_ms"] == pytest.approx(12.5, abs=0.01)
assert rec["mode"] == "bfs"
assert "ts" in rec
def test_log_query_appends(tmp_path, monkeypatch):
log_file = tmp_path / "q.log"
monkeypatch.setenv("GRAPHIFY_QUERY_LOG", str(log_file))
monkeypatch.delenv("GRAPHIFY_QUERY_LOG_DISABLE", raising=False)
log_query(kind="query", question="q1", corpus="/g.json")
log_query(kind="query", question="q2", corpus="/g.json")
lines = log_file.read_text().splitlines()
assert len(lines) == 2
assert json.loads(lines[0])["question"] == "q1"
assert json.loads(lines[1])["question"] == "q2"
# ---------------------------------------------------------------------------
# opt-out / opt-in
# ---------------------------------------------------------------------------
def test_disable_env(tmp_path, monkeypatch):
log_file = tmp_path / "q.log"
monkeypatch.setenv("GRAPHIFY_QUERY_LOG", str(log_file))
monkeypatch.setenv("GRAPHIFY_QUERY_LOG_DISABLE", "1")
log_query(kind="query", question="q", corpus="/g.json")
assert not log_file.exists()
def test_disable_env_true(tmp_path, monkeypatch):
log_file = tmp_path / "q.log"
monkeypatch.setenv("GRAPHIFY_QUERY_LOG", str(log_file))
monkeypatch.setenv("GRAPHIFY_QUERY_LOG_DISABLE", "true")
log_query(kind="query", question="q", corpus="/g.json")
assert not log_file.exists()
def test_responses_not_logged_by_default(tmp_path, monkeypatch):
log_file = tmp_path / "q.log"
monkeypatch.setenv("GRAPHIFY_QUERY_LOG", str(log_file))
monkeypatch.delenv("GRAPHIFY_QUERY_LOG_DISABLE", raising=False)
monkeypatch.delenv("GRAPHIFY_QUERY_LOG_RESPONSES", raising=False)
log_query(kind="query", question="q", corpus="/g.json", result="NODE foo")
rec = json.loads(log_file.read_text())
assert "response" not in rec
def test_responses_optin(tmp_path, monkeypatch):
log_file = tmp_path / "q.log"
monkeypatch.setenv("GRAPHIFY_QUERY_LOG", str(log_file))
monkeypatch.setenv("GRAPHIFY_QUERY_LOG_RESPONSES", "1")
monkeypatch.delenv("GRAPHIFY_QUERY_LOG_DISABLE", raising=False)
log_query(kind="query", question="q", corpus="/g.json", result="NODE foo bar")
rec = json.loads(log_file.read_text())
assert rec["response"] == "NODE foo bar"
# ---------------------------------------------------------------------------
# robustness — never raises
# ---------------------------------------------------------------------------
def test_log_never_raises(tmp_path, monkeypatch):
# Point at a directory — open() for append will fail
bad_path = tmp_path / "is_a_dir"
bad_path.mkdir()
monkeypatch.setenv("GRAPHIFY_QUERY_LOG", str(bad_path))
monkeypatch.delenv("GRAPHIFY_QUERY_LOG_DISABLE", raising=False)
# Must not raise
log_query(kind="query", question="q", corpus="/g.json")
def test_log_creates_parent_dirs(tmp_path, monkeypatch):
log_file = tmp_path / "deep" / "nested" / "q.log"
monkeypatch.setenv("GRAPHIFY_QUERY_LOG", str(log_file))
monkeypatch.delenv("GRAPHIFY_QUERY_LOG_DISABLE", raising=False)
log_query(kind="query", question="q", corpus="/g.json")
assert log_file.exists()
# ---------------------------------------------------------------------------
# field coverage
# ---------------------------------------------------------------------------
def test_nodes_returned_inferred_from_result(tmp_path, monkeypatch):
log_file = tmp_path / "q.log"
monkeypatch.setenv("GRAPHIFY_QUERY_LOG", str(log_file))
monkeypatch.delenv("GRAPHIFY_QUERY_LOG_DISABLE", raising=False)
log_query(kind="query", question="q", corpus="/g.json",
result="5 nodes found\nNODE a\nNODE b")
rec = json.loads(log_file.read_text())
assert rec["nodes_returned"] == 5
def test_explicit_nodes_returned_takes_precedence(tmp_path, monkeypatch):
log_file = tmp_path / "q.log"
monkeypatch.setenv("GRAPHIFY_QUERY_LOG", str(log_file))
monkeypatch.delenv("GRAPHIFY_QUERY_LOG_DISABLE", raising=False)
log_query(kind="path", question="A -> B", corpus="/g.json", nodes_returned=3)
rec = json.loads(log_file.read_text())
assert rec["nodes_returned"] == 3
def test_kind_mcp_query(tmp_path, monkeypatch):
log_file = tmp_path / "q.log"
monkeypatch.setenv("GRAPHIFY_QUERY_LOG", str(log_file))
monkeypatch.delenv("GRAPHIFY_QUERY_LOG_DISABLE", raising=False)
log_query(kind="mcp_query", question="q", corpus="/g.json")
rec = json.loads(log_file.read_text())
assert rec["kind"] == "mcp_query"
# ---------------------------------------------------------------------------
# #1797 — query log is opt-in (default OFF)
# ---------------------------------------------------------------------------
def _clear_log_env(monkeypatch):
for k in ("GRAPHIFY_QUERY_LOG", "GRAPHIFY_QUERY_LOG_ENABLE", "GRAPHIFY_QUERY_LOG_DISABLE"):
monkeypatch.delenv(k, raising=False)
def test_query_log_off_by_default(monkeypatch):
from graphify.querylog import _log_path
_clear_log_env(monkeypatch)
assert _log_path() is None
def test_query_log_enabled_by_explicit_flag(monkeypatch):
from graphify.querylog import _log_path
_clear_log_env(monkeypatch)
monkeypatch.setenv("GRAPHIFY_QUERY_LOG_ENABLE", "1")
assert str(_log_path()).endswith("graphify-queries.log")
def test_query_log_enabled_by_explicit_path(monkeypatch, tmp_path):
from graphify.querylog import _log_path
_clear_log_env(monkeypatch)
monkeypatch.setenv("GRAPHIFY_QUERY_LOG", str(tmp_path / "q.log"))
assert _log_path() == tmp_path / "q.log"
def test_query_log_disable_wins(monkeypatch):
from graphify.querylog import _log_path
_clear_log_env(monkeypatch)
monkeypatch.setenv("GRAPHIFY_QUERY_LOG_ENABLE", "1")
monkeypatch.setenv("GRAPHIFY_QUERY_LOG_DISABLE", "1")
assert _log_path() is None
def test_log_query_writes_nothing_by_default(monkeypatch, tmp_path):
"""End-to-end: with no opt-in, log_query must not create the default log."""
_clear_log_env(monkeypatch)
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
log_query(kind="query", question="secret internal ticket TICKET-123", corpus=".", result="1 node found")
assert not (tmp_path / ".cache" / "graphify-queries.log").exists()