From 7250f141bc132299a633f80e66428f9fd0c9e219 Mon Sep 17 00:00:00 2001 From: "pawel.zlakowski@pcgacademia.pl" Date: Fri, 16 Jan 2026 13:13:46 +0100 Subject: [PATCH 1/5] fix: silently fail when we cannot get visitorId from matomo for bitstream download (cherry picked from commit 0aaaa7745796199be1bdf0f6e6467818bd7d634c) --- .../bitstream-download-page.component.spec.ts | 1 + .../bitstream-download-page.component.ts | 17 ++++--- src/app/statistics/matomo.factory.spec.ts | 49 +++++++++++++++++++ src/app/statistics/matomo.factory.ts | 14 ++++++ src/app/statistics/matomo.service.ts | 19 ++++++- src/modules/app/browser-app.config.ts | 8 +++ 6 files changed, 99 insertions(+), 9 deletions(-) create mode 100644 src/app/statistics/matomo.factory.spec.ts create mode 100644 src/app/statistics/matomo.factory.ts diff --git a/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.spec.ts b/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.spec.ts index 5ef75db2278..b19ccdf029b 100644 --- a/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.spec.ts +++ b/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.spec.ts @@ -116,6 +116,7 @@ describe('BitstreamDownloadPageComponent', () => { matomoService = jasmine.createSpyObj('MatomoService', { appendVisitorId: of(''), isMatomoEnabled$: of(true), + isMatomoScriptLoaded$: of(true), }); matomoService.appendVisitorId.and.callFake((link) => of(link)); } diff --git a/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.ts b/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.ts index 0160cb270b8..b888b7adae2 100644 --- a/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.ts +++ b/src/app/bitstream-page/bitstream-download-page/bitstream-download-page.component.ts @@ -107,26 +107,27 @@ export class BitstreamDownloadPageComponent implements OnInit { const isAuthorized$ = this.authorizationService.isAuthorized(FeatureID.CanDownload, isNotEmpty(bitstream) ? bitstream.self : undefined); const isLoggedIn$ = this.auth.isAuthenticated(); const isMatomoEnabled$ = this.matomoService.isMatomoEnabled$(); - return observableCombineLatest([isAuthorized$, isLoggedIn$, isMatomoEnabled$, accessToken$, of(bitstream)]); + const isMatomoScriptLoaded$ = this.matomoService.isMatomoScriptLoaded$(); + return observableCombineLatest([isAuthorized$, isLoggedIn$, isMatomoEnabled$, isMatomoScriptLoaded$, accessToken$, of(bitstream)]); }), - filter(([isAuthorized, isLoggedIn, isMatomoEnabled, accessToken, bitstream]: [boolean, boolean, boolean, string, Bitstream]) => (hasValue(isAuthorized) && hasValue(isLoggedIn)) || hasValue(accessToken)), + filter(([isAuthorized, isLoggedIn, isMatomoEnabled, isMatomoScriptLoaded, accessToken, bitstream]: [boolean, boolean, boolean, boolean, string, Bitstream]) => (hasValue(isAuthorized) && hasValue(isLoggedIn)) || hasValue(accessToken)), take(1), - switchMap(([isAuthorized, isLoggedIn, isMatomoEnabled, accessToken, bitstream]: [boolean, boolean, boolean, string, Bitstream]) => { + switchMap(([isAuthorized, isLoggedIn, isMatomoEnabled, isMatomoScriptLoaded, accessToken, bitstream]: [boolean, boolean, boolean, boolean, string, Bitstream]) => { if (isAuthorized && isLoggedIn) { return this.fileService.retrieveFileDownloadLink(bitstream._links.content.href).pipe( filter((fileLink) => hasValue(fileLink)), take(1), map((fileLink) => { - return [isAuthorized, isLoggedIn, isMatomoEnabled, bitstream, fileLink]; + return [isAuthorized, isLoggedIn, isMatomoEnabled, isMatomoScriptLoaded, bitstream, fileLink]; })); } else if (hasValue(accessToken)) { - return [[isAuthorized, !isLoggedIn, isMatomoEnabled, bitstream, '', accessToken]]; + return [[isAuthorized, !isLoggedIn, isMatomoEnabled, isMatomoScriptLoaded, bitstream, '', accessToken]]; } else { - return [[isAuthorized, isLoggedIn, isMatomoEnabled, bitstream, bitstream._links.content.href]]; + return [[isAuthorized, isLoggedIn, isMatomoEnabled, isMatomoScriptLoaded, bitstream, bitstream._links.content.href]]; } }), - switchMap(([isAuthorized, isLoggedIn, isMatomoEnabled, bitstream, fileLink, accessToken]: [boolean, boolean, boolean, Bitstream, string, string]) => { - if (isMatomoEnabled) { + switchMap(([isAuthorized, isLoggedIn, isMatomoEnabled, isMatomoScriptLoaded, bitstream, fileLink, accessToken]: [boolean, boolean, boolean, boolean, Bitstream, string, string]) => { + if (isMatomoEnabled && isMatomoScriptLoaded) { return this.matomoService.appendVisitorId(fileLink).pipe( map((fileLinkWithVisitorId) => [isAuthorized, isLoggedIn, bitstream, fileLinkWithVisitorId, accessToken]), ); diff --git a/src/app/statistics/matomo.factory.spec.ts b/src/app/statistics/matomo.factory.spec.ts new file mode 100644 index 00000000000..188e922c3fe --- /dev/null +++ b/src/app/statistics/matomo.factory.spec.ts @@ -0,0 +1,49 @@ +import { Injector } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { OrejimeService } from '@dspace/core/cookies/orejime.service'; +import { ConfigurationDataService } from '@dspace/core/data/configuration-data.service'; +import { NativeWindowService } from '@dspace/core/services/window.service'; +import { + MatomoInitializerService, + MatomoTracker, +} from 'ngx-matomo-client'; +import { firstValueFrom } from 'rxjs'; + +import { customMatomoScriptFactory } from './matomo.factory'; +import { MatomoService } from './matomo.service'; + +describe('customMatomoScriptFactory', () => { + let service: MatomoService; + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { provide: MatomoTracker, useValue: {} }, + { provide: MatomoInitializerService, useValue: {} }, + { provide: OrejimeService, useValue: {} }, + { provide: NativeWindowService, useValue: {} }, + { provide: ConfigurationDataService, useValue: {} }, + { provide: Injector, useValue: TestBed }, + ], + }); + + service = TestBed.inject(MatomoService); + }); + + it('should notify when the script loads', async () => { + const script = customMatomoScriptFactory(service)('', document); + + script.dispatchEvent(new Event('load')); + const isMatomoScriptLoaded = await firstValueFrom(service.isMatomoScriptLoaded$()); + + expect(isMatomoScriptLoaded).toBeTruthy(); + }); + + it('should notify when the script fails', async () => { + const script = customMatomoScriptFactory(service)('', document); + + script.dispatchEvent(new Event('error')); + const isMatomoScriptLoaded = await firstValueFrom(service.isMatomoScriptLoaded$()); + + expect(isMatomoScriptLoaded).toBeFalsy(); + }); +}); diff --git a/src/app/statistics/matomo.factory.ts b/src/app/statistics/matomo.factory.ts new file mode 100644 index 00000000000..fe6367bc359 --- /dev/null +++ b/src/app/statistics/matomo.factory.ts @@ -0,0 +1,14 @@ +import { createDefaultMatomoScriptElement } from 'ngx-matomo-client'; + +import { MatomoService } from './matomo.service'; + +export function customMatomoScriptFactory(matomoService: MatomoService) { + return (scriptUrl: string, document: Document): HTMLScriptElement => { + const script = createDefaultMatomoScriptElement(scriptUrl, document); + + script.onload = () => matomoService.markAsLoaded(); + script.onerror = () => matomoService.markAsError(); + + return script; + }; +} diff --git a/src/app/statistics/matomo.service.ts b/src/app/statistics/matomo.service.ts index e1c47589758..7824ff23230 100644 --- a/src/app/statistics/matomo.service.ts +++ b/src/app/statistics/matomo.service.ts @@ -13,6 +13,7 @@ import { from as fromPromise, Observable, of, + ReplaySubject, } from 'rxjs'; import { map, @@ -45,7 +46,6 @@ export const MATOMO_ENABLED = 'matomo.enabled'; * Provides methods for initializing tracking, managing consent, and appending visitor identifiers. */ export class MatomoService { - /** Injects the MatomoInitializerService to initialize the Matomo tracker. */ matomoInitializer: MatomoInitializerService; @@ -61,8 +61,19 @@ export class MatomoService { /** Injects the ConfigurationService. */ configService = inject(ConfigurationDataService); + private statusSubject = new ReplaySubject<'loading' | 'loaded' | 'error'>(1); + private status$ = this.statusSubject.asObservable(); + constructor(private injector: EnvironmentInjector) { + this.statusSubject.next('loading'); + } + markAsLoaded() { + this.statusSubject.next('loaded'); + } + + markAsError() { + this.statusSubject.next('error'); } /** @@ -165,6 +176,12 @@ export class MatomoService { ); } + isMatomoScriptLoaded$(): Observable { + return this.status$.pipe( + map(status => status === 'loaded'), + ); + } + /** * Appends the visitor ID as a query parameter to the given URL. * @param url - The original URL to modify diff --git a/src/modules/app/browser-app.config.ts b/src/modules/app/browser-app.config.ts index cb66d03fca3..4a096fe2abd 100644 --- a/src/modules/app/browser-app.config.ts +++ b/src/modules/app/browser-app.config.ts @@ -33,10 +33,13 @@ import { Angulartics2RouterlessModule, } from 'angulartics2'; import { + MATOMO_SCRIPT_FACTORY, provideMatomo, withRouteData, withRouter, } from 'ngx-matomo-client'; +import { customMatomoScriptFactory } from 'src/app/statistics/matomo.factory'; +import { MatomoService } from 'src/app/statistics/matomo.service'; import { commonAppConfig } from '../../app/app.config'; import { storeModuleConfig } from '../../app/app.reducer'; @@ -169,5 +172,10 @@ export const browserAppConfig: ApplicationConfig = mergeApplicationConfig({ withRouter(), withRouteData(), ), + { + provide: MATOMO_SCRIPT_FACTORY, + useFactory: customMatomoScriptFactory, + deps: [MatomoService], + }, ], }, commonAppConfig); From 0f6d043f3cb956c5beda1639af5e0149ea2f02f7 Mon Sep 17 00:00:00 2001 From: "pawel.zlakowski@pcgacademia.pl" Date: Fri, 16 Jan 2026 13:41:12 +0100 Subject: [PATCH 2/5] adds typedoc comments (cherry picked from commit f68f1c98cb215527ee8da0e9bc587dc27435abac) --- src/app/statistics/matomo.factory.ts | 14 ++++++++++++++ src/app/statistics/matomo.service.ts | 4 ++++ 2 files changed, 18 insertions(+) diff --git a/src/app/statistics/matomo.factory.ts b/src/app/statistics/matomo.factory.ts index fe6367bc359..d47145c0515 100644 --- a/src/app/statistics/matomo.factory.ts +++ b/src/app/statistics/matomo.factory.ts @@ -2,6 +2,20 @@ import { createDefaultMatomoScriptElement } from 'ngx-matomo-client'; import { MatomoService } from './matomo.service'; +/** + * Creates a custom script factory function that integrates with the `MatomoService`. + * + * @param matomoService - The instance of `MatomoService` used to track the loading state. + * @returns A function to initialize script to listen onload/onerror events by MatomoService + * + * @example + * // In your app config or module providers: + * { + * provide: MATOMO_SCRIPT_FACTORY, + * useFactory: customMatomoScriptFactory, + * deps: [MatomoService] + * } + */ export function customMatomoScriptFactory(matomoService: MatomoService) { return (scriptUrl: string, document: Document): HTMLScriptElement => { const script = createDefaultMatomoScriptElement(scriptUrl, document); diff --git a/src/app/statistics/matomo.service.ts b/src/app/statistics/matomo.service.ts index 7824ff23230..6167b8f77d5 100644 --- a/src/app/statistics/matomo.service.ts +++ b/src/app/statistics/matomo.service.ts @@ -176,6 +176,10 @@ export class MatomoService { ); } + /** + * Checks if Matomo script loaded correctly + * @returns An Observable that emits a boolean indicating whether Matomo script loaded correctly. + */ isMatomoScriptLoaded$(): Observable { return this.status$.pipe( map(status => status === 'loaded'), From 830ebd2ae3912970c1059910100424377b5057a1 Mon Sep 17 00:00:00 2001 From: "pawel.zlakowski@pcgacademia.pl" Date: Fri, 16 Jan 2026 14:18:54 +0100 Subject: [PATCH 3/5] adds missing typedoc v2 (cherry picked from commit c2fbeb23ee933a4f74158d3e294b45440dacf9cd) --- src/app/statistics/matomo.service.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/app/statistics/matomo.service.ts b/src/app/statistics/matomo.service.ts index 6167b8f77d5..286b5237615 100644 --- a/src/app/statistics/matomo.service.ts +++ b/src/app/statistics/matomo.service.ts @@ -68,10 +68,16 @@ export class MatomoService { this.statusSubject.next('loading'); } + /** + * This method indicates that the Matomo script loaded successfully thus we set state to loaded + */ markAsLoaded() { this.statusSubject.next('loaded'); } + /** + * This method indicates that the Matomo script failed to download or execute and sets state to error + */ markAsError() { this.statusSubject.next('error'); } From abe569a690f1b2e5699465ce33953b8fff2b2628 Mon Sep 17 00:00:00 2001 From: "pawel.zlakowski@pcgacademia.pl" Date: Mon, 18 May 2026 12:44:01 +0200 Subject: [PATCH 4/5] chore: backport matomo to 9.x manually --- src/app/statistics/matomo.factory.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/statistics/matomo.factory.spec.ts b/src/app/statistics/matomo.factory.spec.ts index 188e922c3fe..1e29dfe275e 100644 --- a/src/app/statistics/matomo.factory.spec.ts +++ b/src/app/statistics/matomo.factory.spec.ts @@ -1,8 +1,8 @@ import { Injector } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { OrejimeService } from '@dspace/core/cookies/orejime.service'; -import { ConfigurationDataService } from '@dspace/core/data/configuration-data.service'; -import { NativeWindowService } from '@dspace/core/services/window.service'; +import { OrejimeService } from '../shared/cookies/orejime.service'; +import { ConfigurationDataService } from '../core/data/configuration-data.service'; +import { NativeWindowService } from '../core/services/window.service'; import { MatomoInitializerService, MatomoTracker, From 3fd47983f353080444e0eb8ab62edd8266d916a4 Mon Sep 17 00:00:00 2001 From: "pawel.zlakowski@pcgacademia.pl" Date: Mon, 18 May 2026 12:57:07 +0200 Subject: [PATCH 5/5] chore: lint fix --- src/app/statistics/matomo.factory.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/statistics/matomo.factory.spec.ts b/src/app/statistics/matomo.factory.spec.ts index 1e29dfe275e..df75bd7ee63 100644 --- a/src/app/statistics/matomo.factory.spec.ts +++ b/src/app/statistics/matomo.factory.spec.ts @@ -1,14 +1,14 @@ import { Injector } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { OrejimeService } from '../shared/cookies/orejime.service'; -import { ConfigurationDataService } from '../core/data/configuration-data.service'; -import { NativeWindowService } from '../core/services/window.service'; import { MatomoInitializerService, MatomoTracker, } from 'ngx-matomo-client'; import { firstValueFrom } from 'rxjs'; +import { ConfigurationDataService } from '../core/data/configuration-data.service'; +import { NativeWindowService } from '../core/services/window.service'; +import { OrejimeService } from '../shared/cookies/orejime.service'; import { customMatomoScriptFactory } from './matomo.factory'; import { MatomoService } from './matomo.service';