feat(gtag-plugin): gtag should support multiple tracking ids, notably for the UA => GA4 transition (#8620)

This commit is contained in:
Sébastien Lorber 2023-02-02 18:06:12 +01:00 committed by GitHub
parent 5b05c0e73e
commit 32384b761c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 241 additions and 40 deletions

View file

@ -0,0 +1,156 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {normalizePluginOptions} from '@docusaurus/utils-validation';
import {
validateOptions,
type PluginOptions,
type Options,
DEFAULT_OPTIONS,
} from '../options';
import type {Validate} from '@docusaurus/types';
function testValidateOptions(options: Options) {
return validateOptions({
validate: normalizePluginOptions as Validate<Options, PluginOptions>,
options,
});
}
function validationResult(options: Options) {
return {
id: 'default',
...DEFAULT_OPTIONS,
...options,
trackingID:
typeof options.trackingID === 'string'
? [options.trackingID]
: options.trackingID,
};
}
const MinimalConfig: Options = {
trackingID: 'G-XYZ12345',
};
describe('validateOptions', () => {
it('throws for undefined options', () => {
expect(
// @ts-expect-error: TS should error
() => testValidateOptions(undefined),
).toThrowErrorMatchingInlineSnapshot(`""trackingID" is required"`);
});
it('throws for null options', () => {
expect(
// @ts-expect-error: TS should error
() => testValidateOptions(null),
).toThrowErrorMatchingInlineSnapshot(`""value" must be of type object"`);
});
it('throws for empty object options', () => {
expect(
// @ts-expect-error: TS should error
() => testValidateOptions({}),
).toThrowErrorMatchingInlineSnapshot(`""trackingID" is required"`);
});
it('throws for number options', () => {
expect(
// @ts-expect-error: TS should error
() => testValidateOptions(42),
).toThrowErrorMatchingInlineSnapshot(`""value" must be of type object"`);
});
it('throws for null trackingID', () => {
expect(
// @ts-expect-error: TS should error
() => testValidateOptions({trackingID: null}),
).toThrowErrorMatchingInlineSnapshot(
`""trackingID" does not match any of the allowed types"`,
);
});
it('throws for number trackingID', () => {
expect(
// @ts-expect-error: TS should error
() => testValidateOptions({trackingID: 42}),
).toThrowErrorMatchingInlineSnapshot(
`""trackingID" does not match any of the allowed types"`,
);
});
it('throws for empty trackingID', () => {
expect(() =>
testValidateOptions({trackingID: ''}),
).toThrowErrorMatchingInlineSnapshot(
`""trackingID" does not match any of the allowed types"`,
);
});
it('accepts minimal config', () => {
expect(testValidateOptions(MinimalConfig)).toEqual(
validationResult(MinimalConfig),
);
});
it('accepts anonymizeIP', () => {
const config: Options = {
...MinimalConfig,
anonymizeIP: true,
};
expect(testValidateOptions(config)).toEqual(validationResult(config));
});
it('accepts single trackingID', () => {
const config: Options = {
trackingID: 'G-ABCDEF123',
};
expect(testValidateOptions(config)).toEqual(validationResult(config));
});
it('accepts multiple trackingIDs', () => {
const config: Options = {
trackingID: ['G-ABCDEF123', 'UA-XYZ456789'],
};
expect(testValidateOptions(config)).toEqual(validationResult(config));
});
it('throws for empty trackingID arrays', () => {
const config: Options = {
// @ts-expect-error: TS should error
trackingID: [],
};
expect(() =>
testValidateOptions(config),
).toThrowErrorMatchingInlineSnapshot(
`""trackingID" does not match any of the allowed types"`,
);
});
it('throws for sparse trackingID arrays', () => {
const config: Options = {
// @ts-expect-error: TS should error
trackingID: ['G-ABCDEF123', null, 'UA-XYZ456789'],
};
expect(() =>
testValidateOptions(config),
).toThrowErrorMatchingInlineSnapshot(
`""trackingID" does not match any of the allowed types"`,
);
});
it('throws for bad trackingID arrays', () => {
const config: Options = {
// @ts-expect-error: TS should error
trackingID: ['G-ABCDEF123', 42],
};
expect(() =>
testValidateOptions(config),
).toThrowErrorMatchingInlineSnapshot(
`""trackingID" does not match any of the allowed types"`,
);
});
});

View file

@ -5,23 +5,38 @@
* LICENSE file in the root directory of this source tree.
*/
import {Joi} from '@docusaurus/utils-validation';
import type {
LoadContext,
Plugin,
OptionValidationContext,
ThemeConfig,
ThemeConfigValidationContext,
} from '@docusaurus/types';
import type {LoadContext, Plugin} from '@docusaurus/types';
import type {PluginOptions, Options} from './options';
function createConfigSnippet({
trackingID,
anonymizeIP,
}: {
trackingID: string;
anonymizeIP: boolean;
}): string {
return `gtag('config', '${trackingID}', { ${
anonymizeIP ? "'anonymize_ip': true" : ''
} });`;
}
function createConfigSnippets({
trackingID: trackingIDArray,
anonymizeIP,
}: PluginOptions): string {
return trackingIDArray
.map((trackingID) => createConfigSnippet({trackingID, anonymizeIP}))
.join('\n');
}
export default function pluginGoogleGtag(
context: LoadContext,
options: PluginOptions,
): Plugin {
const {anonymizeIP, trackingID} = options;
const isProd = process.env.NODE_ENV === 'production';
const firstTrackingId = options.trackingID[0];
return {
name: 'docusaurus-plugin-google-gtag',
@ -60,7 +75,11 @@ export default function pluginGoogleGtag(
tagName: 'script',
attributes: {
async: true,
src: `https://www.googletagmanager.com/gtag/js?id=${trackingID}`,
// We only include the first tracking id here because google says
// we shouldn't install multiple tags/scripts on the same page
// Instead we should load one script and use n * gtag("config",id)
// See https://developers.google.com/tag-platform/gtagjs/install#add-products
src: `https://www.googletagmanager.com/gtag/js?id=${firstTrackingId}`,
},
},
{
@ -69,9 +88,8 @@ export default function pluginGoogleGtag(
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${trackingID}', { ${
anonymizeIP ? "'anonymize_ip': true" : ''
} });`,
${createConfigSnippets(options)};
`,
},
],
};
@ -79,27 +97,6 @@ export default function pluginGoogleGtag(
};
}
const pluginOptionsSchema = Joi.object<PluginOptions>({
trackingID: Joi.string().required(),
anonymizeIP: Joi.boolean().default(false),
});
export function validateOptions({
validate,
options,
}: OptionValidationContext<Options, PluginOptions>): PluginOptions {
return validate(pluginOptionsSchema, options);
}
export function validateThemeConfig({
themeConfig,
}: ThemeConfigValidationContext<ThemeConfig>): ThemeConfig {
if ('gtag' in themeConfig) {
throw new Error(
'The "gtag" field in themeConfig should now be specified as option for plugin-google-gtag. More information at https://github.com/facebook/docusaurus/pull/5832.',
);
}
return themeConfig;
}
export {validateThemeConfig, validateOptions} from './options';
export type {PluginOptions, Options};

