Skip to content

Commit 009b7c5

Browse files
Adrian Heidenreichdeshmukhmayur
authored andcommitted
Added enhanced identity-aware tracking
Signed-off-by: Adrian Heidenreich <[email protected]>
1 parent 82a81d9 commit 009b7c5

File tree

5 files changed

+181
-31
lines changed

5 files changed

+181
-31
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@backstage-community/plugin-analytics-module-matomo': minor
3+
---
4+
5+
Added enhanced identity-aware tracking so Matomo records page views only after a userId is available, fixing the issue where navigate events fired anonymously despite `identity` being enabled

workspaces/analytics/plugins/analytics-module-matomo/README.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,19 @@ app:
6464
matomo:
6565
host: ${ANALYTICS_MATOMO_INSTANCE_URL}
6666
siteId: ${ANALYTICS_MATOMO_SITE_ID}
67-
identity: optional # (optional) to enable user tracking. Is disabled by default
68-
sendPlainUserId: optional # (optional) to not hash User ID when user tracking is enabled. User ID is hashed by default.
67+
identity: optional # disabled|optional|required; enables user tracking (disabled by default)
68+
sendPlainUserId: optional # if set, do not hash User ID when user tracking is enabled (hashed by default)
69+
enhancedTracking: true # enables extended tracking (navigate events buffering, identity gating)
70+
deferInitialPageView: true # only if enhancedTracking=true; defers first PV until identity resolved
6971
```
7072
73+
Additional optional properties:
74+
75+
- identity: "disabled" | "optional" | "required" (default: disabled)
76+
- sendPlainUserId: boolean; if true, raw userEntityRef sent (privacy: consider hashing)
77+
- enhancedTracking: boolean; adds buffering before identity ready & explicit page view handling
78+
- deferInitialPageView: boolean; when enhancedTracking=true, delays initial page view until identity is available
79+
7180
4. Update CSP in your `app-config.yaml`:(optional)
7281

7382
The following is the minimal content security policy required to load scripts from your Matomo Instance.

workspaces/analytics/plugins/analytics-module-matomo/config.d.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,25 @@ export interface Config {
3737
identity?: 'disabled' | 'optional' | 'required';
3838

3939
/**
40-
* Controls if the hashing of userId should be disabled
40+
* Sends the identity as plain userId instead of hashing it
4141
*
4242
* @visibility frontend
4343
*/
4444
sendPlainUserId?: boolean;
45+
46+
/**
47+
* Enables extended tracking capabilities (navigate events, buffering,…)
48+
*
49+
* @visibility frontend
50+
*/
51+
enhancedTracking?: boolean;
52+
53+
/**
54+
* Defers the initial page view until identity is available
55+
*
56+
* @visibility frontend
57+
*/
58+
deferInitialPageView?: boolean;
4559
};
4660
};
4761
};

workspaces/analytics/plugins/analytics-module-matomo/src/api/Matomo.ts

Lines changed: 149 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -26,31 +26,65 @@ import {
2626

2727
import { loadMatomo } from './loadMatomo';
2828

29+
export type PaqArg = string | number | undefined;
30+
export type PaqCommand = PaqArg[];
2931
declare 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
*/
3752
export 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));

workspaces/analytics/plugins/analytics-module-matomo/src/api/loadMatomo.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,7 @@ export const loadMatomo = (matomoUrl: string, siteId: number) => {
1919
if (isInitialized) return;
2020

2121
const _paq = ((window as any)._paq = (window as any)._paq || []);
22-
/* tracker methods like "setCustomDimension" should be called before "trackPageView" */
23-
_paq.push(['trackPageView']);
22+
/* Do not queue a page view yet; MatomoAnalytics controls when to emit it */
2423
_paq.push(['enableLinkTracking']);
2524
(() => {
2625
const u = `//${matomoUrl}/`;

0 commit comments

Comments
 (0)