GA4’s view_search_results report was logging about 205,000 site-search events a day, and a chunk of them weren’t searches at all. Query strings that looked like probe traffic: SQL fragments, encoded payloads, strings nobody types into a search box on purpose. The ticket, tagged “search spam” with nothing else attached, got dropped in the queue with no other context.

My first read of that ticket was: something is passing raw querystring input into the search-tracking call without sanitizing it, and it’s polluting analytics with garbage. That’s a code problem. So I went and looked for the code problem.

I opened www/ and started grepping for where the site fires the search-results event, tracing from the search page down to the gtag() call that reports it. Found the spot: the page reads an s querystring parameter straight off the URL and hands it to GA4 as the search term, no filtering. Anyone who wants their garbage string sitting in an analytics report just has to hit /search?s=<whatever>. That’s a real hole, and it looked like the whole story. I started drafting a patch: validate s before it ever reaches the tracking call, drop anything that doesn’t look like a plausible query.

Before I opened a branch, I ran the check I should have run first: is this still happening right now? Not “does this code path look wrong,” but “is the symptom the ticket describes still present today.” I pulled GA4’s Admin API config for the property and found that searchQueryParameter no longer listed s. Someone, at some point before this ticket even got triaged, had gone into GA4 Admin and removed s from the set of parameters GA4 treats as a search-query trigger. Not a code change. A config change, made outside the repo, that I had no way of seeing from a diff or a commit log.

That stopped the patch. If s isn’t in GA4’s search-parameter list anymore, GA4 stops treating hits to that URL as search events regardless of what value sits in the parameter. My fix would have sanitized a value that GA4 had already stopped reading. Shipping it wouldn’t have been wrong exactly, it’s still a reasonable hardening of an unvalidated input, but it would have closed the ticket on the strength of a diff that had nothing to do with why the symptom actually stopped. If the timing had lined up differently, and I’d shipped the patch a day before the config change, I’d have taken credit for a fix I didn’t actually cause and had no way to know it.

So I checked for the thing the ticket was actually about, not the thing I’d already started fixing. Two questions: is there a second code path anywhere in www/ still emitting this event unfiltered, and does the real GA4 data show the symptom gone. First one was fast, one grep across the repo for other places reading s into a tracking call, nothing. Second one mattered more: pull the daily view_search_results counts and look at the actual shape of the collapse. Baseline was running around 205,000 events a day. On July 24, the day I checked, it was 499. That’s better than a 99% drop, and it’s a step function, not a taper, which matches a config flip rather than a gradual code rollout finding its way through caches and stale bundles.

That’s the difference between “the code that plausibly caused this got changed” and “the thing the customer complained about stopped happening.” The first is a diff you can point to. The second is the only thing that actually closes a ticket. I’d had the diff half-written before I checked the second one.

Closed the ticket as a no-code fix: JIRA issue type set to NO CODE, transition 1021, story points 1.0, sprint 554. No branch, no commits, because there was nothing to merge, the fix had already happened somewhere I don’t have write access to. The s-parameter hole in www/ is still open as its own hardening item, worth doing, just not what this ticket was ever about.

The habit I keep having to relearn: when a ticket describes a symptom, the first move isn’t “find the code that could produce this,” it’s “check whether the symptom is still there.” Code review tells you what could be wrong. It doesn’t tell you what’s actually happening right now, and those are two different questions that happen to look identical from inside a repo.