View file

@ -4,10 +4,58 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {Joi} from '@docusaurus/utils-validation';
import type {
OptionValidationContext,
ThemeConfig,
ThemeConfigValidationContext,
} from '@docusaurus/types';
export type PluginOptions = {
trackingID: string;
trackingID: [string, ...string[]];
// TODO deprecate anonymizeIP after June 2023
// "In Google Analytics 4, IP masking is not necessary
// since IP addresses are not logged or stored."
// https://support.google.com/analytics/answer/2763052?hl=en
anonymizeIP: boolean;
};
export type Options = Partial<PluginOptions>;
export type Options = {
trackingID: string | [string, ...string[]];
anonymizeIP?: boolean;
};
export const DEFAULT_OPTIONS: Partial<PluginOptions> = {
anonymizeIP: false,
};
const pluginOptionsSchema = Joi.object<PluginOptions>({
// We normalize trackingID as a string[]
trackingID: Joi.alternatives()
.try(
Joi.alternatives().conditional(Joi.string().required(), {
then: Joi.custom((val: boolean) => [val]),
}),
Joi.array().items(Joi.string().required()),
)
.required(),
anonymizeIP: Joi.boolean().default(DEFAULT_OPTIONS.anonymizeIP),
});
export function validateOptions({
validate,
options,
}: OptionValidationContext<Options, PluginOptions>): PluginOptions {
return validate(pluginOptionsSchema, options);
}
export function validateThemeConfig({
themeConfig,
}: ThemeConfigValidationContext<ThemeConfig>): ThemeConfig {
if ('gtag' in themeConfig) {
throw new Error(
'The "gtag" field in themeConfig should now be specified as option for plugin-google-gtag. More information at https://github.com/facebook/docusaurus/pull/5832.',
);
}
return themeConfig;
}

View file

@ -10,6 +10,6 @@
"rootDir": "src",
"outDir": "lib"
},
"include": ["src/gtag.ts", "src/options.ts", "src/*.d.ts"],
"include": ["src/gtag.ts", "src/*.d.ts"],
"exclude": ["**/__tests__/**"]
}

View file

@ -45,7 +45,7 @@ Accepted fields:
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `trackingID` | `string` | **Required** | The tracking ID of your gtag service. |
| `trackingID` | <code>string \| string[]</code> | **Required** | The tracking ID of your gtag service. It is possible to provide multiple ids. |
| `anonymizeIP` | `boolean` | `false` | Whether the IP should be anonymized when sending requests. |
```mdx-code-block

View file

@ -357,7 +357,7 @@ const config = {
},
gtag: !(isDeployPreview || isBranchDeploy)
? {
trackingID: 'UA-141789564-1',
trackingID: ['G-E5CR2Q1NRE', 'UA-141789564-1'],
}
: undefined,
sitemap: {