Skip to content

Commit

Permalink
feat(tracker): allow deferred trackers configuration
Browse files Browse the repository at this point in the history
fixes #31, fixes #54
  • Loading branch information
EmmanuelRoux committed Jul 22, 2022
1 parent 045d0a3 commit cd51156
Show file tree
Hide file tree
Showing 7 changed files with 202 additions and 38 deletions.
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Matomo (fka. Piwik) client for Angular applications
* [Customizing script tag](#customizing-script-tag)
* [Server-side rendering (SSR) with Angular Universal](#server-side-rendering-ssr-with-angular-universal)
* [Scripts with pre-defined (embedded) tracker configuration (Tag Manager variable...)](#scripts-with-pre-defined-embedded-tracker-configuration-tag-manager-variable)
* [Deferred (asynchronous) configuration](#deferred-asynchronous-configuration)
- [Roadmap](#roadmap)
- [Launch demo app](#launch-demo-app)

Expand Down Expand Up @@ -546,6 +547,60 @@ import { NgxMatomoTrackerModule } from '@ngx-matomo/tracker';
export class AppModule {}
```

### Deferred (asynchronous) configuration

In some case, you may want to load your trackers configuration asynchronously. To do so, set the configuration mode
to `AUTO_DEFERRED` and manually call `MatomoInitializerService.initializeTracker(config)` when you are ready:

```ts
function initializeMatomo(http: HttpClient, matomoInitializer: MatomoInitializerService) {
return () =>
http.get('/my-config').pipe(tap(config => matomoInitializer.initializeTracker(config)));
}

@NgModule({
imports: [
NgxMatomoTrackerModule.forRoot({
mode: MatomoInitializationMode.AUTO_DEFERRED,
}),
],

// Option 1: with APP_INITIALIZER
providers: [
{
provide: APP_INITIALIZER,
useFactory: initializeMatomo,
deps: [HttpClient, MatomoInitializerService],
multi: true,
},
],
})
export class AppModule {}

// Option 2: from anywhere, call this when you're ready
@Injectable()
export class MyConfigService {
constructor(private readonly http: HttpClient, matomoInitializer: MatomoInitializerService) {}

initMatomo() {
this.http.get('/my-config').subscribe(config => matomoInitializer.initializeTracker(config));
}
}
```

All tracking instructions before `initializeTracker` will be queued and sent only when this method is called. **Don't
forget to call it!**

If you need to asynchronously load more configuration properties, then
consider [the solution described in this issue](https://github.com/EmmanuelRoux/ngx-matomo/issues/31) instead (which has
some
drawbacks, such as delaying the application startup).

_Side note: only the **trackers** configuration can be deferred, not all configuration properties.
This is required because some properties require to be set **before** any other action is tracked: for
example, `requireConsent` must be set before any other tracking call and `trackAppInitialLoad` should be set before
any navigation occurs._

## Roadmap

[See roadmap here](docs/roadmap.md)
Expand Down
79 changes: 50 additions & 29 deletions projects/tracker/src/lib/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ export enum MatomoInitializationMode {
AUTO,
/** Do not inject Matomo script. In this case, initialization script must be provided */
MANUAL,
/**
* Automatically inject matomo script when deferred tracker configuration is provided using `MatomoDeferredInitializerService.init`.
*/
AUTO_DEFERRED,
}

export enum MatomoConsentMode {
Expand Down Expand Up @@ -107,19 +111,20 @@ export interface BaseMatomoConfiguration {
enableJSErrorTracking?: boolean;
}

export interface BaseAutoMatomoConfiguration {
export interface BaseAutoMatomoConfiguration<M = MatomoInitializationMode.AUTO> {
/**
* Set the script initialization mode (default is `AUTO`)
*
*/
mode?: MatomoInitializationMode.AUTO;
mode?: M;

/** Matomo script url (default is `matomo.js` appended to main tracker url) */
scriptUrl: string;
}

type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
type XOR<T, U> = T | U extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U;
type XOR3<T, U, V> = XOR<T, XOR<U, V>>;

export type ManualMatomoConfiguration = {
/**
Expand All @@ -129,56 +134,72 @@ export type ManualMatomoConfiguration = {
mode: MatomoInitializationMode.MANUAL;
};

export type ExplicitAutoConfiguration = Partial<BaseAutoMatomoConfiguration> &
XOR<MatomoTrackerConfiguration, MultiTrackersConfiguration>;
export type EmbeddedAutoConfiguration = BaseAutoMatomoConfiguration &
Partial<MultiTrackersConfiguration>;
export type DeferredMatomoConfiguration = {
/**
* Set the script initialization mode (default is `AUTO`)
*
*/
mode: MatomoInitializationMode.AUTO_DEFERRED;
};

export type AutoMatomoConfiguration = XOR<ExplicitAutoConfiguration, EmbeddedAutoConfiguration>;
export type ExplicitAutoConfiguration<
M extends
| MatomoInitializationMode.AUTO
| MatomoInitializationMode.AUTO_DEFERRED = MatomoInitializationMode.AUTO
> = Partial<BaseAutoMatomoConfiguration<M>> &
XOR<MatomoTrackerConfiguration, MultiTrackersConfiguration>;
export type EmbeddedAutoConfiguration<
M extends
| MatomoInitializationMode.AUTO
| MatomoInitializationMode.AUTO_DEFERRED = MatomoInitializationMode.AUTO
> = BaseAutoMatomoConfiguration<M> & Partial<MultiTrackersConfiguration>;

export type AutoMatomoConfiguration<
M extends
| MatomoInitializationMode.AUTO
| MatomoInitializationMode.AUTO_DEFERRED = MatomoInitializationMode.AUTO
> = XOR<ExplicitAutoConfiguration<M>, EmbeddedAutoConfiguration<M>>;

export type MatomoConfiguration = BaseMatomoConfiguration &
XOR<AutoMatomoConfiguration, ManualMatomoConfiguration>;
XOR3<AutoMatomoConfiguration, ManualMatomoConfiguration, DeferredMatomoConfiguration>;

export function isAutoConfigurationMode(
config: MatomoConfiguration
): config is AutoMatomoConfiguration {
return config.mode !== MatomoInitializationMode.MANUAL;
return config.mode == null || config.mode === MatomoInitializationMode.AUTO;
}

function hasMainTrackerConfiguration(
config: AutoMatomoConfiguration
): config is ExplicitAutoConfiguration {
function hasMainTrackerConfiguration<
M extends MatomoInitializationMode.AUTO | MatomoInitializationMode.AUTO_DEFERRED
>(config: AutoMatomoConfiguration<M>): config is ExplicitAutoConfiguration<M> {
// If one is undefined, both should be
return config.siteId != null && config.trackerUrl != null;
}

export function isEmbeddedTrackerConfiguration(
config: MatomoConfiguration
): config is EmbeddedAutoConfiguration {
return (
isAutoConfigurationMode(config) &&
config.scriptUrl != null &&
!hasMainTrackerConfiguration(config)
);
export function isEmbeddedTrackerConfiguration<
M extends MatomoInitializationMode.AUTO | MatomoInitializationMode.AUTO_DEFERRED
>(config: AutoMatomoConfiguration<M>): config is EmbeddedAutoConfiguration<M> {
return config.scriptUrl != null && !hasMainTrackerConfiguration(config);
}

export function isExplicitTrackerConfiguration(
config: MatomoConfiguration
): config is ExplicitAutoConfiguration {
return (
isAutoConfigurationMode(config) &&
(hasMainTrackerConfiguration(config) || isMultiTrackerConfiguration(config))
);
export function isExplicitTrackerConfiguration<
M extends MatomoInitializationMode.AUTO | MatomoInitializationMode.AUTO_DEFERRED
>(config: AutoMatomoConfiguration<M>): config is ExplicitAutoConfiguration<M> {
return hasMainTrackerConfiguration(config) || isMultiTrackerConfiguration(config);
}

export function isMultiTrackerConfiguration(
config: AutoMatomoConfiguration
config: AutoMatomoConfiguration<
MatomoInitializationMode.AUTO | MatomoInitializationMode.AUTO_DEFERRED
>
): config is MultiTrackersConfiguration {
return Array.isArray(config.trackers);
}

export function getTrackersConfiguration(
config: ExplicitAutoConfiguration
config: ExplicitAutoConfiguration<
MatomoInitializationMode.AUTO | MatomoInitializationMode.AUTO_DEFERRED
>
): MatomoTrackerConfiguration[] {
return isMultiTrackerConfiguration(config)
? config.trackers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
getTrackersConfiguration,
INTERNAL_MATOMO_CONFIGURATION,
InternalMatomoConfiguration,
isAutoConfigurationMode,
isExplicitTrackerConfiguration,
} from '../configuration';

Expand Down Expand Up @@ -71,7 +72,7 @@ export class MatomoOptOutFormComponent implements OnInit, OnChanges {
// Set default locale
this.locale = locale;

if (isExplicitTrackerConfiguration(this.config)) {
if (isAutoConfigurationMode(this.config) && isExplicitTrackerConfiguration(this.config)) {
this._defaultServerUrl = getTrackersConfiguration(this.config)[0].trackerUrl;
}
}
Expand Down
1 change: 1 addition & 0 deletions projects/tracker/src/lib/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const ALREADY_INJECTED_ERROR = 'Matomo trackers have already been initialized';
52 changes: 52 additions & 0 deletions projects/tracker/src/lib/matomo-initializer.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
MatomoConsentMode,
MatomoInitializationMode,
} from './configuration';
import { ALREADY_INJECTED_ERROR } from './errors';
import { MatomoHolder } from './holder';
import { MatomoInitializerService } from './matomo-initializer.service';
import { MatomoTracker, NoopMatomoTracker } from './matomo-tracker.service';
Expand Down Expand Up @@ -381,6 +382,7 @@ describe('MatomoInitializerService', () => {

// When
service.init();
service.initializeTracker({ trackerUrl: '', siteId: '' });

// Then
expect(injectedScript).toBeUndefined();
Expand Down Expand Up @@ -449,4 +451,54 @@ describe('MatomoInitializerService', () => {
expect(injectedScript?.src).toMatch('^(.+://[^/]+)?/fake/script/url$');
expect(injectedScript?.dataset.cookieconsent).toEqual('statistics');
});

it('should defer script injection until tracker configuration is provided', () => {
// Given
let injectedScript: HTMLScriptElement | undefined;
const service = instantiate({
mode: MatomoInitializationMode.AUTO_DEFERRED,
trackAppInitialLoad: true,
});
const tracker = TestBed.inject(MatomoTracker);

spyOn(tracker, 'setTrackerUrl');
spyOn(tracker, 'setSiteId');
spyOn(tracker, 'trackPageView');
setUpScriptInjection(script => (injectedScript = script));

// When
service.init();
// Then
expect(injectedScript).toBeFalsy();
expect(tracker.setTrackerUrl).not.toHaveBeenCalled();
expect(tracker.setSiteId).not.toHaveBeenCalled();
// Pre-init actions must run
expect(tracker.trackPageView).toHaveBeenCalledOnceWith();

// When
service.initializeTracker({
siteId: 'fakeSiteId',
trackerUrl: 'http://fakeTrackerUrl',
});

// Then
expectInjectedScript(injectedScript, 'http://fakeTrackerUrl/matomo.js');
expect(tracker.setTrackerUrl).toHaveBeenCalledOnceWith('http://fakeTrackerUrl/matomo.php');
expect(tracker.setSiteId).toHaveBeenCalledOnceWith('fakeSiteId');
});

it('should throw an error when initialized trackers more than once', () => {
// Given
const service = instantiate({
mode: MatomoInitializationMode.AUTO_DEFERRED,
});

// When
service.initializeTracker({ trackerUrl: '', siteId: '' });

// Then
expect(() => service.initializeTracker({ trackerUrl: '', siteId: '' })).toThrowError(
ALREADY_INJECTED_ERROR
);
});
});
49 changes: 41 additions & 8 deletions projects/tracker/src/lib/matomo-initializer.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@ import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import { Inject, Injectable, PLATFORM_ID } from '@angular/core';
import { requireNonNull } from './coercion';
import {
AutoMatomoConfiguration,
getTrackersConfiguration,
INTERNAL_MATOMO_CONFIGURATION,
InternalMatomoConfiguration,
isAutoConfigurationMode,
isEmbeddedTrackerConfiguration,
isExplicitTrackerConfiguration,
MatomoConsentMode,
MatomoInitializationMode,
MatomoTrackerConfiguration,
} from './configuration';
import { ALREADY_INJECTED_ERROR } from './errors';
import { initializeMatomoHolder } from './holder';
import { MatomoTracker } from './matomo-tracker.service';
import { MATOMO_SCRIPT_FACTORY, MatomoScriptFactory } from './script-factory';
Expand Down Expand Up @@ -44,10 +48,20 @@ export function createMatomoInitializer(
: new MatomoInitializerService(config, tracker, scriptFactory, document);
}

export class NoopMatomoInitializer implements Pick<MatomoInitializerService, 'init'> {
export class NoopMatomoInitializer
implements Pick<MatomoInitializerService, 'init' | 'initializeTracker'>
{
init(): void {
// No-op
}

initializeTracker(
_: AutoMatomoConfiguration<
MatomoInitializationMode.AUTO | MatomoInitializationMode.AUTO_DEFERRED
>
): void {
// No-op
}
}

@Injectable({
Expand All @@ -62,6 +76,8 @@ export class NoopMatomoInitializer implements Pick<MatomoInitializerService, 'in
],
})
export class MatomoInitializerService {
private injected = false;

constructor(
private readonly config: InternalMatomoConfiguration,
private readonly tracker: MatomoTracker,
Expand All @@ -73,28 +89,45 @@ export class MatomoInitializerService {

init(): void {
this.runPreInitTasks();
this.injectMatomoScript();

if (isAutoConfigurationMode(this.config)) {
this.injectMatomoScript(this.config);
}
}

private injectMatomoScript() {
if (isExplicitTrackerConfiguration(this.config)) {
const { scriptUrl: customScriptUrl } = this.config;
const [mainTracker, ...additionalTrackers] = getTrackersConfiguration(this.config);
initializeTracker(config: AutoMatomoConfiguration<MatomoInitializationMode.AUTO_DEFERRED>): void {
this.injectMatomoScript(config);
}

private injectMatomoScript(
config: AutoMatomoConfiguration<
MatomoInitializationMode.AUTO | MatomoInitializationMode.AUTO_DEFERRED
>
): void {
if (this.injected) {
throw new Error(ALREADY_INJECTED_ERROR);
}

if (isExplicitTrackerConfiguration(config)) {
const { scriptUrl: customScriptUrl } = config;
const [mainTracker, ...additionalTrackers] = getTrackersConfiguration(config);
const scriptUrl =
customScriptUrl ?? appendTrailingSlash(mainTracker.trackerUrl) + DEFAULT_SCRIPT_SUFFIX;

this.registerMainTracker(mainTracker);
this.registerAdditionalTrackers(additionalTrackers);
this.injectDOMScript(scriptUrl);
} else if (isEmbeddedTrackerConfiguration(this.config)) {
} else if (isEmbeddedTrackerConfiguration(config)) {
const { scriptUrl, trackers: additionalTrackers } = {
trackers: [],
...this.config,
...config,
};

this.registerAdditionalTrackers(additionalTrackers);
this.injectDOMScript(scriptUrl);
}

this.injected = true;
}

private registerMainTracker(mainTracker: MatomoTrackerConfiguration): void {
Expand Down

0 comments on commit cd51156

Please sign in to comment.