diff --git a/injected/src/content-scope-features.js b/injected/src/content-scope-features.js index 2b062f6c23..f27e01262a 100644 --- a/injected/src/content-scope-features.js +++ b/injected/src/content-scope-features.js @@ -3,6 +3,7 @@ import { platformSupport } from './features'; import { PerformanceMonitor } from './performance'; import platformFeatures from 'ddg:platformFeatures'; import { registerForURLChanges } from './url-change'; +import { initDetectors } from './detectors/detector-init.js'; let initArgs = null; const updates = []; @@ -44,6 +45,14 @@ export function load(args) { const bundledFeatureNames = typeof importConfig.injectName === 'string' ? platformSupport[importConfig.injectName] : []; + // Initialize detectors early so they're available when features init + try { + initDetectors(args.bundledConfig); + } catch (error) { + console.error('[detectors] Initialization failed:', error); + // TODO: Consider firing error pixel if needed + } + // prettier-ignore const featuresToLoad = isGloballyDisabled(args) // if we're globally disabled, only allow `platformSpecificFeatures` diff --git a/injected/src/detectors/README.md b/injected/src/detectors/README.md new file mode 100644 index 0000000000..b4745fff0e --- /dev/null +++ b/injected/src/detectors/README.md @@ -0,0 +1,252 @@ +# Detector Service + +This directory contains a lightweight detector service that runs inside content-scope-scripts. Detectors are automatically registered during the `load()` phase and any feature can query their latest results (breakage reporting, native PIR, debug tooling, etc.). + +The current implementation focuses on synchronous, on-demand collection with caching. Continuous monitoring (mutation observers, polling, batching) can be layered on later without changing the public API. + +## API Snapshot + +```mermaid +sequenceDiagram + participant Feature as Content Feature + participant Service as detectorService + participant Detector as botDetection + + Feature->>Service: getDetectorData('botDetection') + Service->>Detector: getData() + Detector-->>Service: snapshot + Service-->>Feature: snapshot (cached) +``` + +### Core helpers + +- `registerDetector(detectorId, { getData, shouldRun?, refresh?, teardown? })` +- `getDetectorData(detectorId, { maxAgeMs }?)` +- `getDetectorsData(detectorIds, options?)` + +Detectors return arbitrary JSON payloads. Include timestamps if consumers rely on freshness. + +## Directory Layout + +``` +detectors/ +├── detector-service.js # registry + caching service +├── detector-init.js # initializes detectors from bundledConfig +├── default-config.js # default detector settings +├── detections/ +│ ├── bot-detection.js # CAPTCHA/bot detection +│ └── fraud-detection.js # anti-fraud/phishing warnings +└── utils/ + └── detection-utils.js # DOM helpers (selectors, text matching, visibility, domain matching) +``` + +## How It Works + +### Initialization + +Detectors are automatically registered during the content-scope-features `load()` phase: + +1. `content-scope-features.js` calls `initDetectors(bundledConfig)` during page load +2. `detector-init.js` reads the `web-interference-detection` feature config +3. Default detector settings are merged with remote config +4. Detectors are registered with the service using `registerDetector()` +5. After `autoRunDelayMs` delay (default 100ms), detectors with `autoRun: true` execute automatically + - This delay lets the DOM settle after initial page load + - Auto-run calls check gates (domain + `shouldRun()`) + - Results are cached for later manual calls + +### Remote Configuration + +Detectors are controlled via `privacy-configuration/features/web-interference-detection.json`: + +```json +{ + "state": "enabled", + "settings": { + "autoRunDelayMs": 100, + "interferenceTypes": { + "botDetection": { + "hcaptcha": { + "state": "enabled", + "vendor": "hcaptcha", + "selectors": [".h-captcha"], + "windowProperties": ["hcaptcha"] + } + }, + "fraudDetection": { + "phishingWarning": { + "state": "enabled", + "type": "phishing", + "selectors": [".warning-banner"] + } + } + } + } +} +``` + +#### Domain Gating + +Detectors can be restricted to specific domains using a per-detector `domains` field: + +- **Domain patterns**: + - Exact match: `"youtube.com"` + - Wildcard: `"*.youtube.com"` (matches www.youtube.com, m.youtube.com, etc.) + - Substring: `"youtube.com"` also matches `www.youtube.com` for convenience + +Example: +```json +{ + "settings": { + "interferenceTypes": { + "fraudDetection": { + "domains": ["*.bank.com", "*.financial.com"], + ... + } + } + } +} +``` + +#### Auto-Run + +Detectors can be configured to run automatically on page load: + +- **`autoRun: true`** (default): Run detector automatically after page load + - Gates are checked (domain + custom `shouldRun()`) + - Results are cached immediately + - Runs after configurable delay (see `autoRunDelayMs`) + - Useful for detectors that should always gather data (bot detection, fraud detection) + +- **`autoRun: false`**: Only run when explicitly called + - Gates are skipped for manual calls + - Useful for expensive detectors or event-driven scenarios + +- **`autoRunDelayMs`** (global setting, default: 100): Milliseconds to wait before running auto-run detectors + - Allows DOM to settle after page load + - Can be tuned per-site or globally + - Use 0 for immediate execution, higher values for slower-loading pages + - **How it works**: After detectors are registered, a single `setTimeout` schedules all auto-run detectors to execute in batch after the delay + +Example: +```json +"settings": { + "autoRunDelayMs": 250, // Wait 250ms before auto-running detectors + "interferenceTypes": { + "botDetection": { + "autoRun": true, // Run automatically with gates + "domains": ["*.example.com"], + ... + }, + "expensiveDetector": { + "autoRun": false, // Only run on-demand, skip gates + ... + } + } +} +``` + +### Consuming Detector Data + +Features can directly import and use the detector service: + +```javascript +import { getDetectorsData } from '../detectors/detector-service.js'; + +// In breakage reporting feature - gates bypassed automatically for manual calls +const detectorData = await getDetectorsData(['botDetection', 'fraudDetection']); +// Returns: { botDetection: {...}, fraudDetection: {...} } +``` + +**Behavior:** +- **Manual calls** (like above): Gates are bypassed, detector always runs +- **Auto-run calls**: Gates are checked (domain + `shouldRun()`) +- **Caching**: Results cached with timestamp, use `maxAgeMs` to force refresh + +**Options:** +- `maxAgeMs`: Maximum age of cached data in milliseconds before forcing refresh + +## Adding New Detectors + +1. **Create detection logic** under `detections/`: + - Export a `createXDetector(config)` factory function + - Return an object with `{ getData, shouldRun?, refresh?, teardown? }` + - Use shared utilities from `utils/detection-utils.js` + +2. **Add default config** to `default-config.js`: + - Define default selectors, patterns, and settings + - These serve as fallback if remote config is unavailable + +3. **Register in detector-init.js**: + - Import your detector factory + - Add one line: `registerIfEnabled('myDetector', detectorSettings.myDetector, createMyDetector)` + +4. **Add remote config** to `privacy-configuration/features/web-interference-detection.json`: + - Define the detector's configuration schema + - Optionally add `domains` field for domain gating + - This allows remote enabling/disabling and tuning + +5. **Consume the detector** in your feature: + - Import `getDetectorData` or `getDetectorBatch` + - Call with your detector ID to get results + +### Custom Gate Functions + +Detectors can optionally implement a `shouldRun()` gate function for custom precondition checks: + +```javascript +export function createMyDetector(config) { + return { + // Optional gate function runs before getData() + // Return false to skip detection entirely (returns null) + shouldRun() { + // Example: Only run if specific element exists + return document.querySelector('#app-root') !== null; + }, + + async getData() { + // This only runs if shouldRun() returns true + // and domain gate passes + return { detected: true, ... }; + } + }; +} +``` + +**Example: Detector that depends on another detector's results** + +```javascript +import { getDetectorData } from '../detector-service.js'; + +export function createAdvancedBotDetector(config) { + return { + // Only run advanced detection if basic bot detection found something + async shouldRun() { + const basicBotData = await getDetectorData('botDetection'); + // Only run if basic detector found a bot/CAPTCHA + return basicBotData?.detected === true; + }, + + async getData() { + // Run expensive/detailed analysis only when needed + return runAdvancedBotAnalysis(config); + } + }; +} +``` + +**When to use `shouldRun()`:** +- Lightweight DOM precondition checks (e.g., element exists) +- Dependency on another detector's results (use `getDetectorData()` inside `shouldRun()`) +- Runtime feature detection +- Performance optimization to avoid expensive operations + +**Gate execution order:** +1. Domain gate (from config) +2. Custom `shouldRun()` gate (if provided) +3. `getData()` (if all gates pass) + +If any gate fails, `getDetectorData()` returns `null`. + +Future enhancements—shared observers, background aggregation, streaming updates—can build on this service without breaking the public API. + diff --git a/injected/src/detectors/default-config.js b/injected/src/detectors/default-config.js new file mode 100644 index 0000000000..e569ce19e7 --- /dev/null +++ b/injected/src/detectors/default-config.js @@ -0,0 +1,53 @@ +export const DEFAULT_DETECTOR_SETTINGS = Object.freeze({ + botDetection: { + cloudflareTurnstile: { + state: 'enabled', + vendor: 'cloudflare', + selectors: ['.cf-turnstile', 'script[src*="challenges.cloudflare.com"]'], + windowProperties: ['turnstile'], + statusSelectors: [ + { + status: 'solved', + selectors: ['[data-state="success"]'], + }, + { + status: 'failed', + selectors: ['[data-state="error"]'], + }, + ], + }, + cloudflareChallengePage: { + state: 'enabled', + vendor: 'cloudflare', + selectors: ['#challenge-form', '.cf-browser-verification', '#cf-wrapper', 'script[src*="challenges.cloudflare.com"]'], + windowProperties: ['_cf_chl_opt', '__CF$cv$params', 'cfjsd'], + }, + hcaptcha: { + state: 'enabled', + vendor: 'hcaptcha', + selectors: [ + '.h-captcha', + '[data-hcaptcha-widget-id]', + 'script[src*="hcaptcha.com"]', + 'script[src*="assets.hcaptcha.com"]', + ], + windowProperties: ['hcaptcha'], + }, + }, + fraudDetection: { + phishingWarning: { + state: 'enabled', + type: 'phishing', + selectors: ['.warning-banner', '#security-alert'], + textPatterns: ['suspicious.*activity', 'unusual.*login', 'verify.*account'], + textSources: ['innerText'], + }, + accountSuspension: { + state: 'enabled', + type: 'suspension', + selectors: ['.account-suspended', '#suspension-notice'], + textPatterns: ['account.*suspended', 'access.*restricted'], + textSources: ['innerText'], + }, + }, +}); diff --git a/injected/src/detectors/detections/bot-detection.js b/injected/src/detectors/detections/bot-detection.js new file mode 100644 index 0000000000..9eeee4ada5 --- /dev/null +++ b/injected/src/detectors/detections/bot-detection.js @@ -0,0 +1,58 @@ +import { checkSelectors, checkWindowProperties, matchesSelectors, matchesTextPatterns } from '../utils/detection-utils.js'; + +/** + * Create a detector registration for CAPTCHA/bot detection. + * @param {Record} config + */ +export function createBotDetector(config = {}) { + return { + getData() { + return runBotDetection(config); + }, + }; +} + +/** + * Run detection immediately and return structured results. + * @param {Record} config + */ +export function runBotDetection(config = {}) { + const results = Object.entries(config) + .filter(([_, challengeConfig]) => challengeConfig?.state === 'enabled') + .map(([challengeId, challengeConfig]) => { + const detected = checkSelectors(challengeConfig.selectors) || checkWindowProperties(challengeConfig.windowProperties || []); + if (!detected) { + return null; + } + + const challengeStatus = findStatus(challengeConfig.statusSelectors); + return { + detected: true, + vendor: challengeConfig.vendor, + challengeType: challengeId, + challengeStatus, + }; + }) + .filter(Boolean); + + return { + detected: results.length > 0, + type: 'botDetection', + results, + timestamp: Date.now(), + }; +} + +function findStatus(statusSelectors) { + if (!Array.isArray(statusSelectors)) { + return null; + } + + const match = statusSelectors.find((statusConfig) => { + const { selectors, textPatterns, textSources } = statusConfig; + return matchesSelectors(selectors) || matchesTextPatterns(document.body, textPatterns, textSources); + }); + + return match?.status ?? null; +} + diff --git a/injected/src/detectors/detections/fraud-detection.js b/injected/src/detectors/detections/fraud-detection.js new file mode 100644 index 0000000000..92dbe74b3e --- /dev/null +++ b/injected/src/detectors/detections/fraud-detection.js @@ -0,0 +1,36 @@ +import { checkSelectorsWithVisibility, checkTextPatterns } from '../utils/detection-utils.js'; + +export function createFraudDetector(config = {}) { + return { + getData() { + return runFraudDetection(config); + }, + }; +} + +export function runFraudDetection(config = {}) { + const results = Object.entries(config) + .filter(([_, alertConfig]) => alertConfig?.state === 'enabled') + .map(([alertId, alertConfig]) => { + const detected = + checkSelectorsWithVisibility(alertConfig.selectors) || + checkTextPatterns(alertConfig.textPatterns, alertConfig.textSources); + if (!detected) { + return null; + } + + return { + detected: true, + alertId, + category: alertConfig.type, + }; + }) + .filter(Boolean); + + return { + detected: results.length > 0, + type: 'fraudDetection', + results, + timestamp: Date.now(), + }; +} diff --git a/injected/src/detectors/detector-init.js b/injected/src/detectors/detector-init.js new file mode 100644 index 0000000000..9d83955b29 --- /dev/null +++ b/injected/src/detectors/detector-init.js @@ -0,0 +1,133 @@ +/** + * Detector Initialization + * + * Reads bundledConfig and registers detectors with the detector service. + * Called during content-scope-features load phase. + */ + +import { registerDetector } from './detector-service.js'; +import { DEFAULT_DETECTOR_SETTINGS } from './default-config.js'; +import { createBotDetector } from './detections/bot-detection.js'; +import { createFraudDetector } from './detections/fraud-detection.js'; +import { matchesDomainPatterns } from './utils/detection-utils.js'; + +/** + * Check if gates should run for this call + * @param {Object} options - Options passed to getData + * @param {boolean} [options._autoRun] - Internal flag indicating this is an auto-run call + * @param {string[]} [domains] - Domain patterns to check + * @param {Function} [shouldRun] - Custom gate function + * @returns {boolean} True if gates pass, false otherwise + */ +function checkGates(options, domains, shouldRun) { + // Only check gates for auto-run calls + // Manual calls bypass gates by default + if (!options?._autoRun) { + return true; + } + + // Check domain gate + if (!matchesDomainPatterns(domains)) { + return false; + } + + // Check custom gate function if provided + if (shouldRun && !shouldRun()) { + return false; + } + + return true; +} + +/** + * Create a gated detector registration that checks domain and custom gates + * Gates only apply for auto-run calls (when _autoRun: true is passed) + * Manual calls bypass gates by default + * + * @param {Object} registration - The detector registration object + * @param {Function} registration.getData - Function to get detector data + * @param {Function} [registration.shouldRun] - Optional gate function + * @param {Function} [registration.refresh] - Optional refresh function + * @param {Function} [registration.teardown] - Optional teardown function + * @param {string[]} [domains] - Optional array of domain patterns + * @returns {Object} Gated detector registration + */ +function createGatedDetector(registration, domains) { + const { getData, shouldRun, refresh, teardown } = registration; + + return { + getData: async (options) => { + // Check gates (only for auto-run, manual calls bypass) + if (!checkGates(options, domains, shouldRun)) { + return null; + } + + // All gates passed, run the detector + return await getData(options); + }, + refresh: refresh ? async (options) => { + if (!checkGates(options, domains, shouldRun)) { + return null; + } + return await refresh(options); + } : undefined, + teardown, + }; +} + +/** + * Initialize detectors based on bundled configuration + * @param {any} bundledConfig - The bundled configuration object + */ +export function initDetectors(bundledConfig) { + // Check if web-interference-detection feature is enabled + const enabled = bundledConfig?.features?.['web-interference-detection']?.state === 'enabled'; + if (!enabled) { + return; + } + + // Merge default settings with remote config + const detectorSettings = { + ...DEFAULT_DETECTOR_SETTINGS, + ...bundledConfig?.features?.['web-interference-detection']?.settings?.interferenceTypes, + }; + + // Track detectors to auto-run after registration + const autoRunDetectors = []; + + // Helper to register a detector with less repetition + const registerIfEnabled = (detectorId, detectorSettings, createDetectorFn) => { + if (!detectorSettings) return; + + const domains = detectorSettings.domains; + const autoRun = detectorSettings.autoRun !== false; // Default true + const registration = createDetectorFn(detectorSettings); + + registerDetector(detectorId, createGatedDetector(registration, domains)); + + if (autoRun) { + autoRunDetectors.push(detectorId); + } + }; + + // Register each detector if its settings exist + registerIfEnabled('botDetection', detectorSettings.botDetection, createBotDetector); + registerIfEnabled('fraudDetection', detectorSettings.fraudDetection, createFraudDetector); + + // Auto-run detectors after a short delay to let page settle + if (autoRunDetectors.length > 0) { + // Get delay from config, default to 100ms + const autoRunDelayMs = bundledConfig?.features?.['web-interference-detection']?.settings?.autoRunDelayMs ?? 100; + + // Use setTimeout to avoid blocking page load + setTimeout(async () => { + const { getDetectorsData } = await import('./detector-service.js'); + + // Run all auto-run detectors with _autoRun flag (gates will be checked) + await getDetectorsData(autoRunDetectors, { _autoRun: true }); + + console.log('[detectors] Auto-run complete for:', autoRunDetectors); + }, autoRunDelayMs); + } +} + diff --git a/injected/src/detectors/detector-service.js b/injected/src/detectors/detector-service.js new file mode 100644 index 0000000000..19ce471666 --- /dev/null +++ b/injected/src/detectors/detector-service.js @@ -0,0 +1,84 @@ +/** + * Detector Service + * + * Central registry and caching layer for interference detectors. + * Provides a simple API for registering detectors and retrieving their data. + */ + +const registrations = new Map(); +const cache = new Map(); + +/** + * @typedef {Object} DetectorRegistration + * @property {() => Promise} getData - Function to get current detector data + * @property {() => Promise} [refresh] - Optional function to refresh/re-run detection + */ + +/** + * @typedef {Object} CachedSnapshot + * @property {any} data - The cached detector data + * @property {number} ts - Timestamp when data was cached + */ + +/** + * Register a detector with the service + * @param {string} detectorId - Unique identifier for the detector + * @param {DetectorRegistration} registration - Detector registration object + */ +export function registerDetector(detectorId, registration) { + registrations.set(detectorId, registration); +} + +/** + * Get data from a specific detector + * @param {string} detectorId - Unique identifier for the detector + * @param {Object} [options] - Options for data retrieval + * @param {number} [options.maxAgeMs] - Maximum age of cached data in milliseconds + * @param {boolean} [options._autoRun] - Internal flag indicating auto-run (gates apply) + * @returns {Promise} Detector data or null if not registered + */ +export async function getDetectorData(detectorId, options = {}) { + const { maxAgeMs } = options; + // Include URL in cache key to handle SPA navigation (e.g., YouTube) + const cacheKey = `${detectorId}:${location.href}`; + const cached = /** @type {CachedSnapshot | undefined} */ (cache.get(cacheKey)); + + if (cached) { + const age = Date.now() - cached.ts; + if (!maxAgeMs || age <= maxAgeMs) { + return cached.data; + } + } + + const registration = registrations.get(detectorId); + if (!registration) { + return null; + } + + const runner = registration.refresh ?? registration.getData; + try { + // Pass options to the runner so gates can check _autoRun flag + const data = await runner(options); + cache.set(cacheKey, { data, ts: Date.now() }); + return data; + } catch (error) { + console.error(`[detectorService] Failed to fetch data for ${detectorId}`, error); + return null; + } +} + +/** + * Get data from multiple detectors in a single call + * @param {string[]} detectorIds - Array of detector IDs + * @param {Object} [options] - Options for data retrieval + * @param {number} [options.maxAgeMs] - Maximum age of cached data in milliseconds + * @param {boolean} [options._autoRun] - Internal flag indicating auto-run (gates apply) + * @returns {Promise>} Object mapping detector IDs to their data + */ +export async function getDetectorsData(detectorIds, options = {}) { + const results = {}; + for (const detectorId of detectorIds) { + results[detectorId] = await getDetectorData(detectorId, options); + } + return results; +} diff --git a/injected/src/detectors/utils/detection-utils.js b/injected/src/detectors/utils/detection-utils.js new file mode 100644 index 0000000000..7c63d2ce89 --- /dev/null +++ b/injected/src/detectors/utils/detection-utils.js @@ -0,0 +1,153 @@ +/** + * @param {string[]} [selectors] + * @returns {boolean} + */ +export function checkSelectors(selectors) { + if (!selectors || !Array.isArray(selectors)) { + return false; + } + return selectors.some((selector) => document.querySelector(selector)); +} + +/** + * @param {string[]} [selectors] + * @returns {boolean} + */ +export function checkSelectorsWithVisibility(selectors) { + if (!selectors || !Array.isArray(selectors)) { + return false; + } + return selectors.some((selector) => { + const element = document.querySelector(selector); + return element && isVisible(element); + }); +} + +/** + * @param {string[]} [properties] + * @returns {boolean} + */ +export function checkWindowProperties(properties) { + if (!properties || !Array.isArray(properties)) { + return false; + } + return properties.some((prop) => typeof window?.[prop] !== 'undefined'); +} + +/** + * @param {Element} element + * @returns {boolean} + */ +export function isVisible(element) { + const computedStyle = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return ( + rect.width > 0.5 && + rect.height > 0.5 && + computedStyle.display !== 'none' && + computedStyle.visibility !== 'hidden' && + +computedStyle.opacity > 0.05 + ); +} + +/** + * @param {Element} element + * @param {string[]} [sources] + * @returns {string} + */ +export function getTextContent(element, sources) { + if (!sources || sources.length === 0) { + return element.textContent || ''; + } + return sources.map((source) => element[source] || '').join(' '); +} + +/** + * @param {string[]} [selectors] + * @returns {boolean} + */ +export function matchesSelectors(selectors) { + if (!selectors || !Array.isArray(selectors)) { + return false; + } + const elements = queryAllSelectors(selectors); + return elements.length > 0; +} + +/** + * @param {Element} element + * @param {string[]} [patterns] + * @param {string[]} [sources] + * @returns {boolean} + */ +export function matchesTextPatterns(element, patterns, sources) { + if (!patterns || !Array.isArray(patterns)) { + return false; + } + const text = getTextContent(element, sources); + return patterns.some((pattern) => { + const regex = new RegExp(pattern, 'i'); + return regex.test(text); + }); +} + +/** + * @param {string[]} [patterns] + * @param {string[]} [sources] + * @returns {boolean} + */ +export function checkTextPatterns(patterns, sources) { + if (!patterns || !Array.isArray(patterns)) { + return false; + } + return matchesTextPatterns(document.body, patterns, sources); +} + +/** + * @param {string[]} selectors + * @param {Element|Document} [root] + * @returns {Element[]} + */ +export function queryAllSelectors(selectors, root = document) { + if (!selectors || !Array.isArray(selectors) || selectors.length === 0) { + return []; + } + const elements = root.querySelectorAll(selectors.join(',')); + return Array.from(elements); +} + +/** + * Check if current domain matches any patterns in the domains list + * Supports exact matches, wildcards (*.domain.com), and substring matching + * + * @param {string[]} [domains] - Array of domain patterns + * @returns {boolean} True if current domain matches any pattern, or if no patterns provided + * + * @example + * matchesDomainPatterns(['youtube.com']) // Exact or substring match + * matchesDomainPatterns(['*.youtube.com']) // Wildcard match (www.youtube.com, m.youtube.com) + * matchesDomainPatterns([]) // Empty = match all domains + */ +export function matchesDomainPatterns(domains) { + if (!domains || !Array.isArray(domains) || domains.length === 0) { + return true; // No domain restrictions means match all + } + + const hostname = window.location.hostname; + + return domains.some((pattern) => { + // Exact match + if (pattern === hostname) { + return true; + } + + // Wildcard pattern (e.g., "*.youtube.com" or "youtube.*") + if (pattern.includes('*')) { + const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$'); + return regex.test(hostname); + } + + // Substring match for convenience (e.g., "youtube.com" matches "www.youtube.com") + return hostname.includes(pattern); + }); +} diff --git a/injected/src/features/breakage-reporting.js b/injected/src/features/breakage-reporting.js index b94172bfa7..e350185bd3 100644 --- a/injected/src/features/breakage-reporting.js +++ b/injected/src/features/breakage-reporting.js @@ -1,5 +1,6 @@ import ContentFeature from '../content-feature'; import { getExpandedPerformanceMetrics, getJsPerformanceMetrics } from './breakage-reporting/utils.js'; +import { getDetectorsData } from '../detectors/detector-service.js'; export default class BreakageReporting extends ContentFeature { init() { @@ -7,9 +8,17 @@ export default class BreakageReporting extends ContentFeature { this.messaging.subscribe('getBreakageReportValues', async () => { const jsPerformance = getJsPerformanceMetrics(); const referrer = document.referrer; + + // Collect detector data (gates bypassed by default for manual calls) + const detectorData = await getDetectorsData([ + 'botDetection', + 'fraudDetection' + ]); + const result = { jsPerformance, referrer, + detectorData, }; if (isExpandedPerformanceMetricsEnabled) { const expandedPerformanceMetrics = await getExpandedPerformanceMetrics();