@@ -26,31 +26,65 @@ import {
2626
2727import { loadMatomo } from './loadMatomo' ;
2828
29+ export type PaqArg = string | number | undefined ;
30+ export type PaqCommand = PaqArg [ ] ;
2931declare const window : Window &
3032 typeof globalThis & {
31- _paq : any [ ] ;
33+ _paq : PaqCommand [ ] ;
34+ __MATOMO_INITIAL_PV_SENT ?: boolean ;
35+ __MATOMO_INITIAL_PV_TS ?: number ;
3236 } ;
37+ const pushPaq = ( ...args : PaqArg [ ] ) => window . _paq . push ( args ) ;
38+
39+ type NormalizedMatomoEvent = {
40+ action : string ;
41+ subject ?: string ;
42+ value ?: number ;
43+ context : {
44+ extension ?: string ;
45+ extensionId ?: string ;
46+ } ;
47+ } ;
3348
3449/**
3550 * @public
3651 */
3752export class MatomoAnalytics implements AnalyticsApi , AnalyticsImplementation {
53+ private readonly enhancedTracking : boolean ;
54+ private readonly deferInitialPageView : boolean ;
55+ private userIdSet = false ;
56+ private pageViewSent = false ;
57+ private pendingEvents : NormalizedMatomoEvent [ ] = [ ] ;
58+
3859 private constructor ( options : {
3960 matomoUrl : string ;
4061 siteId : number ;
4162 identity : string ;
4263 identityApi ?: IdentityApi ;
4364 sendPlainUserId ?: boolean ;
65+ enhancedTracking ?: boolean ;
66+ deferInitialPageView ?: boolean ;
4467 } ) {
45- loadMatomo ( options . matomoUrl , options . siteId ) ;
68+ this . enhancedTracking = ! ! options . enhancedTracking ;
69+ this . deferInitialPageView =
70+ this . enhancedTracking && ! ! options . deferInitialPageView ;
71+ this . userIdSet = ! this . enhancedTracking && options . identity === 'disabled' ;
4672
47- /* Add user tracking if identity is enabled and identityApi is provided */
73+ loadMatomo ( options . matomoUrl , options . siteId ) ;
74+ // Initial PV unless explicitly deferred (enhancedTracking + deferInitialPageView)
75+ if ( ! this . enhancedTracking || ! this . deferInitialPageView ) {
76+ this . trackInitialPageView ( ) ;
77+ }
4878 if ( options . identity !== 'disabled' && options . identityApi ) {
49- this . setUserFrom ( options . identityApi , options . sendPlainUserId ) . catch (
50- ( ) => {
51- return ;
52- } ,
53- ) ;
79+ const shouldNotifyReady = true ;
80+
81+ this . setUserFrom (
82+ options . identityApi ,
83+ options . sendPlainUserId ,
84+ shouldNotifyReady ,
85+ ) . catch ( ( ) => { } ) ;
86+ } else if ( ! this . userIdSet ) {
87+ this . onIdentityReady ( ) ;
5488 }
5589 }
5690
@@ -67,12 +101,20 @@ export class MatomoAnalytics implements AnalyticsApi, AnalyticsImplementation {
67101 'app.analytics.matomo.sendPlainUserId' ,
68102 ) ;
69103
104+ const enhancedTracking = config . getOptionalBoolean (
105+ 'app.analytics.matomo.enhancedTracking' ,
106+ ) ;
107+
108+ const deferInitialPageView = config . getOptionalBoolean (
109+ 'app.analytics.matomo.deferInitialPageView' ,
110+ ) ;
111+
70112 const matomoUrl = config . getString ( 'app.analytics.matomo.host' ) ;
71113 const siteId = config . getNumber ( 'app.analytics.matomo.siteId' ) ;
72114
73115 if ( identity === 'required' && ! options ?. identityApi ) {
74116 throw new Error (
75- ' Invalid config: identity API must be provided to deps when app.matomo.identity is required',
117+ " Invalid config: identity API must be provided when app.analytics. matomo.identity is ' required'" ,
76118 ) ;
77119 }
78120
@@ -82,36 +124,117 @@ export class MatomoAnalytics implements AnalyticsApi, AnalyticsImplementation {
82124 identity,
83125 identityApi : options ?. identityApi ,
84126 sendPlainUserId,
127+ enhancedTracking,
128+ deferInitialPageView,
85129 } ) ;
86130 }
87131
88132 captureEvent ( event : AnalyticsEvent | LegacyAnalyticsEvent ) {
89- const { context, action, subject, value } = event ;
90- // REF: https://github.com/backstage/community-plugins/blob/main/workspaces/analytics/plugins/analytics-module-ga/src/apis/implementations/AnalyticsApi/GoogleAnalytics.ts#L160
91- // REF: https://matomo.org/faq/reports/implement-event-tracking-with-matomo/
92- window . _paq . push ( [
133+ const normalizedEvent = this . normalizeEvent ( event ) ;
134+ if ( ! this . userIdSet ) {
135+ this . pendingEvents . push ( normalizedEvent ) ; // Buffer until identity ready
136+ return ;
137+ }
138+ this . handleNormalizedEvent ( normalizedEvent ) ;
139+ }
140+
141+ private handleNormalizedEvent ( event : NormalizedMatomoEvent ) {
142+ if ( event . action === 'navigate' ) {
143+ this . trackPageView ( event ) ;
144+ return ;
145+ }
146+ this . pushEvent ( event ) ;
147+ }
148+
149+ private normalizeEvent (
150+ event : AnalyticsEvent | LegacyAnalyticsEvent ,
151+ ) : NormalizedMatomoEvent {
152+ const context = ( event . context ?? { } ) as NormalizedMatomoEvent [ 'context' ] ;
153+
154+ return {
155+ action : event . action ,
156+ subject : event . subject ,
157+ value : event . value ,
158+ context : {
159+ extension : context . extension ,
160+ extensionId : context . extensionId ,
161+ } ,
162+ } ;
163+ }
164+
165+ private pushEvent ( event : NormalizedMatomoEvent ) {
166+ pushPaq (
93167 'trackEvent' ,
94- context . extensionId || context . extension || 'App' ,
95- action ,
96- subject ,
97- value ,
98- ] ) ;
168+ event . context . extensionId || event . context . extension || 'App' ,
169+ event . action ,
170+ event . subject ,
171+ event . value ,
172+ ) ;
173+ }
174+
175+ private trackPageView ( event : NormalizedMatomoEvent ) {
176+ const subject = event . subject ?? window . location . pathname ?? '/' ;
177+ const normalizedSubject = subject . startsWith ( '/' ) ? subject : `/${ subject } ` ;
178+ const fullUrl = `${ window . location . origin } ${ normalizedSubject } ` ;
179+
180+ pushPaq ( 'setCustomUrl' , fullUrl ) ;
181+ pushPaq ( 'setDocumentTitle' , normalizedSubject ) ;
182+ pushPaq ( 'trackPageView' ) ;
183+
184+ // Mark global initial PV sentinel if first time; navigate events may serve as initial PV
185+ if ( ! window . __MATOMO_INITIAL_PV_SENT ) {
186+ window . __MATOMO_INITIAL_PV_SENT = true ;
187+ window . __MATOMO_INITIAL_PV_TS = Date . now ( ) ;
188+ }
189+ this . pageViewSent = true ; // prevent duplicate initial PV within instance
190+ }
191+
192+ private trackInitialPageView ( ) {
193+ if ( this . pageViewSent ) return ;
194+ // If deferral requested, caller MUST invoke only after identity ready; constructor invokes early only when no deferral
195+ try {
196+ if ( ! window . __MATOMO_INITIAL_PV_SENT ) {
197+ pushPaq ( 'trackPageView' ) ;
198+ window . __MATOMO_INITIAL_PV_SENT = true ;
199+ window . __MATOMO_INITIAL_PV_TS = Date . now ( ) ;
200+ }
201+ this . pageViewSent = true ;
202+ } catch {
203+ // Matomo script not loaded yet; ignoring initial PV
204+ }
205+ }
206+
207+ private flushPendingEvents ( ) {
208+ if ( ! this . pendingEvents . length ) {
209+ return ;
210+ }
211+
212+ const bufferedEvents = this . pendingEvents . slice ( ) ;
213+ this . pendingEvents = [ ] ;
214+ bufferedEvents . forEach ( e => this . handleNormalizedEvent ( e ) ) ;
215+ }
216+
217+ private onIdentityReady ( ) {
218+ this . userIdSet = true ;
219+ // First flush pending events; if a buffered navigate event exists it will send PageView
220+ this . flushPendingEvents ( ) ;
221+ // If no navigate event has set pageViewSent yet, send initial PageView now
222+ this . trackInitialPageView ( ) ;
99223 }
100224
101225 private async setUserFrom (
102226 identityApi : IdentityApi ,
103227 sendPlainUserId ?: boolean ,
228+ notifyReady ?: boolean ,
104229 ) {
105230 const { userEntityRef } = await identityApi . getBackstageIdentity ( ) ;
231+ const resolvedId = sendPlainUserId
232+ ? userEntityRef
233+ : await this . getPrivateUserId ( userEntityRef ) ; // hashed ID (no salt)
106234
107- if ( sendPlainUserId ) {
108- window . _paq . push ( [ 'setUserId' , userEntityRef ] ) ;
109- } else {
110- // Prevent PII from being passed to Matomo
111- const userId = await this . getPrivateUserId ( userEntityRef ) ;
235+ pushPaq ( 'setUserId' , resolvedId ) ;
112236
113- window . _paq . push ( [ 'setUserId' , userId ] ) ;
114- }
237+ if ( notifyReady ) this . onIdentityReady ( ) ;
115238 }
116239
117240 private getPrivateUserId ( userEntityRef : string ) : Promise < string > {
@@ -120,7 +243,7 @@ export class MatomoAnalytics implements AnalyticsApi, AnalyticsImplementation {
120243
121244 private async hash ( value : string ) : Promise < string > {
122245 const digest = await window . crypto . subtle . digest (
123- 'sha -256' ,
246+ 'SHA -256' ,
124247 new TextEncoder ( ) . encode ( value ) ,
125248 ) ;
126249 const hashArray = Array . from ( new Uint8Array ( digest ) ) ;
0 commit comments