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>
This commit is contained in:
adam-pond-agent
2026-07-12 10:36:00 +01:00
committed by safishamsi
co-authored by Claude Opus 4.8
parent 71d7ab9d71
commit da71f8264f
4 changed files with 62 additions and 4 deletions
+2
View File
@@ -4,6 +4,8 @@ Full release notes with details on each version: [GitHub Releases](https://githu
## 0.9.13 (unreleased)
- Fix: the query log is now opt-in (off by default) (#1797, thanks @adam-pond-agent). `querylog` wrote every `query`/`path`/`explain` question and corpus path (and full responses if `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, which contradicts graphify's on-device / no-telemetry posture. Logging is now OFF unless you opt in with `GRAPHIFY_QUERY_LOG_ENABLE=1` (default path) or `GRAPHIFY_QUERY_LOG=<path>`; `GRAPHIFY_QUERY_LOG_DISABLE=1` still forces it off. All the query-log env vars are now documented in the README.
- Fix: a markdown file that went through semantic extraction is no longer duplicated into two disconnected nodes on later `graphify update` (#1799, thanks @jerp86). The semantic pass mints `<slug>_doc` while the markdown quick-scan mints the bare `<slug>`, so the file's edges split across two twins (a docs->code path query would dead-end on the bare half; centrality and communities split too). `build_from_json` now merges the bare quick-scan node into the semantic `_doc` node when both share the same `source_file` and are `file_type: document`, consolidating their edges/hyperedges onto one node. Gated so an unrelated code symbol `foo` and `foo_doc` never merge.
- Fix: incremental `graphify update` no longer silently evicts nodes for a file that left the scan corpus but still exists on disk (#1795, thanks @CJNA). `_reconcile_existing_graph` read "source absent from the collected corpus" as "deleted", but that's also what an ignore-rule/filter change looks like (e.g. an upgrade that starts honoring `.gitignore`) — in one 27k-node graph the first rebuild after such an upgrade mass-evicted 655 nodes whose files were present the whole time. Eviction now fails closed: a corpus-absent source is only evicted when `Path(identity).exists()` is False (true deletion), otherwise its nodes/edges/hyperedges are preserved and a loud line reports how many were kept and why. True deletions and renames evict as before; a full `extract --force` still purges deliberate exclusions.
+4 -3
View File
@@ -513,9 +513,10 @@ These are only needed for **headless / CI extraction** (`graphify extract`). Whe
| `GRAPHIFY_GOOGLE_WORKSPACE` | Auto-enable Google Workspace export | optional — set to `1` |
| `GRAPHIFY_TRIAGE_BACKEND` | Backend for `graphify prs --triage` | optional — auto-detected from available keys |
| `GRAPHIFY_TRIAGE_MODEL` | Model override for triage | optional — e.g. `claude-opus-4-7` |
| `GRAPHIFY_QUERY_LOG` | Override query log path (default: `~/.cache/graphify-queries.log`) | optional — set to empty or `/dev/null` to silence |
| `GRAPHIFY_QUERY_LOG_DISABLE` | Set to `1` to disable query logging entirely | optional |
| `GRAPHIFY_QUERY_LOG_RESPONSES` | Set to `1` to also log full subgraph responses (off by default) | optional |
| `GRAPHIFY_QUERY_LOG_ENABLE` | Set to `1` to turn on the local query log at `~/.cache/graphify-queries.log` (records each query/path/explain question + corpus path). Off by default — nothing is written unless you opt in (#1797) | optional |
| `GRAPHIFY_QUERY_LOG` | Enable the query log and write it to this path instead of the default | optional — off unless this or `_ENABLE` is set |
| `GRAPHIFY_QUERY_LOG_DISABLE` | Set to `1` to force the query log off (wins over the enable vars) | optional |
| `GRAPHIFY_QUERY_LOG_RESPONSES` | When the log is enabled, also record full subgraph responses (off by default) | optional |
| `GRAPHIFY_MAX_GRAPH_BYTES` | Override the 512 MiB graph.json size cap — e.g. `700MB`, `2GB`, or plain bytes | optional — useful for very large corpora |
| `GRAPHIFY_LLM_TEMPERATURE` | Override LLM temperature for semantic extraction — e.g. `0.7`, or `none` to omit | optional — auto-omitted for o1/o3/o4/gpt-5 reasoning models |
+11 -1
View File
@@ -13,12 +13,22 @@ _NODES_RE = re.compile(r"(\d+)\s+nodes?\s+found")
def _log_path() -> Path | None:
# Opt-in only (#1797). The log records every query/path/explain question and
# corpus path (and full responses if GRAPHIFY_QUERY_LOG_RESPONSES) in a
# plaintext file under ~/.cache — outside any repo's .gitignore/retention. A
# default-on record of proprietary queries contradicts graphify's on-device,
# no-telemetry posture, so it is OFF unless explicitly enabled:
# GRAPHIFY_QUERY_LOG=<path> log to that path, or
# GRAPHIFY_QUERY_LOG_ENABLE=1 log to ~/.cache/graphify-queries.log.
# GRAPHIFY_QUERY_LOG_DISABLE=1 still forces it off (back-compat, wins).
if os.environ.get("GRAPHIFY_QUERY_LOG_DISABLE", "").lower() in ("1", "true", "yes"):
return None
override = os.environ.get("GRAPHIFY_QUERY_LOG", "").strip()
if override:
return Path(override).expanduser()
return Path.home() / ".cache" / "graphify-queries.log"
if os.environ.get("GRAPHIFY_QUERY_LOG_ENABLE", "").lower() in ("1", "true", "yes"):
return Path.home() / ".cache" / "graphify-queries.log"
return None
def _log_responses() -> bool:
+45
View File
@@ -176,3 +176,48 @@ def test_kind_mcp_query(tmp_path, monkeypatch):
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()