Interpreting and querying Firefox's `captcha_detection.*` Glean metrics and the `captcha-detection` custom ping (BigQuery `mozdata.firefox_desktop.captcha_detection`, `mozdata.fenix.captcha_detection`). Use when analyzing captcha prevalence or solve / pass / interacted rates per vendor (ArkoseLabs, Cloudflare Turnstile, Datadome, reCAPTCHA v2, hCaptcha, AWS WAF), cohorting the ping by privacy settings or browsing volume, or writing or reviewing a query or dashboard over it. Records which ratios are valid per vendor, which counters are dead or over-counting, and the filters that keep non-organic rows out.
68
81%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Context and guidance for analyzing the captcha_detection.* metrics defined in
metrics.yaml and sent in the captcha-detection custom ping (pings.yaml).
Everything lives in toolkit/components/captchadetection/ in mozilla-central:
CaptchaDetectionChild.sys.mjs (detects captchas in-frame),
CaptchaDetectionParent.sys.mjs (records metrics),
CaptchaDetectionPingUtils.sys.mjs (ping submission). File paths below are
relative to that directory unless stated otherwise.
Purpose (per pings.yaml): analyze how often CAPTCHAs appear in the wild, broken
down by users' anti-tracking / anti-fingerprinting settings. This document is
about interpreting and querying the data — it records the non-obvious things
that are easy to get wrong.
The ping is captcha-triggered: it is submitted when a client encounters a
detected captcha (an outcome event flags unsubmitted data; pages_visited alone
does not trigger submission). It is therefore NOT sent by every client — the
reporting population is "clients who hit a detected captcha", which is the right
denominator to keep in mind when sizing any analysis.
Detection is DOM/heuristic based: an in-frame actor identifies a vendor's captcha and reports load and outcome events, which the parent turns into counters. The exact triggers are vendor-specific (see Metrics Overview). Because detection depends on the vendor's page structure, it can miss cases the heuristic doesn't match, and a trend change can reflect a vendor markup change rather than user behavior.
The ping carries, per client:
_pbm (Private Browsing Mode) variant, with one
historical exception: datadome_oc_pbm was only added in Firefox 154 (bug
2054037). It is NULL, not 0, on submissions from older builds — including
current ESR — so any window reaching back before 154 must treat it as
missing rather than as a zero load count (see Datadome).privacy_* (tracking
protection incl. cryptomining/known-fingerprinter toggles, fingerprinting
protection, resistFingerprinting) and network_cookie_* (cookie behavior,
opt-in partitioning). These describe the user's settings, not captcha events.pages_visited (+ _pbm) — a browsing denominator.Counter-suffix legend: oc loaded, ps shown, pc/cc passed/completed,
pf/cf failed, ac auto-completed, bl blocked. Not all vendors record all
suffixes — see each subsection. Pair non-_pbm counters with pages_visited
for normal browsing and _pbm counters with pages_visited_pbm for private
browsing; never mix the two.
Counters are batched across pings, so a single ping can look inconsistent. The
ping accumulates counters and submits periodically (CaptchaDetectionPingUtils,
~once/24h), not once per captcha. A captcha's lifecycle can straddle a submission
boundary: the widget loads (incrementing oc) before a ping is sent, and the user
interacts / it resolves (ps/ac/cc/cf/pc/pf) only after — so the load
lands in one ping and its outcome in the next (or an outcome lands in a ping whose
load already shipped). Within a single ping you can therefore see an outcome with
no matching load, or a load whose outcome never arrives, and a per-ping ratio can
exceed 100%. These straddles are small and mostly cancel in aggregate; a large
imbalance (an outcome far exceeding its load count) instead signals corrupt /
non-organic data — which is why the invalid-ratio checks (Data Analysis
Techniques) use a gross tolerance rather than a strict inequality.
Counters:
arkoselabs_oc — an Arkose puzzle loaded (the game-like "FunCaptcha"). There's
no checkbox to click first, so a load basically means the puzzle appeared.arkoselabs_pc — the puzzle was solved (the Arkose server confirmed "correct").arkoselabs_pf — the puzzle was gotten wrong (the server said "wrong").arkoselabs_solutions_required — how many rounds ("waves") that one puzzle made
the person do. Stored as a spread of values, not a single number.pc/pf come from a network-response observer on the Arkose API (/fc/ca/):
the terminal solved boolean -> pc (true) or pf (false). Arkose has a real
pf fail counter, so pc/(pc+pf) is a genuine solve rate (not a lower bound
like the widget vendors).
NOTE: arkoselabs_ps exists in the schema but is never recorded (always 0) —
nothing in the code increments it. Ignore it. Also solutions_required.count is
the deprecated internal field (bug 1799509, unpopulated) — the sample count is
pc + pf, so mean rounds = solutions_required.sum / (pc + pf).
Ratios (normal browsing):
oc / pages_visited (~0.0043%) — how often an Arkose puzzle shows
up, out of all the pages people load.pc / (pc + pf) (~67%) — of the puzzles that got a clear
win-or-lose answer, the share that were solved. This is the number to trust,
because wins and losses are counted the same way.(pc + pf) / oc (~34%) — ArkoseLabs's version of the interacted
(resolved) rate the other providers have; the dashboard groups it in the
Interacted section. Of the puzzles that loaded, how many we actually saw end
in a win or a loss. Only rough: people leave or close the tab, and we don't
always catch the ending.solutions_required.sum / (pc + pf) (~2.26) — how
many rounds a typical puzzle asks for.pc + pf <= oc), give or
take the rare edge case.NOTE: solutions_required counts waves within a single challenge, not retries.
FunCaptcha shows several image "waves" in sequence; the response observer counts
each /fc/ca/ wave and, when a terminal solved boolean arrives, records one
pc/pf plus that accumulated wave-count, then resets. So the mean-rounds figure
(~2.26) and the solve rate are independent: a 2-wave challenge is still ONE
session ending in ONE outcome, and the waves are not themselves pass/fail events —
so "avg ~2 waves" does NOT imply a ~50% solve rate. The ~67% solve rate means
~33% of decided challenges came back solved=false (genuine, server-confirmed
fails); abandoned challenges never get a terminal boolean, so they are excluded
from both pc and pf (this is why the solve rate is a true rate, not a lower
bound like the widget vendors).
Counters (all scaffolded, never wired up):
awswaf_psawswaf_pcawswaf_pfawswaf_solutions_requiredThey exist in the schema / Glean dictionary, but there is no AWS WAF handler or
recording code (not in this tree, not in searchfox/mozilla-central), and the
production table carries zero data for all of them over 90 days (verified:
max(awswaf_ps)=0, no rows > 0). No ratios are computable. Treat as absent until
an implementation lands; a cleanup bug to either implement or remove these metrics
would be reasonable.
Counters:
cloudflare_turnstile_oc — a Turnstile challenge loaded on the page. Turnstile
usually checks you in the background, so a load does NOT mean the person did
anything.cloudflare_turnstile_cc — the challenge ended in a pass (the green check).
This includes passes that happen automatically with no clicking.cloudflare_turnstile_cf — the challenge ended in a fail.What they mean — the child actor (CaptchaDetectionChild.sys.mjs):
..._oc.#success / #fail div to become
visible -> increments ..._cc or ..._cf. The observer disconnects after
the first outcome, so at most one cc/cf per oc.oc fires when the challenge iframe loads (the handler is constructed on the
iframe-URL match), independent of visibility: a Turnstile that never surfaces a
#success/#fail div (invisible/managed mode, or torn down before it shows)
yields an oc with no cc/cf outcome. Crucially, the handler only
checks which of #success/#fail is displayed — there is no interaction
detection at all. A managed / non-interactive Turnstile that passes on its own
(the success checkmark appears with zero user action) increments cc identically
to a pass the user actually worked through. So cc is not evidence of user
interaction, and Turnstile records only oc/cc/cf (no ps/ac) — automatic
and interactive passes cannot be distinguished. This gap is tracked in bug
2054267 (under meta 2054266): add a Turnstile auto-vs-interactive signal so it
can be segmented like reCAPTCHA/hCaptcha.
Appropriate ratios (oc/cc/cf are separate counters at different lifecycle
points — do NOT use raw cc/oc as a "solve rate"):
cc / (cc + cf) — of the challenges we actually saw finish
(pass or fail), the share that passed. This is the cleanest "did it succeed"
number, and it's always between 0 and 100%. Show cc+cf next to it, and don't
trust it when only a handful of challenges finished.(cc + cf) / oc — of the Turnstiles that loaded, how many we
saw finish at all. The rest never showed a pass or fail (they ran in the
background, got closed, or our detector missed them) — that is NOT the same as
the person "giving up." Despite the name, this is really "how many resolved",
NOT "a human interacted": Turnstile often passes on its own, and those silent
passes still count as cc.oc / pages_visited — how often a Turnstile loads per page.
Very small (around 0.005%). Good for "how common is Turnstile".cc / oc as a "solve rate" — it mostly reflects how often
Turnstile runs in the background, not whether people succeed.Caveat: pass rate is bounded 0-100% by construction; Interacted/Prevalence
are not bounded at daily granularity (see the invariant under Data Analysis).
Counters:
datadome_oc — Datadome stepped in with its full-page gate screen. This only
fires when Datadome actually interrupts the person, not just because the site
uses Datadome.datadome_ps — that screen showed a puzzle to solve.datadome_pc — the puzzle was passed.datadome_bl — the person was hard-blocked (a "no entry" page, no puzzle to
even try).Recording (recordDatadomeEvent): a load reports ps (puzzle) or bl (hard
block) — mutually exclusive; a pass reports pc. So ps + bl <= oc, pc <= ps.
There is no auto-success signal (a silent pass loads no interstitial).
datadome_oc_pbm was missing until bug 2054037 added it in Firefox 154, so
PBM load data only exists from 154 onward (older builds, current ESR included,
report PBM ps/bl/pc but no oc). On those older submissions the column is
NULL rather than 0, so a PBM prevalence denominator built from it silently drops
them instead of dividing by zero — scope any oc_pbm ratio to 154+ explicitly.
Ratios (normal browsing):
oc / pages_visited — how often Datadome interrupts
someone per page (it actually stepped in, not just "the site uses it").bl / (ps + bl) — when Datadome steps in, how often it's a
flat-out block versus a puzzle you can actually solve (~27%). The main Datadome
number.pc / ps — of the puzzles shown, how many got passed.
This is a floor, not the true rate: there is no "failed" counter, the pass can
go uncounted if the page moves on, and lots of people just leave the site — all
of which look like "not solved."bl / pages_visited — how often someone gets hard-blocked per
page. A direct "how much does this hurt people" measure.Dropped as redundant (since ps + bl ≈ oc): puzzle share (= 1 − block share) and
puzzle non-completion (= 1 − solve rate).
Counters:
google_recaptcha_v2_oc — the "I'm not a robot" checkbox loaded on the page.
(Only the visible checkbox kind; the fully-invisible kind isn't counted.) It
fires when the box loads, not when someone clicks it — so most of these are just
sitting on the page, unused.google_recaptcha_v2_ps — the person was shown an image puzzle ("pick all the
buses").google_recaptcha_v2_pc — that image puzzle was solved.google_recaptcha_v2_ac — the person was waved through with no puzzle at all.Recording (updateGRecaptchaV2State): ImagesShown -> ps; a
checkmark -> ac if images were never shown, else pc. Per widget ac and ps
are meant to be mutually exclusive with pc <= ps and ac + ps <= oc — but in
release data ps grossly over-counts (see the "shown" note below), so
ac + ps <= oc does NOT hold in practice. No fail counter.
Interactions = ac + ps.
Ratios (normal browsing):
oc / pages_visited — how often the checkbox loads per page.(ac + ps) / pages_visited (or (ac + ps) / oc per load)
— how often something actually happened with it (a wave-through or a puzzle).
Most loaded boxes never get this far.ac / (ac + ps) — of the boxes that did
something, how many waved the person through with no puzzle.pc / ps — of the puzzles shown, how many got
solved.(ps - pc) / ps — the flip side: fails and
give-ups lumped together (there is no separate "failed" counter).Cross-provider caveat (validated against 90d release data, desktop + Android): pc/ps is a
within-provider lower bound, not a clean solve-success rate, and is not
comparable across providers. The observed ~43% (reCAPTCHA) vs ~94% (hCaptcha)
is NOT relative solve skill; two things drive it:
pc loss + abandonment (the main driver): the post-solve checkmark (pc)
is often lost when solving submits the form / navigates away before it is
recorded, and reCAPTCHA's multi-round image puzzles are genuinely abandoned more
than hCaptcha's. Both push reCAPTCHA's pc/ps down (measurement + behaviour).ps fires on challenge-element
existence (rc-imageselect present in the bframe); hCaptcha ps fires on
visibility (aria-hidden flips). reCAPTCHA's existence check does
over-count, confirmed in 90d release data: ps re-fires on the persistent
element, reaching 76,156 in a single ping and pushing ac + ps > oc for
~165k clients (~0.4%). Being fixed / aligned in bug 2054272 (under meta
2054266). ps is also interaction-gated — only ~1.6% of oc — so it does NOT
mean "the widget was present"; it means a challenge was instantiated for an
engaged user.
Separately, oc counts widget loads: reCAPTCHA sits on ~6% of pageviews with
only ~2.4% ever interacted (passive embeds / periodic token-refresh reloads), so
oc-denominated ratios mean "widget present", not "user challenged". (That passive
majority shows up in the interacted rate, not in pc/ps — ignored widgets never
reach ps.)Counters:
hcaptcha_oc — the hCaptcha checkbox loaded on the page. Like reCAPTCHA, this
fires at load, not when someone actually uses it.hcaptcha_ps — the person was shown the puzzle.hcaptcha_pc — the puzzle was passed.hcaptcha_ac — the person was waved through with no puzzle.Same recording model and mutual exclusivity as reCAPTCHA v2; no
fail counter. Unlike reCAPTCHA, hCaptcha ps fires when the challenge frame
becomes visible (aria-hidden -> false) — a tighter "genuinely shown"
denominator that does not have reCAPTCHA's existence over-count (max ps per ping
~400, not ~76k), so ac + ps <= oc holds far more tightly. That, plus hCaptcha being deployed as hard gates
(~43% of loads are interacted vs reCAPTCHA's ~2.4%), is why its pc/ps runs ~94%:
a measurement + deployment difference, not that hCaptcha is "easier" to solve.
Ratios (normal browsing): identical
shape to reCAPTCHA v2 with the hcaptcha_ prefix — loaded/page oc/pages,
interacted/page (ac+ps)/pages, auto-success% ac/(ac+ps), success pc/ps,
non-completion (ps-pc)/ps.
Improvements to these metrics are tracked under meta bug 2054266:
cc counts silent auto-passes; no ac/ps).ps uses element existence while hCaptcha uses
visibility; investigate and align so "shown" means the same across providers.Keep these in mind when comparing providers: the counters were not all designed to the same definition.
Ratios are provider-specific (see Metrics Overview). Independent of provider, the
techniques below control which rows enter the numerator/denominator. Always
pair a counter with the matching-mode pages_visited and report the denominator
count so small samples are visible.
Build a composite key from the privacy_* / network_cookie_* context metrics
and treat each distinct combination as a cohort. Split into a non-PBM key
(the non-_pbm settings, paired with non-PBM counters) and a PBM key (the
_pbm settings plus the settings that have no PBM variant — cookie behavior,
cryptomining/known-fingerprinter protection — which apply in both modes, paired
with _pbm counters).
Suggested selection rule: include any combination with > 10,000 reporting clients on each of the last 7 days straight (release, non-bot). Firefox settings are overwhelmingly at their defaults, so a handful of profiles cover almost the whole population; everything else is long-tail. Typical profiles:
resistFingerprinting = true, any other setting. A tiny hardened
cohort added explicitly; usually too small for reliable rates.Composite values are platform-specific — do not port desktop composites to Android (or vice versa). The same conceptual profile serializes to different booleans per platform because the products have different defaults and report some settings differently. Known differences (Firefox for Android / Fenix vs desktop):
trackingprotection_pbm_enabled = false where desktop reports true. So the
desktop PBM key matches ~0 Android clients; Android's PBM default is otherwise
identical (rfp_pbm=false, fpp_pbm=true, tp_pbm=false, optin_pbm=false, cryptomining=true, known-fp=true, cookieBehavior=5).preferences.enhanced_tracking_protection (the Fenix metrics ping, the
ground-truth mode): 99.4% of true Strict users report fpp=false — the
same composite as Standard
(rfp=F, fpp=F, tp=F, crypto=T, fp=T, cookie=5, optin=F). True Strict is
~2.46% of Fenix release (~830k clients, comparable to desktop), but the
captcha ping records them as Standard. ROOT CAUSE (confirmed in source): this
metric reads the pref privacy.fingerprintingProtection; GeckoView's engine
setting maps to that same pref, but Fenix sets it AT ENGINE CREATION from a
Nimbus rollout, not from ETP mode. In Core.kt the engine default is gated
on the Nimbus feature:
if (FxNimbus.features.fingerprintingProtection.value().enabled) { defaultSettings.fingerprintingProtection = ...enabledNormal }.
The ETP-Strict->FPP wiring only runs transiently on the settings screen
(TrackingProtectionFragment.updateFingerprintingProtection()). So on
Android fpp tracks the FPP Nimbus experiment, NOT the ETP Standard/Strict
choice. To segment Android by ETP mode, JOIN
preferences.enhanced_tracking_protection by client_id — do not use the
captcha ping's privacy prefs. TIME-SENSITIVE: this reflects pre-fix builds.
Bug 2054072 (D311582) makes Fenix persist the ETP-mode
fingerprinting-protection choice at engine creation; once it rides out, true
Strict Android clients will begin reporting fpp=true, so the
Standard/Strict indistinguishability above (and the 99.4% figure) is specific
to builds without that fix and will drift after it ships.When segmenting by OS/platform, re-derive each platform's modal composite (the most common key) rather than reusing another platform's predicates, and apply the
10k rule within the platform.
Threshold each client-day by the pages that client loaded that day — e.g.
all / >= 20 / >= 200 pages/day (use pages_visited for non-PBM cohorts,
pages_visited_pbm for PBM). Thresholding per client-day rather than on a
whole-window total lets a client fall in a higher tier on a busy day and a lower
one on a quiet day, so each (tier, day) draws its own client set. Notes:
Interacted is very sensitive to the
tier while pass rate is largely tier-insensitive. Always state the tier
with any rate you quote.Weekend and holiday traffic differs materially from weekday traffic. Default to weekdays only and exclude holidays; include weekends/holidays only when the question is specifically about them.
DAYOFWEEK: 1=Sun .. 7=Sat):
EXTRACT(DAYOFWEEK FROM DATE(submission_timestamp)) BETWEEN 2 AND 6.Three complementary layers:
NOT is_bot_generated (BrowserStack) when using the derived
*.captcha_detection views.LHS > 2*RHS + 10) so ordinary cross-ping straddles (a load in
one ping, its outcome in the next) survive. See the invalid ratios below.Structural invariants — a loaded handler yields at most one terminal outcome, and
funnel stages are ordered. Evaluate per ping with the gross tolerance from layer 2
(LHS > 2*RHS + 10, and the _pbm equivalents) and drop the offending ping:
cc + cf <= oc (the outcome observer disconnects
after the first #success/#fail div, so at most one outcome per load).ac + ps <= oc; pc <= ps.ac + ps <= oc; pc <= ps.ps + bl <= oc; pc <= ps. pc_pbm <= ps_pbm holds
throughout, but the _pbm form of ps + bl <= oc is only evaluable on
Firefox 154+ submissions, where datadome_oc_pbm exists (bug 2054037). On
older rows it is NULL: skip the rule there rather than coalescing the load
count to 0, which would flag every PBM ping carrying a Datadome outcome as
invalid.pc + pf <= oc.Caveat on the reCAPTCHA / hCaptcha ac + ps <= oc rule: these vendors' outcome
counters accumulate across pings, and reCAPTCHA ps over-counts by firing on
element existence (bug 2054272; up to 76,156 in a single ping). So ac + ps > oc
predominantly flags that metric bug rather than corrupt data, and is by far the
largest contributor to the drop. Applying it removes those inflated-ps pings;
either way, treat reCAPTCHA ps-based ratios as inflated until 2054272 lands.
(As of 2026-07-13, applying all of the above to the 90-day release population
dropped ~216.5k pings across ~165.8k clients — ~0.1% of pings and ~0.4% of
reCAPTCHA clients; the reCAPTCHA ac + ps > oc rule alone was ~97% of that.)
Data lands in BigQuery as one dedup'd per-app view. Analysis over the whole
release population unions the two apps that carry the ping:
mozdata.firefox_desktop.captcha_detection and mozdata.fenix.captcha_detection
(Android) — views over the respective *_stable.captcha_detection_v1 tables.
Client IDs are per-app namespaces: stack rows with UNION ALL; do NOT join across
apps by client_id (distinct-client counts still sum, since a client is in one
app). Metric columns are nested, e.g.
metrics.counter.captcha_detection_cloudflare_turnstile_oc,
metrics.boolean.captcha_detection_privacy_resistfingerprinting,
metrics.string.captcha_detection_network_cookie_cookiebehavior.
Always filter on submission_timestamp (the partition column) with an explicit
range; a DATE()-wrapped or OR-buried predicate is not accepted for partition
elimination. Then apply channel, the automation/invalid-client exclusions, and
the weekday/holiday filter as appropriate.
6821231
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.