diff --git a/common/changes/@snowplow/browser-plugin-bot-detection/bot-detection-plugin_2026-03-17-17-11.json b/common/changes/@snowplow/browser-plugin-bot-detection/bot-detection-plugin_2026-03-17-17-11.json new file mode 100644 index 000000000..4ac37d3ca --- /dev/null +++ b/common/changes/@snowplow/browser-plugin-bot-detection/bot-detection-plugin_2026-03-17-17-11.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "Add browser-plugin-bot-detection plugin wrapping FingerprintJS BotD for client-side bot detection", + "type": "minor", + "packageName": "@snowplow/browser-plugin-bot-detection" + } + ], + "packageName": "@snowplow/browser-plugin-bot-detection", + "email": "nick.stanch@snowplowanalytics.com" +} \ No newline at end of file diff --git a/common/config/rush/browser-approved-packages.json b/common/config/rush/browser-approved-packages.json index 091e52eb9..feadd0224 100644 --- a/common/config/rush/browser-approved-packages.json +++ b/common/config/rush/browser-approved-packages.json @@ -6,6 +6,10 @@ "name": "@ampproject/rollup-plugin-closure-compiler", "allowedCategories": [ "libraries", "plugins", "trackers" ] }, + { + "name": "@fingerprintjs/botd", + "allowedCategories": [ "plugins" ] + }, { "name": "@react-native-async-storage/async-storage", "allowedCategories": [ "trackers" ] @@ -42,6 +46,10 @@ "name": "@snowplow/browser-plugin-ad-tracking", "allowedCategories": [ "trackers" ] }, + { + "name": "@snowplow/browser-plugin-bot-detection", + "allowedCategories": [ "trackers" ] + }, { "name": "@snowplow/browser-plugin-browser-features", "allowedCategories": [ "trackers" ] diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index c6895c636..72f87516b 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -235,6 +235,79 @@ importers: specifier: ~4.6.2 version: 4.6.4 + ../../plugins/browser-plugin-bot-detection: + dependencies: + '@fingerprintjs/botd': + specifier: 2.0.0 + version: 2.0.0 + '@snowplow/browser-tracker-core': + specifier: workspace:* + version: link:../../libraries/browser-tracker-core + '@snowplow/tracker-core': + specifier: workspace:* + version: link:../../libraries/tracker-core + tslib: + specifier: ^2.3.1 + version: 2.8.1 + devDependencies: + '@ampproject/rollup-plugin-closure-compiler': + specifier: ~0.27.0 + version: 0.27.0(rollup@2.70.2) + '@rollup/plugin-commonjs': + specifier: ~21.0.2 + version: 21.0.3(rollup@2.70.2) + '@rollup/plugin-node-resolve': + specifier: ~13.1.3 + version: 13.1.3(rollup@2.70.2) + '@types/jest': + specifier: ~28.1.1 + version: 28.1.8 + '@types/jsdom': + specifier: ~16.2.14 + version: 16.2.15 + '@typescript-eslint/eslint-plugin': + specifier: ~5.15.0 + version: 5.15.0(@typescript-eslint/parser@5.15.0(eslint@8.11.0)(typescript@4.6.4))(eslint@8.11.0)(typescript@4.6.4) + '@typescript-eslint/parser': + specifier: ~5.15.0 + version: 5.15.0(eslint@8.11.0)(typescript@4.6.4) + eslint: + specifier: ~8.11.0 + version: 8.11.0 + jest: + specifier: ~28.1.3 + version: 28.1.3(@types/node@20.17.10)(ts-node@10.9.2(@types/node@20.17.10)(typescript@4.6.4)) + jest-environment-jsdom: + specifier: ~28.1.3 + version: 28.1.3 + jest-environment-jsdom-global: + specifier: ~4.0.0 + version: 4.0.0(jest-environment-jsdom@28.1.3) + jest-standard-reporter: + specifier: ~2.0.0 + version: 2.0.0 + rollup: + specifier: ~2.70.1 + version: 2.70.2 + rollup-plugin-cleanup: + specifier: ~3.2.1 + version: 3.2.1(rollup@2.70.2) + rollup-plugin-license: + specifier: ~2.6.1 + version: 2.6.1(rollup@2.70.2) + rollup-plugin-terser: + specifier: ~7.0.2 + version: 7.0.2(rollup@2.70.2) + rollup-plugin-ts: + specifier: ~2.0.5 + version: 2.0.7(@babel/core@7.26.0)(@babel/plugin-transform-runtime@7.25.9(@babel/core@7.26.0))(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@babel/runtime@7.26.0)(rollup@2.70.2)(typescript@4.6.4) + ts-jest: + specifier: ~28.0.8 + version: 28.0.8(@babel/core@7.26.0)(@jest/types@28.1.3)(babel-jest@28.1.3(@babel/core@7.26.0))(jest@28.1.3(@types/node@20.17.10)(ts-node@10.9.2(@types/node@20.17.10)(typescript@4.6.4)))(typescript@4.6.4) + typescript: + specifier: ~4.6.2 + version: 4.6.4 + ../../plugins/browser-plugin-button-click-tracking: dependencies: '@snowplow/browser-tracker-core': @@ -3428,6 +3501,9 @@ packages: resolution: {integrity: sha512-XXrH9Uarn0stsyldqDYq8r++mROmWRI1xKMXa640Bb//SY1+ECYX6VzT6Lcx5frD0V30XieqJ0oX9I2Xj5aoMA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@fingerprintjs/botd@2.0.0': + resolution: {integrity: sha512-yhuz23NKEcBDTHmGz/ULrXlGnbHenO+xZmVwuBkuqHUkqvaZ5TAA0kAgcRy4Wyo5dIBdkIf57UXX8/c9UlMLJg==} + '@gar/promisify@1.1.3': resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} @@ -10140,6 +10216,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@fingerprintjs/botd@2.0.0': {} + '@gar/promisify@1.1.3': {} '@hapi/hoek@9.3.0': {} diff --git a/common/config/rush/repo-state.json b/common/config/rush/repo-state.json index f780a410f..6ed643312 100644 --- a/common/config/rush/repo-state.json +++ b/common/config/rush/repo-state.json @@ -1,5 +1,5 @@ // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. { - "pnpmShrinkwrapHash": "1bbfee8474092dd7a04f769c89f237e4c74bfbb5", + "pnpmShrinkwrapHash": "d3b2991182c4ca5879833e9c94d65eb10cb6c7f1", "preferredVersionsHash": "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f" } diff --git a/plugins/browser-plugin-bot-detection/LICENSE b/plugins/browser-plugin-bot-detection/LICENSE new file mode 100644 index 000000000..76f1946ea --- /dev/null +++ b/plugins/browser-plugin-bot-detection/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2022 Snowplow Analytics Ltd, 2010 Anthon Pang +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/plugins/browser-plugin-bot-detection/README.md b/plugins/browser-plugin-bot-detection/README.md new file mode 100644 index 000000000..bea8bd5e5 --- /dev/null +++ b/plugins/browser-plugin-bot-detection/README.md @@ -0,0 +1,59 @@ +# Snowplow Bot Detection + +[![npm version][npm-image]][npm-url] +[![License][license-image]](LICENSE) + +Browser Plugin to be used with `@snowplow/browser-tracker`. + +Detects bots client-side using [FingerprintJS BotD](https://github.com/nicedoc/fingerprintjs-botd) and attaches the result as a `client_side_bot_detection` context entity to all tracked events. + +## Maintainer quick start + +Part of the Snowplow JavaScript Tracker monorepo. +Build with [Node.js](https://nodejs.org/en/) (18 - 20) and [Rush](https://rushjs.io/). + +### Setup repository + +```bash +npm install -g @microsoft/rush +git clone https://github.com/snowplow/snowplow-javascript-tracker.git +rush update +``` + +## Package Installation + +With npm: + +```bash +npm install @snowplow/browser-plugin-bot-detection +``` + +## Usage + +Initialize your tracker with the BotDetectionPlugin: + +```js +import { newTracker } from '@snowplow/browser-tracker'; +import { BotDetectionPlugin } from '@snowplow/browser-plugin-bot-detection'; + +newTracker('sp1', '{{collector}}', { plugins: [ BotDetectionPlugin() ] }); +``` + +Once detection completes, a `client_side_bot_detection` context entity will be attached to all subsequent events with the following fields: + +- `bot` (boolean): Whether a bot was detected. +- `kind` (string | null): The type of bot detected (e.g. `"selenium"`, `"phantomjs"`), or `null` if no bot was found. + +## Copyright and license + +Licensed and distributed under the [BSD 3-Clause License](LICENSE) ([An OSI Approved License][osi]). + +Copyright (c) 2022 Snowplow Analytics Ltd, 2010 Anthon Pang. + +All rights reserved. + +[npm-url]: https://www.npmjs.com/package/@snowplow/browser-plugin-bot-detection +[npm-image]: https://img.shields.io/npm/v/@snowplow/browser-plugin-bot-detection +[docs]: https://docs.snowplowanalytics.com/docs/collecting-data/collecting-from-own-applications/javascript-tracker/ +[osi]: https://opensource.org/licenses/BSD-3-Clause +[license-image]: https://img.shields.io/npm/l/@snowplow/browser-plugin-bot-detection diff --git a/plugins/browser-plugin-bot-detection/jest.config.js b/plugins/browser-plugin-bot-detection/jest.config.js new file mode 100644 index 000000000..87d15da9b --- /dev/null +++ b/plugins/browser-plugin-bot-detection/jest.config.js @@ -0,0 +1,6 @@ +module.exports = { + preset: 'ts-jest', + reporters: ['jest-standard-reporter'], + setupFilesAfterEnv: ['../../setupTestGlobals.ts'], + testEnvironment: 'jest-environment-jsdom-global', +}; diff --git a/plugins/browser-plugin-bot-detection/package.json b/plugins/browser-plugin-bot-detection/package.json new file mode 100644 index 000000000..fd2035cd6 --- /dev/null +++ b/plugins/browser-plugin-bot-detection/package.json @@ -0,0 +1,54 @@ +{ + "name": "@snowplow/browser-plugin-bot-detection", + "version": "4.6.9", + "description": "Detects bots client-side using FingerprintJS BotD and attaches the result as a context entity.", + "homepage": "https://docs.snowplow.io/", + "bugs": "https://github.com/snowplow/snowplow-javascript-tracker/issues", + "repository": { + "type": "git", + "url": "https://github.com/snowplow/snowplow-javascript-tracker.git" + }, + "license": "BSD-3-Clause", + "author": "Snowplow Analytics Ltd (https://snowplow.io/)", + "sideEffects": false, + "main": "./dist/index.umd.js", + "module": "./dist/index.module.js", + "types": "./dist/index.module.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "rollup -c --silent --failAfterWarnings", + "test": "jest" + }, + "dependencies": { + "@fingerprintjs/botd": "2.0.0", + "@snowplow/browser-tracker-core": "workspace:*", + "@snowplow/tracker-core": "workspace:*", + "tslib": "^2.3.1" + }, + "devDependencies": { + "@ampproject/rollup-plugin-closure-compiler": "~0.27.0", + "@rollup/plugin-commonjs": "~21.0.2", + "@rollup/plugin-node-resolve": "~13.1.3", + "@types/jest": "~28.1.1", + "@types/jsdom": "~16.2.14", + "@typescript-eslint/eslint-plugin": "~5.15.0", + "@typescript-eslint/parser": "~5.15.0", + "eslint": "~8.11.0", + "jest": "~28.1.3", + "jest-environment-jsdom": "~28.1.3", + "jest-environment-jsdom-global": "~4.0.0", + "jest-standard-reporter": "~2.0.0", + "rollup": "~2.70.1", + "rollup-plugin-cleanup": "~3.2.1", + "rollup-plugin-license": "~2.6.1", + "rollup-plugin-terser": "~7.0.2", + "rollup-plugin-ts": "~2.0.5", + "ts-jest": "~28.0.8", + "typescript": "~4.6.2" + }, + "peerDependencies": { + "@snowplow/browser-tracker": "~4.6.9" + } +} diff --git a/plugins/browser-plugin-bot-detection/rollup.config.js b/plugins/browser-plugin-bot-detection/rollup.config.js new file mode 100644 index 000000000..fe08aba48 --- /dev/null +++ b/plugins/browser-plugin-bot-detection/rollup.config.js @@ -0,0 +1,37 @@ +import { nodeResolve } from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import ts from 'rollup-plugin-ts'; // Preferred over @rollup/plugin-typescript as it bundles .d.ts files +import { banner } from '../../banner'; +import compiler from '@ampproject/rollup-plugin-closure-compiler'; +import { terser } from 'rollup-plugin-terser'; +import cleanup from 'rollup-plugin-cleanup'; +import pkg from './package.json'; +import { builtinModules } from 'module'; + +const umdPlugins = [nodeResolve({ browser: true }), commonjs(), ts()]; +const umdName = 'snowplowBotDetection'; + +export default [ + // CommonJS (for Node) and ES module (for bundlers) build. + { + input: './src/index.ts', + plugins: [...umdPlugins, banner()], + treeshake: { moduleSideEffects: ['sha1'] }, + output: [{ file: pkg.main, format: 'umd', sourcemap: true, name: umdName }], + }, + { + input: './src/index.ts', + plugins: [...umdPlugins, compiler(), terser(), cleanup({ comments: 'none' }), banner()], + treeshake: { moduleSideEffects: ['sha1'] }, + output: [{ file: pkg.main.replace('.js', '.min.js'), format: 'umd', sourcemap: true, name: umdName }], + }, + { + input: './src/index.ts', + external: [...builtinModules, ...Object.keys(pkg.dependencies), ...Object.keys(pkg.devDependencies)], + plugins: [ + ts(), // so Rollup can convert TypeScript to JavaScript + banner(), + ], + output: [{ file: pkg.module, format: 'es', sourcemap: true }], + }, +]; diff --git a/plugins/browser-plugin-bot-detection/src/index.ts b/plugins/browser-plugin-bot-detection/src/index.ts new file mode 100644 index 000000000..966c4f356 --- /dev/null +++ b/plugins/browser-plugin-bot-detection/src/index.ts @@ -0,0 +1,32 @@ +import { BrowserPlugin } from '@snowplow/browser-tracker-core'; +import { LOG } from '@snowplow/tracker-core'; +import { load } from '@fingerprintjs/botd'; +import { CLIENT_SIDE_BOT_DETECTION_SCHEMA } from './schemata'; +import { BotDetectionContextData } from './types'; + +export { BotDetectionContextData, BotKind } from './types'; + +let contextData: BotDetectionContextData | undefined; +let detectionStarted = false; + +export function BotDetectionPlugin(): BrowserPlugin { + return { + activateBrowserPlugin: () => { + if (!detectionStarted) { + detectionStarted = true; + load() + .then((detector) => detector.detect()) + .then((result) => { + contextData = result.bot ? { bot: true, kind: result.botKind } : { bot: false, kind: null }; + }) + .catch((err) => LOG.error('BotDetectionPlugin: BotD load/detect failed', err)); + } + }, + contexts: () => { + if (contextData) { + return [{ schema: CLIENT_SIDE_BOT_DETECTION_SCHEMA, data: contextData }]; + } + return []; + }, + }; +} diff --git a/plugins/browser-plugin-bot-detection/src/schemata.ts b/plugins/browser-plugin-bot-detection/src/schemata.ts new file mode 100644 index 000000000..af05d9f6a --- /dev/null +++ b/plugins/browser-plugin-bot-detection/src/schemata.ts @@ -0,0 +1,2 @@ +export const CLIENT_SIDE_BOT_DETECTION_SCHEMA = + 'iglu:com.snowplowanalytics.snowplow/client_side_bot_detection/jsonschema/1-0-0'; diff --git a/plugins/browser-plugin-bot-detection/src/types.ts b/plugins/browser-plugin-bot-detection/src/types.ts new file mode 100644 index 000000000..f374b478d --- /dev/null +++ b/plugins/browser-plugin-bot-detection/src/types.ts @@ -0,0 +1,25 @@ +export type BotKind = + | 'awesomium' + | 'cef' + | 'cefsharp' + | 'coachjs' + | 'electron' + | 'fminer' + | 'geb' + | 'nightmarejs' + | 'phantomas' + | 'phantomjs' + | 'rhino' + | 'selenium' + | 'sequentum' + | 'slimerjs' + | 'webdriverio' + | 'webdriver' + | 'headless_chrome' + | 'unknown'; + +export interface BotDetectionContextData { + [key: string]: unknown; + bot: boolean; + kind: BotKind | null; +} diff --git a/plugins/browser-plugin-bot-detection/test/bot-detection.test.ts b/plugins/browser-plugin-bot-detection/test/bot-detection.test.ts new file mode 100644 index 000000000..7b6885879 --- /dev/null +++ b/plugins/browser-plugin-bot-detection/test/bot-detection.test.ts @@ -0,0 +1,149 @@ +import { buildLinkClick, trackerCore } from '@snowplow/tracker-core'; +import { BrowserTracker } from '@snowplow/browser-tracker-core'; +import { setImmediate } from 'timers'; + +const flushPromises = () => new Promise(setImmediate); + +let mockDetectResult: any = { bot: false }; +let mockLoadReject: Error | null = null; +let mockDetectReject: Error | null = null; + +jest.mock('@fingerprintjs/botd', () => ({ + load: () => { + if (mockLoadReject) { + return Promise.reject(mockLoadReject); + } + return Promise.resolve({ + detect: () => { + if (mockDetectReject) { + return Promise.reject(mockDetectReject); + } + return Promise.resolve(mockDetectResult); + }, + }); + }, +})); + +describe('BotDetectionPlugin', () => { + beforeEach(() => { + jest.resetModules(); + mockDetectResult = { bot: false }; + mockLoadReject = null; + mockDetectReject = null; + }); + + it('attaches bot context when a bot is detected', async () => { + mockDetectResult = { bot: true, botKind: 'selenium' }; + + const { BotDetectionPlugin } = require('../src'); + const plugin = BotDetectionPlugin(); + + const core = trackerCore({ + corePlugins: [plugin], + callback: (payloadBuilder) => { + const json = payloadBuilder.getJson().filter((e) => e.keyIfEncoded === 'cx'); + const contexts = json[0]?.json; + expect(contexts).toEqual({ + schema: 'iglu:com.snowplowanalytics.snowplow/contexts/jsonschema/1-0-0', + data: [ + { + schema: 'iglu:com.snowplowanalytics.snowplow/client_side_bot_detection/jsonschema/1-0-0', + data: { bot: true, kind: 'selenium' }, + }, + ], + }); + }, + }); + + plugin.activateBrowserPlugin?.({ core } as BrowserTracker); + await flushPromises(); + + core.track(buildLinkClick({ targetUrl: 'https://example.com' })); + }); + + it('attaches no-bot context when no bot is detected', async () => { + mockDetectResult = { bot: false }; + + const { BotDetectionPlugin } = require('../src'); + const plugin = BotDetectionPlugin(); + + const core = trackerCore({ + corePlugins: [plugin], + callback: (payloadBuilder) => { + const json = payloadBuilder.getJson().filter((e) => e.keyIfEncoded === 'cx'); + const contexts = json[0]?.json; + expect(contexts).toEqual({ + schema: 'iglu:com.snowplowanalytics.snowplow/contexts/jsonschema/1-0-0', + data: [ + { + schema: 'iglu:com.snowplowanalytics.snowplow/client_side_bot_detection/jsonschema/1-0-0', + data: { bot: false, kind: null }, + }, + ], + }); + }, + }); + + plugin.activateBrowserPlugin?.({ core } as BrowserTracker); + await flushPromises(); + + core.track(buildLinkClick({ targetUrl: 'https://example.com' })); + }); + + it('returns empty contexts while detection is pending', () => { + const { BotDetectionPlugin } = require('../src'); + const plugin = BotDetectionPlugin(); + + const core = trackerCore({ + corePlugins: [plugin], + callback: (payloadBuilder) => { + const json = payloadBuilder.getJson().filter((e) => e.keyIfEncoded === 'cx'); + expect(json).toHaveLength(0); + }, + }); + + plugin.activateBrowserPlugin?.({ core } as BrowserTracker); + // Do NOT await — detection is still pending + core.track(buildLinkClick({ targetUrl: 'https://example.com' })); + }); + + it('returns empty contexts when load() fails', async () => { + mockLoadReject = new Error('load failed'); + + const { BotDetectionPlugin } = require('../src'); + const plugin = BotDetectionPlugin(); + + const core = trackerCore({ + corePlugins: [plugin], + callback: (payloadBuilder) => { + const json = payloadBuilder.getJson().filter((e) => e.keyIfEncoded === 'cx'); + expect(json).toHaveLength(0); + }, + }); + + plugin.activateBrowserPlugin?.({ core } as BrowserTracker); + await flushPromises(); + + core.track(buildLinkClick({ targetUrl: 'https://example.com' })); + }); + + it('returns empty contexts when detect() fails', async () => { + mockDetectReject = new Error('detect failed'); + + const { BotDetectionPlugin } = require('../src'); + const plugin = BotDetectionPlugin(); + + const core = trackerCore({ + corePlugins: [plugin], + callback: (payloadBuilder) => { + const json = payloadBuilder.getJson().filter((e) => e.keyIfEncoded === 'cx'); + expect(json).toHaveLength(0); + }, + }); + + plugin.activateBrowserPlugin?.({ core } as BrowserTracker); + await flushPromises(); + + core.track(buildLinkClick({ targetUrl: 'https://example.com' })); + }); +}); diff --git a/plugins/browser-plugin-bot-detection/tsconfig.json b/plugins/browser-plugin-bot-detection/tsconfig.json new file mode 100644 index 000000000..4082f16a5 --- /dev/null +++ b/plugins/browser-plugin-bot-detection/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/rush.json b/rush.json index 7f5b3315a..b6ada47fd 100644 --- a/rush.json +++ b/rush.json @@ -463,6 +463,12 @@ "reviewCategory": "plugins", "versionPolicyName": "tracker" }, + { + "packageName": "@snowplow/browser-plugin-bot-detection", + "projectFolder": "plugins/browser-plugin-bot-detection", + "reviewCategory": "plugins", + "versionPolicyName": "tracker" + }, { "packageName": "@snowplow/browser-plugin-site-tracking", "projectFolder": "plugins/browser-plugin-site-tracking",