ENG-2055: stop the test suite sending real analytics to production - #412
Conversation
`os.environ.setdefault("ANTON_ANALYTICS_ENABLED", "false")` writes only when
the key is absent, so a developer with that variable exported — which is
exactly what someone working on an analytics ticket sets — cancelled the
guard silently, and the suite shipped real events to production PostHog.
Measured against a local capture server on origin/staging: one run with the
variable exported emits 259 events (tool_completed 149, ds_connect_* 89,
ask_user_* 16, turn_completed 5). With the assignment: zero. All 2,709 tests
pass either way, so the guard was doing nothing a test would notice.
This is the source of the ~70% contamination in turn_completed. The fake rows
carry `planning_model = "<AsyncMock name='mock.planning_model' id=...>"`, and
counted per day the AsyncMock rows are the script-shaped rows (2026-08-27:
2,147 vs 2,147).
ENG-1692's script-traffic guard does not cover this: it lives inside
_emit_turn_cost alone, so three of those four families have no guard, and it
only takes effect once a developer updates their installed build.
Nothing loses coverage — the tests that exercise the analytics layer build
their own settings objects and never read the environment, and
tests/e2e/harness.py already sets the variable explicitly per subprocess.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
alecantu7
left a comment
There was a problem hiding this comment.
Adversarial self-review (ENG-2055)
Reviewed at head 009c5957 against fresh origin/staging (87918945). Full suite run twice on this branch — with the fix: 2,709 passed, 0 events; mutated back to setdefault: 2,709 passed, 259 events. Writer inventory for ANTON_ANALYTICS_ENABLED run across all seven repos in the workspace.
Verdict: safe to merge on correctness. One finding worth fixing first (inline, on conftest.py), plus three corrections to my own PR body below.
What survived refutation
F2 — the body understates the emitter surface. The Why section says "three of the four event families are unprotected". anton actually has 15 send_event call sites across 9 files, ~10 distinct event names, of which four route to the direct PostHog sink: turn_completed, tool_completed, rule_retrieval, scratchpad_package_installed. My capture run observed four names; rule_retrieval (cortex.py:89) and scratchpad_package_installed (core/utils/scratchpad.py:100) never fired — not because they are guarded, but because their test paths pass settings objects that fail inside send_event's own try/except before sending. 259 is a floor, not the total.
F3 — "works on every build, immediately" is overstated. conftest.py is read from the developer's checkout, so this needs a pull or rebase. The contrast with ENG-1692 is still real and worth keeping — that one needs a package reinstall — but a developer on a long-lived branch does not get this automatically.
F4 — "the escape hatch is removed" invites the wrong conclusion. Only the shell-level hatch goes. monkeypatch.setenv still works inside a test; I verified it. As written a reviewer could reasonably conclude the analytics path is no longer testable from the suite, which is false.
What did NOT survive (checked and killed)
- A
.envfile could still enable it. Verified live: envfalse+.envsayingtrue→analytics_enabled = False. Env beats.envin pydantic-settings. - The assignment leaks into subprocesses.
setdefaultmutatedos.environidentically — no change. Both subprocess paths pass explicit values (test_analytics.py's generated script uses class attributes;tests/e2e/harness.py:164sets the variable itself). - The named settings classes read the env, so the coverage claim is false. Checked all four.
_Settingsand_PosthogSettingsare plain classes with hardcoded values, and both tests monkeypatch_fire/_fire_posthogso nothing leaves the process.Sis a subprocess script pointed at127.0.0.1.test_tool_outcome_tracking.py'sSimpleNamespace(analytics_enabled=True)has noanalytics_url, sosend_eventraises into its own guard and sends nothing. - Another repo sets this variable. It appears only in anton — README, CLI help, settings, docs, and the two test files. No CI workflow sets it.
Unverified
I proved the mechanism and reproduced the fingerprint locally, but I have no access to the two machines producing the production traffic. If their leak has a different cause, this closes a real hole and the 70% persists. The ticket's ds_connect_attempt criterion is the falsifiable check.
run-tests had not completed when I reviewed; my local run of the same suite on this head passed 2,709 / skipped 31 / failed 0.
| # tests/e2e/harness.py sets the variable explicitly per subprocess rather than | ||
| # relying on inheritance. Same call cowork-server's own conftest already makes for | ||
| # the database: "Force isolation (assignment, not setdefault, never touch a real DB)." | ||
| os.environ["ANTON_ANALYTICS_ENABLED"] = "false" |
There was a problem hiding this comment.
F1 — medium. Nothing in CI catches a regression of this line.
I grepped origin/staging and this head: no test references ANTON_ANALYTICS_ENABLED outside this file and tests/e2e/harness.py:164. Both of my suite runs returned the same counts — 2,709 passed with the assignment, 2,709 passed mutated back to setdefault. No test observes this guard in either direction.
That is exactly the property that let the leak run for four months, and this PR leaves it intact. A merge conflict, a revert, or a well-meaning "restore the opt-in" reopens it with a green CI.
The cheap version is a partial guard — it only fails on a machine that already has the variable exported:
def test_analytics_is_off_for_the_whole_suite():
assert os.environ["ANTON_ANALYTICS_ENABLED"] == "false"
assert AntonSettings().analytics_enabled is FalseThe airtight version spawns a child process with the variable exported, imports this conftest the way pytest does, and asserts the setting still resolves False — so it fails on every machine when the line regresses. tests/test_analytics.py already has the subprocess + _CI_MARKERS scaffolding to build on (_run_child, _capture_endpoint).
Follow-up commit incoming.
…iable Self-review finding F1 on #412. Nothing observed the guard in either direction — the same 2,709 tests passed whether it held or was silently cancelled, which is precisely why the leak survived four months with a green CI. This PR closed the hole and left that property intact. The assertion cannot be in-process: by the time any test runs, conftest has already executed in an interpreter the test did not control, so on a clean machine (which is what CI provides) an in-process check passes for the wrong reason. The child process exports the variable BEFORE the interpreter starts, which is the only shape in which the bug is visible. Mutation-verified: restoring `os.environ.setdefault(...)` fails the test with `enabled=True`; the fix passes it. Reuses test_analytics.py's existing subprocess + _CI_MARKERS scaffolding rather than adding a second pattern. The second test pins the other half, so nobody restores the escape hatch believing it was lost: `monkeypatch.setenv` still re-enables analytics inside a test, because it runs after conftest import and AntonSettings reads the environment at construction. 2,711 passed, 31 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
alecantu7
left a comment
There was a problem hiding this comment.
Self-review findings addressed
F1 (medium) — fixed in 54f4f816. test_the_suite_kill_switch_beats_an_exported_variable spawns a child process with ANTON_ANALYTICS_ENABLED=true exported before the interpreter starts, imports this conftest the way pytest does, and asserts AntonSettings().analytics_enabled still resolves False. An in-process assertion could not do this — by the time any test runs, conftest has already executed in an interpreter the test did not control, so on a clean machine (which is what CI provides) it would pass for the wrong reason.
Mutation-verified: restoring os.environ.setdefault(...) fails it with - enabled=False / + enabled=True; the fix passes it. Reuses test_analytics.py's existing subprocess + _CI_MARKERS scaffolding rather than introducing a second pattern.
A second test pins the other half — monkeypatch.setenv still re-enables analytics inside a test — so nobody restores the escape hatch believing that case was lost with it.
Full suite: 2,711 passed, 31 skipped.
F2, F3, F4 — corrected in the PR body. The emitter surface is now stated as 15 call sites / ~10 names / 4 direct-sink events with 259 called out as a floor; the delivery claim now says "next test run after a pull or rebase" rather than "immediately"; and the escape-hatch paragraph now says shell-level and points at the test that proves the monkeypatch path survives.
Ready for human review.
tino097
left a comment
There was a problem hiding this comment.
Code review
Found 1 issue, inline on the new subprocess helper.
The core change (setdefault -> assignment) checks out. I ran the suite at this head (2711 passed, 31 skipped) and confirmed the new kill-switch test fails with enabled=True when the setdefault form is restored, so the mutation-verification claim in its docstring holds rather than being aspirational.
Also checked and dismissed: nothing else in the repo relies on the old only-if-absent behavior (tests/e2e/harness.py builds its subprocess env explicitly, and the analytics tests construct their own settings objects rather than reading the environment), and the "three of those four families" note in tests/conftest.py is accurate for the four families it enumerates.
🤖 Generated with Claude Code
- If this code review was useful, please react with 👍. Otherwise, react with 👎.
| # The suite may itself run under GitHub Actions. `_is_ci()` is a *separate* | ||
| # guard that would drop the traffic anyway, so leaving a marker set would | ||
| # make this pass for the wrong reason on CI and fail locally. | ||
| for marker in _CI_MARKERS: | ||
| env.pop(marker, None) |
There was a problem hiding this comment.
This block is inert here, and the comment justifying it is inaccurate for this code path.
_CI_MARKERS only matter to _is_ci(), which is consulted inside send_event in anton/analytics.py#L499-L501. The child spawned here never calls send_event — it imports conftest and prints AntonSettings().analytics_enabled, and that field resolves purely from ANTON_ANALYTICS_ENABLED via pydantic's env_prefix. No CI marker is read anywhere on that path. So leaving a marker set would not change this test's result, and the stated reason ("would make this pass for the wrong reason on CI and fail locally") does not hold.
It reads as a copy of the same block in _run_child above, where the child does call send_event and the pop is genuinely load-bearing.
Worth fixing rather than leaving: this is the regression test for a bug whose entire character was a guard that looked effective and silently wasn't. A comment asserting a guard that does nothing works against exactly the property this test exists to establish.
Smallest fix is to drop both:
| # The suite may itself run under GitHub Actions. `_is_ci()` is a *separate* | |
| # guard that would drop the traffic anyway, so leaving a marker set would | |
| # make this pass for the wrong reason on CI and fail locally. | |
| for marker in _CI_MARKERS: | |
| env.pop(marker, None) |
Alternatively keep the pop and reword the comment to say the markers are cleared for parity with _run_child, not because they affect this assertion.
There was a problem hiding this comment.
Confirmed and fixed in fb0b3395 — you're right on both halves, and I checked it rather than taking it on the reading.
analytics_enabled is a plain field on AntonSettings (anton/config/settings.py:147) resolved from ANTON_ANALYTICS_ENABLED via env_prefix. _is_ci() is referenced in exactly one place, send_event at analytics.py:500, and this child never gets there. Measured each marker individually and all seven together:
| Child env | fix | setdefault mutant |
|---|---|---|
| markers popped | enabled=False |
enabled=True |
every _CI_MARKERS entry set |
enabled=False |
enabled=True |
Inert in both directions, so the comment's stated failure mode ("pass for the wrong reason on CI and fail locally") cannot occur. Copied from _run_child, exactly as you read it.
I took the reword rather than the bare delete, for the reason you gave — this is the regression test for a guard that looked effective and wasn't, so the useful thing to leave behind is why there is no scrub here when the helper 60 lines up has one. Otherwise the next person restores it by symmetry. The comment now names send_event as what makes the scrub load-bearing in _run_child and records the both-directions result.
One thing your comment pushed me into that wasn't in scope: the same commit fixes a stale line in tests/conftest.py claiming this fix "works on every build, immediately". My own self-review (F3) had already corrected that in the PR body and I never carried it back into the code. Same defect class you flagged — a comment asserting something untrue — so it went with it.
Suite re-run at the new head against a local capture server, ANTON_ANALYTICS_ENABLED=true exported: 2,711 passed, 31 skipped, 0 events. Kill-switch test still mutation-verified — restoring setdefault fails it with enabled=True, with or without CI markers set.
…ment (ENG-2055) The kill-switch child imports conftest and resolves AntonSettings; it never calls send_event, so _is_ci() is never consulted on that path and clearing _CI_MARKERS changed nothing. The comment claimed the opposite. Verified in both directions with every marker set: the fix reports enabled=False, the setdefault mutant reports enabled=True. The scrub was copied from _run_child, where the child does call send_event and the pop is load-bearing. A comment asserting a guard that does nothing works against the property this test exists to establish. Also corrects the conftest note that still said this line "works on every build, immediately" — self-review F3 fixed that in the PR body but not here. It is read from the checkout, so it lands on the next run after a pull. Suite unchanged: 2,711 passed, 31 skipped, 0 events against a local capture server with ANTON_ANALYTICS_ENABLED=true exported. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
alecantu7
left a comment
There was a problem hiding this comment.
Self-review at fb0b3395
Re-ran the ticket's acceptance criterion from scratch rather than trusting it — full suite, local capture server, analytics forced on, both directions.
| conftest line | events captured | tests |
|---|---|---|
os.environ.setdefault(...) (mutant) |
260 | 1 failed, 2710 passed |
os.environ[...] = "false" (this PR) |
0 | 2711 passed, 31 skipped |
Family split under the mutant: tool_completed 149, ds_connect_* 89 (the ticket's figure exactly), ask_user_* 15, turn_completed 5. The new test is genuinely mutation-verified — restoring setdefault fails it with enabled=True, and it fails inside a full-suite run rather than only in isolation.
Supporting claims checked and holding:
- Nothing bypasses
send_event. All 15 emitters funnel through it, and theanalytics_enabledgate atanalytics.py:496sits above the branch to either sink — so the single line covers both the direct PostHog path and the collector path. This was the one that mattered: if the direct sink had its own gate, the fix would have been partial. harness.pysets the variable explicitly in stub mode, and in live modeenv.update(common)applies it on top of the inherited environment — so the subprocess claim holds in both modes, not just the one.- No other
os.environ.setdefaultanywhere intests/. - The cited cowork-server precedent is real and verbatim at
cowork-server/tests/conftest.py:14.
Finding 1 (medium) — the guard is one layer; the precedent it cites is seven
That cowork-server conftest does not merely prefer assignment to setdefault. It forces seven variables and redirects every sink to a temp dir. This PR takes the "assignment" half and leaves ANTON_ANALYTICS_URL and ANTON_POSTHOG_KEY pointing at production for the whole suite, which is safe today only because of one if at analytics.py:496.
That is the uncomfortable part. This bug exists because ENG-1288 added a new emitter that reached a real sink. The next one — or any refactor that moves that check — reopens it, and the new test would not notice, because it asserts on AntonSettings().analytics_enabled rather than on "no bytes left the process". The ticket's own lesson is that reading the code is how this was missed for four months; asserting on a resolved setting is the same category of evidence.
Demonstrated, with the enabled-check bypassed to stand in for that future emitter:
PR version (flag only): analytics_enabled=False url='http://…/collector' key=set -> 2 events
hardened (flag + sinks blanked): analytics_enabled=False url='' key=empty -> 0 events
Two lines, both already documented in analytics.py as supported kill switches — "Blanking analytics_url has always stopped EVERY event", and ANTON_POSTHOG_KEY="" disables the PostHog sink specifically. Full suite with them added: 2711 passed, 31 skipped, identical to without. No coverage cost.
Pushing this as a follow-up commit.
Finding 2 (low) — the family table is missing one
The ticket and the conftest comment both say four families and 254 events. My run captured five: the same four plus scratchpad_package_installed. Minor in volume, but the "Out of scope" section reasons by enumerating families, so the fifth belongs in the list before anyone judges what residue is left.
Finding 3 (low) — the sibling repos have no guard at all
cowork-server/tests/conftest.py and both scratchpad-controller conftests contain no analytics guard of any kind. The ticket's measurement of cowork-server at zero is correct but incidental — nothing stops its tests emitting; they simply never drive a turn to an emitter today. cowork-server vendors anton, so the first test that does drive one leaks with nothing to catch it, which is precisely the shape of this bug. Worth a one-line guard there rather than relying on the measurement staying true.
Separately, and not covered by the ticket: cowork_evals has no conftest at all and runs anton for real. If eval runs carry a developer's aid, some share of the 41,121 ds_connect_attempt in project 355390 is evals rather than the test suite — which would mean the volume does not fall as far as the Done when predicts, and someone reads a partial drop as the fix having failed. Worth naming in the ticket so the follow-up measurement is interpreted correctly.
The flag is honoured by exactly one `if` in `send_event`. This bug exists because ENG-1288 added an emitter that reached a real sink, so the next one reopens it — and the ENG-2055 test would not notice, since it asserts on a resolved setting rather than on no bytes leaving the process. Both are documented kill switches in analytics.py. Measured with the enabled-check bypassed: the flag alone still emitted 2 events, the flag plus these two emitted none. Full suite unchanged at 2711 passed / 31 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Verification of
|
| conftest state | capture server | suite |
|---|---|---|
pre-PR (setdefault, no sink blanking) |
259 | 1 failed, 2710 passed |
setdefault + sink blanking retained |
0 | 1 failed, 2710 passed |
| this PR | 0 | 2711 passed, 31 skipped |
Row 2 is the problem: the guard is defeated and the acceptance check still reads zero. Anyone re-running the documented repro to confirm the fix gets a green that would be green either way — the same "looks effective, silently isn't" shape this ticket exists to fix.
Not a defect in the code, and not an argument for reverting: the discriminating guard has simply moved into test_the_suite_kill_switch_beats_an_exported_variable, which fails in every row where the flag regresses and runs in CI, which the capture-server ritual never did. That is a better place for it. But Done when #1 and any QA step derived from it need to say so, or the fifth column of that table gets ticked off on a vacuous check.
Finding 2 — refuted
scratchpad_package_installed does not leak, so the four-family enumeration in the conftest comment is right as written. Full breakdown from the pre-PR mutant run above — 259 events, 12 event names, four families:
| Family | Count |
|---|---|
tool_completed |
149 |
ds_connect_* (success 44 / attempt 40 / failed 5) |
89 |
ask_user_* (7 names) |
16 |
turn_completed |
5 |
Matches the PR body's 149/89/16/5 exactly. scratchpad_package_installed is absent, and the code says why: all six of its tests monkeypatch anton.analytics.send_event itself (test_tool_outcome_tracking.py:224,246,267,294,338,358) or pass _PosthogSettings with _fire_posthog patched (test_analytics.py:181), so it never reaches the wire. The first self-review had this right — scratchpad_package_installed and rule_retrieval never fire — and the later one contradicted it. The only stale figure is the conftest comment's 254 (147/89/16/2), which is a real measurement from a pre-branch build; the PR body already carries the corrected 259.
Finding 3 — agreed, but it belongs in its own ticket
The sibling-repo gap is real and the reasoning is right: cowork-server measuring zero is incidental, not guarded. But it is a change to two other repos, so per convention it wants its own ticket rather than widening this diff.
The cowork_evals half is the part worth carrying into ENG-2055 before anyone reads the follow-up measurement, since it changes how the ds_connect_attempt criterion is interpreted: if some of the 41,121 is eval runs rather than the suite, the volume will not fall as far as Done when predicts, and a partial drop is not evidence the fix failed.
Note on review state: the approval is on fb0b3395; 24178e4c landed after it, so the approving reviewer has not seen the sink-blanking commit. All checks green at head (run-tests 4m28s). Leaving the merge to a human.
…from a grep (ENG-2055) 260 events across five families, not 254 across four. Reproduced twice on a clean checkout. The fifth family matters less for its volume (1 event) than for how it was missed. Enumerating leakers by grepping the event name finds only TestPackageInstallTelemetry, whose four tests monkeypatch send_event and fire nothing. The event that actually reaches the wire comes from test_scratchpad_observer_dispatch.py, which never names the event at all and patches nothing — found by instrumenting the emitter with PYTEST_CURRENT_TEST. Same trap as the bug itself: reading the code is how this was missed for four months. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Correcting the Finding 2 refutation — it is five families, and the fifth has a nameThe refutation above is wrong, and the reason is worth more than the count. Re-ran on a clean checkout at Rather than trade counts, I instrumented the emitter with The refutation is right about the four it names — those That is why the code reading missed it: the enumeration was built by grepping the event name, and a test that triggers an event without naming it is structurally invisible to that method. Which is this ticket's own lesson pointed back at itself — "Reading the code is how this was missed for four months." Corrected in The new finding lands, and it is a fair hit on the sink-blanking commitBlanking the sinks does make The conclusion is right: the discriminating guard has moved into
Finding 3Agreed on both halves — the sibling-repo guard as its own ticket, and the Review state
|
…G-2055) The previous revision said 260 across five families and called scratchpad_package_installed a leak. It is not one. It reaches send_event and dies in _posthog_body with "Object of type MagicMock is not JSON serializable", swallowed by send_event's own except/pass. Verified three ways: absent from two full-suite wire captures, zero when the test runs alone, and instrumenting _posthog_body shows the TypeError. The 260/five figure came from counting at the emitter rather than at the wire. Counted there the totals are 277 invocations across 16 names — which is neither four families nor five, so the comment now carries both numbers and says which one to quote. Keeps the real find from that revision: grepping the event name cannot see test_scratchpad_observer_dispatch.py, which reaches the emitter without naming it. Adds the corollary it exposed — that caller passes a MagicMock, so it sails past both guards in this file and is safe only because a serialization error happens to stop it. Flagged as its own ticket rather than left reading as covered. Suite unchanged: 2711 passed, 31 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Half right, and the half I got wrong is the useful halfThe correction is right that my enumeration was unsound, and right about why. I built it by grepping the event name, which cannot see My "all six tests monkeypatch But it does not leak, and the 260/five figure is an emitter countReaching
The wire number is 259 across four families — So 260/five is an emitter count, not a wire count — and counted at the emitter it is not five families either, it is 277 across 16 names. Neither number is wrong; they measure different things, and only one of them reached production. Why that specific event cannot reach the wire, traced to the line:
The part of this that is actually worth havingThat caller passes an explicit settings object, which means neither guard in That is the same shape as the original bug — a guard that looks like it covers a path it does not — except this one currently fails safe by accident. A caller that passed a more realistic settings object would send for real, past both guards, and no test would notice. Worth its own ticket alongside the sibling-repo one; it should not sit in this file reading as covered. Corrected in
|
…pulations (ENG-2055) Two findings from the pre-merge review, both the same class this ticket exists to close: a claim about a guard that does not hold as written. 1. The conftest comment subtracted the emitter count from the wire count and called the difference swallowed exceptions. The two count different populations in both directions — some send_event calls never send, and some wire requests have no in-process call at all, because test_cloud_turn_process.py copies os.environ and runs the real entrypoint as a child. Measured alone: 5 wire events, 0 in-process calls. The tell was already in the numbers — the wire showed MORE tool_completed than the emitter (149 vs 148), which swallowed exceptions cannot produce. Also records the second reason the assignment matters: children inherit os.environ, so that same file goes 5 events -> 0 with this fix. 2. test_a_test_can_still_re_enable_analytics_for_itself claimed a test could still "exercise the enabled path". Since the sinks were blanked it can only flip the flag: analytics_url and posthog_key resolve empty, so send_event returns before either sink. The test passed because it asserted the setting. It now asserts at the sender — nothing handed to a sender thread — for both the direct and collector routes, and says what a test must also set to send. That second assertion closes real coverage: nothing tested the sink blanking before, so removing it from conftest was a silent no-op. Mutation-verified both ways — dropping the sink lines fails the re-enable test, restoring setdefault fails the kill-switch test. Suite unchanged: 2711 passed, 31 skipped, 0 events against a local capture server with ANTON_ANALYTICS_ENABLED=true exported. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pre-merge adversarial review — 2 findings, both fixed in
|
| conftest | wire events |
|---|---|
| pre-PR | 5 |
| at head | 0 |
This only works because it is an assignment. With setdefault and an exported true, children inherited true and sent for real. It also corrects my first self-review's "setdefault mutated os.environ identically — no change" — true only when the variable is absent, i.e. not in the case this ticket is about.
F2 — test_a_test_can_still_re_enable_analytics_for_itself passed for a narrower reason than it claimed
Its docstring promised a test could still "exercise the enabled path". Since 24178e4c blanked the sinks, flipping the flag re-enables the setting, not sending. Probe at head:
analytics_enabled = True
analytics_url = ''
posthog_key = ''
spawned = [] <- send_event hands nothing to a sender thread
The test stayed green because it asserted the setting. That is the exact reassurance a future developer would follow before concluding the guard broke and restoring setdefault.
It also closed real coverage. Nothing tested the sink blanking — deleting those two lines from conftest was a silent no-op. The test now asserts at the sender, on both routes, so it fails if they are removed.
Mutation-verified both ways:
| Mutation | Result |
|---|---|
| drop the two sink lines | test_a_test_can_still_re_enable_analytics_for_itself fails |
restore setdefault |
test_the_suite_kill_switch_beats_an_exported_variable fails |
Full suite at 021168af: 2,711 passed, 31 skipped, 0 events against a local capture server with ANTON_ANALYTICS_ENABLED=true exported.
Checked and refuted — did not become findings
- Writer inventory (ENG-597 rule) for
ANTON_ANALYTICS_ENABLED/ANTON_ANALYTICS_URL/ANTON_POSTHOG_KEY/ANTON_POSTHOG_HOSTacross every repo inMindsDB_Repos: every hit is inside an anton clone or worktree. No sibling repo reads or writes them, so the sink blanking cannot reach cowork-server, scratchpad-controller, cowork or cowork_evals. No CI workflow sets them either. - Blanking breaks the subprocess analytics tests — it does not;
_SHORT_LIVED_CHILD'sShardcodes url/key/host rather than inheriting. - A test asserts on the default
analytics_url/posthog_key— none does. - Windows —
tests.yml:19isubuntu-latestonly, and tests are not packaged.
Not run
/security-review, judged disproportionate for a diff adding no production code, no input handling and no endpoint. Manual pass instead: the change strictly reduces egress; the blanked posthog_key is a public project token, not a credential (settings.py:156); no secrets in the diff or history.
Caveat on my own method
The combined instrumentation run needed PYTHONPATH=/tmp to load a counting plugin, which fails test_backend_launcher_env.py::test_build_env_without_existing_pythonpath — that test asserts on exactly that variable. Clean without it (4 passed). Not a PR defect.
Review state: tino097's approval is on fb0b3395, now five commits back, and 24178e4c (sink blanking) was the behavioural one. staging-freeze is currently enforcement=disabled and the PR reads MERGEABLE / CLEAN. Leaving the merge to a human.
… that sends (ENG-2055) 260 on the wire across five families, not 259 across four. Measured three times. The previous revision was right that the observer test is safe, and right about why: MagicMock settings, TypeError in _posthog_body, swallowed. It generalised that into "it never leaked", which is wrong. Instrumenting send_event itself rather than send_package_install_event shows three callers, not one: test_analytics.py _PosthogSettings, fake host goes nowhere test_chat_scratchpad.py real AntonSettings SENDS test_scratchpad_observer_... MagicMock TypeError, swallowed The middle one is the wire event. It needs no "more realistic settings object" to become dangerous — it already builds a real AntonSettings and reads the environment. Both earlier findings survive: a name-grep cannot see the third caller, and reaching send_event is not sending. The accidental safety of the third is still worth its own ticket. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Settled by measurement: it does leak, and the culprit is a third caller neither of us had namedCorrected in I instrumented
Raw probe output for the middle one: Wire total 260, third run in a row. What survives from your revision, unchanged. The MagicMock mechanism is exactly right and I watched it fire — What does not survive is generalising it to "it never leaked". It leaks from the middle caller. And the follow-up you flagged — "a caller that passed a more realistic settings object would send for real" — is not hypothetical: that caller is in the suite today and needs no more realistic object, because it already constructs a real My earlier correction was also wrong, in the same shape: I named the observer test as the leaker because I instrumented So the header now carries 260 across five families, all three callers with their outcomes, and keeps both traps — a name-grep cannot see the third caller, and reaching None of this changes merge safety. The fix stops all three: with the assignment plus blanked sinks the suite emits zero, |
…G-2055) The previous revision is right that scratchpad_package_installed leaks and right about which caller sends it. Its explanation for why other runs measure 259 is not: ordering within a run cannot change this. The event is gated on install_call_installed_something(result) (tool_handlers.py:719), and the `workspace` fixture is a persistent directory in the repo — <repo>/.pytest-workspace (test_chat_scratchpad.py:20), not tmp_path. So the first run on a machine really pip-installs cowsay and emits; every run after that gets "already satisfied" and emits nothing. Verified by toggling only that state, same commit, same command: workspace moved aside (cold) scratchpad_package_installed 1 workspace restored (warm) scratchpad_package_installed 0 Both counts were therefore correct on the machines that produced them. Records the cold/warm split and the `rm -rf .pytest-workspace` needed to reproduce 260 — without it the next person measures 259 and concludes the comment is wrong, which is how this question has now been reversed three times. Ran tests/test_analytics.py, tests/test_chat_scratchpad.py, tests/test_scratchpad_observer_dispatch.py, tests/test_tool_outcome_tracking.py: 99 passed. Comment-only change to conftest; CI runs the full suite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
| workspace | scratchpad_package_installed on the wire |
|---|---|
| moved aside (cold) | 1 |
| restored (warm) | 0 |
.pytest-workspace/.anton/scratchpad-venvs/main/bin/cowsay was already present on my machine — the suite has run here many times this session. That is the whole discrepancy. Both measurements were correct on the machines that produced them; neither of us was mis-measuring.
Ordering cannot explain it: the gate is the state of a directory that outlives the run.
Corrected in 7b7fffe6 — the comment now carries the cold/warm split and the rm -rf .pytest-workspace step needed to reproduce 260. Without that step the next person measures 259 and concludes the comment is wrong, which is how this one question has now been reversed three times.
Two things that follow
For the ticket's Done when. The leak count is machine-state dependent. A verifier on a warm checkout sees 259/four and a fresh CI runner sees 260/five, and neither is evidence of anything being broken. Worth stating wherever the figure is quoted.
Unrelated to this PR, but surfaced by it: on a cold workspace the suite performs a real pip install from PyPI during test_install_action_dispatch. That is live network egress in a unit-test run, and it is why the event fires at all. Not this PR's business — flagging it for the sibling-repo/test-hygiene follow-up rather than widening this diff.
Ran test_analytics.py, test_chat_scratchpad.py, test_scratchpad_observer_dispatch.py, test_tool_outcome_tracking.py at the new head: 99 passed. Comment-only change to conftest; CI runs the full suite.
What
One line in
tests/conftest.py:os.environ.setdefault("ANTON_ANALYTICS_ENABLED", "false")→ a plain assignment.Why
setdefaultwrites only when the key is absent. A developer withANTON_ANALYTICS_ENABLEDexported — exactly what someone working on an analytics ticket sets — cancelled the guard silently, and the suite has been posting real events to production PostHog since ENG-1288 shipped on 2026-08-06.This is the source of the ~70% contamination in
turn_completed. The fake rows carry a fingerprint that cannot occur in production —planning_modelrenders as<AsyncMock name='mock.planning_model' id='…'>, which onlymake_mock_llmproduces. Counted per day, the AsyncMock rows are the script-shaped rows:It is also ENG-1964's April complaint, still live: project 355390 holds 41,121
ds_connect_attemptin 14 days across 350 installs — 117 per install, which no real user does. That ticket was filed at ~38% of volume and cancelled in May; four months on it is worse.ENG-1692 does not cover this. Its guard sits inside
_emit_turn_costalone, so the other families are unprotected.259 is a floor, not the total (self-review F2). anton has 15
send_eventcall sites across 9 files emitting ~10 distinct names, of which four route to the direct PostHog sink:turn_completed,tool_completed,rule_retrieval,scratchpad_package_installed. The run above exercised four names;rule_retrieval(cortex.py:89) andscratchpad_package_installed(core/utils/scratchpad.py:100) never fired — not because they are guarded, but because their test paths pass settings objects that fail insidesend_event's own try/except before sending. A run that exercised those paths would emit more.Delivery, stated precisely (self-review F3). This is cheaper than ENG-1692 but not free.
conftest.pyis read from the developer's checkout, so it takes effect on the next test run after a pull or rebase — no package reinstall, unlike ENG-1692, but a developer on a long-lived branch cut before today does not get it automatically.CI was never the source —
analytics.py::_is_ci()already dropsGITHUB_ACTIONSand friends. The leak is local developer runs.Verification — mutation-verified against a live capture server
Both runs on this branch, same venv, same environment,
ANTON_ANALYTICS_ENABLED=trueexported, withANTON_POSTHOG_HOSTandANTON_ANALYTICS_URLpointed at a local HTTP server that logs every GET and POST:setdefaultThe 259:
tool_completed149,ds_connect_*89,ask_user_*16,turn_completed5.The test count was identical in both directions on the first commit, which is the point worth noticing — no test observed this guard, so it could never have failed loudly. That is why it went unnoticed for four months, and why the check above is a capture server rather than a code reading.
That gap is now closed.
54f4f816addstest_the_suite_kill_switch_beats_an_exported_variable, which spawns a child process with the variable exported before the interpreter starts — the only shape in which the bug is visible, and one an in-process assertion cannot reach on a clean machine. Mutation-verified: restoringsetdefaultfails it withenabled=True. Full suite now 2,711 passed / 31 skipped.Nothing loses coverage
The shell-level escape hatch is removed deliberately — and only that one.
monkeypatch.setenvstill re-enables analytics inside a test, because it runs long after conftest import andAntonSettingsreads the environment at construction. That is asserted bytest_a_test_can_still_re_enable_analytics_for_itself, so nobody restores the opt-out believing this case was lost with it (self-review F4).The tests that exercise the analytics layer construct their own settings objects and never read the environment —
test_analytics.py::_Settingsand::S,test_tool_completed.py::_PosthogSettings,test_tool_outcome_tracking.py'sSimpleNamespace.tests/e2e/harness.py:164already sets the variable explicitly per subprocess rather than relying on inheritance.This is the same call cowork-server's own conftest already makes for the database: "Force isolation (assignment, not setdefault, never touch a real DB)."
Expected side effect — not a regression
turn_completedvolume should fall by roughly two thirds as developers pull this. That is the fix working. The drop arrives per machine, so expect a few days rather than a cliff, and any alert floor calibrated before it was calibrated on contamination.The polluted history is deliberately left alone; historical reads keep using the behavioural filter.
Out of scope
Extending the script guard to
tool_completed— 149 of the 259 are tool rows and this stops them at the source. A guard in the product code would change a live event whose baseline is three weeks old, need a rule invented separately fords_connect_*/ask_user_*(which fire outside a turn, so there is nollm_callsto test), and drop rows unauditably. Revisit only if residue survives.Langfuse was never affected — a mocked LLM never reaches the gateway, so no trace exists.
Security check
Performed. The change strictly reduces data egress: it stops a test process sending to an external service. No credential, authz, endpoint, input-validation or deserialization surface is touched, and no secrets appear in the diff or in history.
Closes ENG-2055.
🤖 Generated with Claude Code