Measured, not asserted
Published
A frequency cap looks like one setting and is three: how many, per whom, and over what stretch of time. Each is a separate option key with a separate default, and each fails on its own while the other two keep working and keep reporting themselves as configured. This test renamed each of them in turn against a real obfuscation engine. Every counter still ran. No counter ever exceeded its limit. One household went from twenty exposures over the flight to fifty-eight, and in a different arm the daily cap was breached without a single dollar of spend moving.
One setting, three decisions
The fixture is a twenty-eight day flight across two hundred households, thirteen thousand and sixty-five ad requests, and a cap of three a day, eight a week and twenty over the flight. Most households hold more than one device, most requests arrive in the evening, and about one slot in eleven comes back unfilled while one in twenty-three errors outright.
Correct, the flight serves 4,583 slots of which 4,000 are real exposures, suppresses 8,482, reaches all two hundred households, and no household sees the ad more than twenty times or more than three times in any rolling day. ABOVE-FLIGHT-CAP=0 and WASTED=0.00.
The three decisions live in five option keys. caps is how many. capScope and identityKey are per whom. windowAnchor is over what stretch, and countableKinds decides which delivery attempts spend the allowance at all. The library's defaults for all five are the permissive ones, because a capping library that has not been told a limit cannot invent one and a library that has not been told what a household is cannot resolve one.
Two names for the same subject, and one answer
The media plan was bought against households, because that is the unit the brand's reach and frequency study is measured in. The library's default counts devices. A household in this fixture holds up to three.
^(capScope)$ takes MAX-PER-HOUSEHOLD from 20 to 58, exposures from 4,000 to 7,019, and puts 3,019 impressions above the flight cap the campaign paid to stay under. ^(identityKey)$ -- a different name, on a different line, meaning a different thing -- produces output byte-identical to it, because a household scope that cannot resolve a household identity falls back to the identity every request carries, which is the device. ^(capScope|identityKey)$ is byte-identical again.
Nothing in the delivery report distinguishes the three runs from the correct one except the numbers themselves. Every counter ran. Every limit was three, eight and twenty. No counter ever exceeded its limit. The person behind the three devices simply appeared to the capping engine as three people.
The cap that was bought is not the cap that was enforced
The quietest arm in this area moves no money at all. ^(windowAnchor)$ replaces a rolling day with a calendar day. Both are twenty-four hours; they merely start in different places, and under a calendar anchor a viewer served three times before midnight can be served three more an hour later.
Exposures stay at exactly 4,000. Spend stays at exactly 74.00. Reach stays at 200. MAX-PER-HOUSEHOLD stays at 20. The only figure that moves in the entire report is the one the fixture computes independently of the counter: MAX-PER-ROLLING-DAY, which goes from 3 to 5.
That figure exists in the fixture because it was written to exist. A real delivery report does not carry it. The daily cap in the media plan was a promise about what a viewer experiences in a day, and the run that breaks that promise is arithmetically indistinguishable from the run that keeps it on every number a delivery report actually publishes.
The loud half is switched off by the quiet one
^(caps)$ on its own fails closed and loudly. The advertiser's own cap rule refuses the flight outright with cap-check(no frequency cap was configured), nothing is delivered, and somebody notices inside a day.
^(requireCapCheck)$ and ^(capRule)$ each move nothing but the line that reports the policy. Every exposure count, every maximum and every dollar is identical to the correct run, because with the caps still configured there is nothing for the review to reject. A half-arm that moves only a reported label is exactly the reading a reviewer closes as cosmetic.
^(requireCapCheck|caps)$ delivers uncapped. All 13,065 requests are served, nothing is suppressed, and one household takes 84 exposures. The half that would have screamed is switched off by the half that fails quietly, which is the same shape a previous pass measured in a software updater and in a data-retention purge.
There is a second sting in that arm and it is worth reading twice. ABOVE-FLIGHT-CAP reports 0. Not because nothing exceeded the flight cap, but because the metric is computed by looking up the flight cap in the configuration -- and the configuration is what was lost. The guard and the alarm that watches it went in one edit.
The direction nobody expects
^(countableKinds)$ fails the other way and costs the advertiser rather than the audience. A passback is an unfilled slot and an error is a slot that failed; neither put the ad in front of anybody, so neither is an exposure. The library counts all three, because an exchange logs all three.
Exposures fall from 4,000 to 3,474 and spend from 74.00 to 64.27. No household is over-exposed; several are under-exposed, because their allowance was spent on slots that showed them nothing. MAX-PER-HOUSEHOLD reads 19 rather than 20, which looks like a campaign comfortably inside its limits rather than one that under-delivered thirteen per cent of the exposure it bought.
The record fields the counter is keyed on reach the same places. Renaming householdId reproduces the scope collapse; renaming kind disturbs which events are countable. Excluding the options object from a rename pattern is not sufficient while the records it is evaluated against are still in scope.
What to change, whether or not you obfuscate
Every one of these failures is reachable without an obfuscator. A dropped key over a serialisation hop, a hand-edited configuration file, a version bump that renames an option, and a partially-populated response from a config service all produce the same missing name.
Carry the scope and the identity together rather than in two keys that can disagree, and refuse to cap at all if the nominated identity field is absent rather than falling back to the device. A silent fallback to a narrower identity is the entire mechanism here.
Compute the metric that would catch the failure from the delivery records, not from the configuration. ABOVE-FLIGHT-CAP read zero in the worst arm in this file because it asked the configuration what the cap was. A reach and frequency distribution built from the served log would have shown eighty-four in the top bucket.
If you do obfuscate, keep member renaming narrow and explicit and exclude the options object and the delivery records it is evaluated against. Renaming identifiers, renaming globals and control-flow transformation are unaffected by any of this: the base column of this test measured all five areas on five presets and every one was byte-identical to the unprotected run. The same reasoning applies to any capping or throttling logic you ship inside a third-party widget or SDK, where the configuration arrives from a host page you do not control.
How this was measured
A grounding harness drives the protection engine directly. Every sample is deterministic -- no clock, no randomness, no network -- and the unprotected original is run twice and required to reproduce itself before anything is compared to it. The simulated capping library is copied in unprotected, because an installed dependency is not rebuilt with your bundle.
Before any renaming, each option key and each record field was deleted from the unprotected file one at a time to establish that it moves the output at all. Across the five areas this pass that was 46 option keys, 34 record fields and 20 far-side reads: one hundred arms, of which five were structurally blind on the first run. All five were fixture defects, all five were fixed, and the second run had none.
The base column ran all five areas on five presets: the ES5 default, the modern target, the emit gate's option set on both targets, and the string transforms. Twenty-five of twenty-five were byte-identical to the unprotected run. For a broader view of what protection is and is not worth in a bought-media context, see obfuscating ad tech JavaScript.
Frequently asked questions
Did obfuscation on its own break frequency capping?
No. Five presets -- the ES5 default, the modern target, the emit gate's option set on both targets, and the string transforms -- all produced output byte-identical to the unprotected run across all five areas measured this pass.
What actually broke it, then?
Member renaming with a pattern that reached the names the cap is made of: the option naming the scope, the option naming the identity field that realises it, the window anchor, the list of countable event kinds, and the record fields the counter is keyed on.
Why does renaming the scope multiply the exposures?
The cap was bought per household and the library's default counts per device. Every counter still runs and no counter exceeds three, but a household holding three devices gets three separate allowances, so the person behind them sees up to nine a day and fifty-eight over the flight.
Which arm moved no money at all?
Renaming the window anchor. Exposures, spend, reach and the flight maximum are all identical to the correct run; the only figure that moves is the most exposures any household took in a real rolling day, which goes from three to five. A delivery report does not publish that figure.
Why did the advertiser's own cap rule not catch the worst arm?
Because the rule sits behind an enabling flag and both names can be lost by the same pattern. Renaming the caps alone fails closed and loudly. Renaming the flag alone is inert. Renaming both delivers uncapped, at eighty-four exposures for one household.
Which arm was loud?
Renaming the caps array on its own. The advertiser's rule refused the flight because no cap was configured, so nothing was delivered and the failure is visible within a day. Losing a value fails closed; losing a meaning fails open.
What is the safest way to use member renaming here?
Keep the pattern narrow and explicit and exclude any options object that carries cap scopes, window anchors or event-kind lists, along with the delivery records they are evaluated against. Renaming identifiers and control flow is unaffected by all of this.
Related reading