Skip to content

Commit 5d31ab4

Browse files
stanchclaude
andcommitted
fix(browser-plugin-bot-detection): exclude BotD's spurious plugins-length detector
BotD's `detectPluginsLengthInconsistency` reports any non-Android Chromium browser with `navigator.plugins.length === 0` as headless Chrome. Chrome's `--headless=new` mode now reports plugins like a headed browser, while privacy-focused browsers legitimately report none, so the check only yields false positives. Rather than patch the dependency, compose BotD's exported `collect`/`detect` with our own detector set. A pnpm patch would only reach the UMD build, since the ESM build keeps `@fingerprintjs/botd` external and npm consumers would resolve their own unpatched copy. Mirrors fingerprintjs/BotD#194, which is approved upstream but unmerged and so absent from botd 2.0.0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 53f668a commit 5d31ab4

4 files changed

Lines changed: 49 additions & 22 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"changes": [
3+
{
4+
"comment": "Exclude BotD's spurious plugins-length headless Chrome detector",
5+
"type": "none",
6+
"packageName": "@snowplow/browser-plugin-bot-detection"
7+
}
8+
],
9+
"packageName": "@snowplow/browser-plugin-bot-detection",
10+
"email": "nick.stanch@snowplowanalytics.com"
11+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { detectors } from '@fingerprintjs/botd';
2+
3+
type Detector = (typeof detectors)[keyof typeof detectors];
4+
5+
/**
6+
* BotD's default detectors, minus `detectPluginsLengthInconsistency`.
7+
*
8+
* This detector is considered spurious, see https://github.com/fingerprintjs/BotD/pull/194.
9+
*
10+
* TODO: remove once the PR is merged upstream.
11+
*/
12+
export const activeDetectors: Record<string, Detector> = { ...detectors };
13+
delete activeDetectors.detectPluginsLengthInconsistency;

plugins/browser-plugin-bot-detection/src/index.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { BrowserPlugin } from '@snowplow/browser-tracker-core';
22
import { LOG } from '@snowplow/tracker-core';
3-
import { load } from '@fingerprintjs/botd';
3+
import { collect, detect, sources } from '@fingerprintjs/botd';
44
import { CLIENT_SIDE_BOT_DETECTION_SCHEMA } from './schemata';
5+
import { activeDetectors } from './detectors';
56
import { BotDetectionContextData } from './types';
67

78
export { BotDetectionContextData, BotKind } from './types';
@@ -14,12 +15,14 @@ export function BotDetectionPlugin(): BrowserPlugin {
1415
activateBrowserPlugin: () => {
1516
if (!detectionStarted) {
1617
detectionStarted = true;
17-
load()
18-
.then((detector) => detector.detect())
18+
// Equivalent to BotD's `load().then((d) => d.detect())`,
19+
// but with our own detector set (see `./detectors`)
20+
collect(sources)
21+
.then((components) => detect(components, activeDetectors)[1])
1922
.then((result) => {
2023
contextData = result.bot ? { bot: true, kind: result.botKind } : { bot: false, kind: null };
2124
})
22-
.catch((err) => LOG.error('BotDetectionPlugin: BotD load/detect failed', err));
25+
.catch((err) => LOG.error('BotDetectionPlugin: BotD collect/detect failed', err));
2326
}
2427
},
2528
contexts: () => {

plugins/browser-plugin-bot-detection/test/bot-detection.test.ts

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,31 +5,31 @@ import { setImmediate } from 'timers';
55
const flushPromises = () => new Promise(setImmediate);
66

77
let mockDetectResult: any = { bot: false };
8-
let mockLoadReject: Error | null = null;
9-
let mockDetectReject: Error | null = null;
8+
let mockCollectReject: Error | null = null;
9+
let mockDetectThrow: Error | null = null;
1010

1111
jest.mock('@fingerprintjs/botd', () => ({
12-
load: () => {
13-
if (mockLoadReject) {
14-
return Promise.reject(mockLoadReject);
12+
...jest.requireActual('@fingerprintjs/botd'),
13+
collect: () => {
14+
if (mockCollectReject) {
15+
return Promise.reject(mockCollectReject);
1516
}
16-
return Promise.resolve({
17-
detect: () => {
18-
if (mockDetectReject) {
19-
return Promise.reject(mockDetectReject);
20-
}
21-
return Promise.resolve(mockDetectResult);
22-
},
23-
});
17+
return Promise.resolve({});
18+
},
19+
detect: () => {
20+
if (mockDetectThrow) {
21+
throw mockDetectThrow;
22+
}
23+
return [{}, mockDetectResult];
2424
},
2525
}));
2626

2727
describe('BotDetectionPlugin', () => {
2828
beforeEach(() => {
2929
jest.resetModules();
3030
mockDetectResult = { bot: false };
31-
mockLoadReject = null;
32-
mockDetectReject = null;
31+
mockCollectReject = null;
32+
mockDetectThrow = null;
3333
});
3434

3535
it('attaches bot context when a bot is detected', async () => {
@@ -107,8 +107,8 @@ describe('BotDetectionPlugin', () => {
107107
core.track(buildLinkClick({ targetUrl: 'https://example.com' }));
108108
});
109109

110-
it('returns empty contexts when load() fails', async () => {
111-
mockLoadReject = new Error('load failed');
110+
it('returns empty contexts when collect() fails', async () => {
111+
mockCollectReject = new Error('collect failed');
112112

113113
const { BotDetectionPlugin } = require('../src');
114114
const plugin = BotDetectionPlugin();
@@ -128,7 +128,7 @@ describe('BotDetectionPlugin', () => {
128128
});
129129

130130
it('returns empty contexts when detect() fails', async () => {
131-
mockDetectReject = new Error('detect failed');
131+
mockDetectThrow = new Error('detect failed');
132132

133133
const { BotDetectionPlugin } = require('../src');
134134
const plugin = BotDetectionPlugin();

0 commit comments

Comments
 (0)