Skip to content

Commit c990ccf

Browse files
authored
feat(analytics): tag Plausible pageviews with foundation/project/lens (#709)
* feat(analytics): tag Plausible pageviews with foundation/project/lens PlausibleService now reads ProjectContextService and LensService on every NavigationEnd, attaching foundation / foundation_name / project / project_name / lens custom-props to each pageview so the dashboard becomes filterable and groupable by those dimensions. Previously opaque entries like /meetings/<id> can now be sliced by their owning foundation/project. Public /meetings/:numericId routes load project context asynchronously, so the auto NavigationEnd pageview is deferred for those paths and MeetingJoinComponent fires one enriched pageview once meeting + project (and parent foundation when applicable) have resolved. A 2s timeout race ensures the event still fires when the parent maps to the root project and is returned as null by initializeParentProject. No route, redirect, guard, or user-visible behavior changes. Signed-off-by: Nirav Patel <npatel@linuxfoundation.org> * fix(analytics): correct past-meeting + slow-parent Plausible attribution Address two issues raised in PR review: - Extend deferredPageviewPattern to also match past-meeting occurrence URLs of the form /meetings/<id>-<13-digit-timestamp> (matches the shape that isPastMeetingOccurrenceId validates). Previously these paths slipped past the auto-pageview skip and produced a context-free NavigationEnd event in addition to the deferred enriched event from MeetingJoinComponent — double-counting every past-meeting visit. - Branch buildMeetingPageviewProps on project.parent_uid rather than on whether parent resolved. When parent_uid is set but the lookup is slow, the 2s timer race used to fire with parent=null, which mis-attributed the sub-project under foundation_* and dropped project_* entirely. The new branch records the sub-project correctly under project_* and leaves foundation_* empty when unresolved — honest gap vs. data corruption in the dashboard dimensions this PR is introducing. Signed-off-by: Nirav Patel <npatel@linuxfoundation.org> * refactor(analytics): centralize Plausible pageview-context builder Address review feedback from MRashad26: - Extract PlausibleService.buildPageviewContext({...}) as the single owner of the pageview custom-prop schema. Both buildContextProps (service) and the deferred-pageview callsite in MeetingJoinComponent route through it, so adding a new dimension never lands in just one of the two callsites. - trackPage(context?: PlausiblePageviewContext) now auto-prepends path/url/title via getSanitizedPath/getSanitizedUrl. Callers only supply context — MeetingJoinComponent no longer touches window.location or document.title directly, removing the privacy/sanitization-bypass risk if a future public meeting URL ever carried query-string tokens. - Drop redundant `return { ...context };` spread on buildContextProps — the new typed return is PlausiblePageviewContext. - Name the parent-fetch timeout (PARENT_FETCH_TIMEOUT_MS = 2000) so its load-bearing role isn't buried as a bare literal. - Strip multi-paragraph JSDocs and multi-line inline comments added in this PR per CLAUDE.md ("default to writing no comments. Never write multi-paragraph docstrings or multi-line comment blocks"). No behavior or data change: same pageview frequency, same custom-prop values populated under the same conditions. Initial script.onload pageview now also includes path/url/title in props (previously only via `u`); strictly additive. Signed-off-by: Nirav Patel <npatel@linuxfoundation.org> --------- Signed-off-by: Nirav Patel <npatel@linuxfoundation.org>
1 parent a3d54e9 commit c990ccf

3 files changed

Lines changed: 110 additions & 13 deletions

File tree

apps/lfx-one/src/app/modules/meetings/meeting-join/meeting-join.component.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { LinkifyPipe } from '@pipes/linkify.pipe';
4444
import { MeetingTimePipe } from '@pipes/meeting-time.pipe';
4545
import { RecurrenceSummaryPipe } from '@pipes/recurrence-summary.pipe';
4646
import { MeetingService } from '@services/meeting.service';
47+
import { PlausibleService } from '@services/plausible.service';
4748
import { ProjectContextService } from '@services/project-context.service';
4849
import { ProjectService } from '@services/project.service';
4950
import { UserService } from '@services/user.service';
@@ -115,6 +116,7 @@ export class MeetingJoinComponent implements OnInit {
115116
private readonly userService = inject(UserService);
116117
private readonly clipboard = inject(Clipboard);
117118
private readonly projectContextService = inject(ProjectContextService);
119+
private readonly plausibleService = inject(PlausibleService);
118120
private readonly dialogService = inject(DialogService);
119121
private readonly destroyRef = inject(DestroyRef);
120122
private readonly platformId = inject(PLATFORM_ID);
@@ -297,6 +299,7 @@ export class MeetingJoinComponent implements OnInit {
297299
this.registrants = this.initializeRegistrants();
298300
this.parentProject = this.initializeParentProject();
299301
this.initializeAutoJoin();
302+
this.initializePublicMeetingPageviewTracking();
300303
}
301304

302305
public ngOnInit(): void {
@@ -1128,6 +1131,46 @@ export class MeetingJoinComponent implements OnInit {
11281131
);
11291132
}
11301133

1134+
// PlausibleService defers the auto-pageview for /meetings/:id — fire it here once context lands.
1135+
private initializePublicMeetingPageviewTracking(): void {
1136+
if (isPlatformServer(this.platformId)) {
1137+
return;
1138+
}
1139+
// Caps the parent foundation fetch so ROOT_PROJECT_SLUG → null (mapped in initializeParentProject)
1140+
// doesn't block the pageview forever. Sub-projects whose parent doesn't land in time record
1141+
// project_* correctly and leave foundation_* empty (see buildPageviewContext callsite below).
1142+
const PARENT_FETCH_TIMEOUT_MS = 2000;
1143+
toObservable(this.project)
1144+
.pipe(
1145+
filter((p): p is Partial<Project> => !!p?.slug),
1146+
switchMap((project) => {
1147+
if (!project.parent_uid) {
1148+
return of({ project, parent: null as Project | null });
1149+
}
1150+
return merge(
1151+
toObservable(this.parentProject).pipe(
1152+
filter((parent): parent is Project => !!parent),
1153+
map((parent) => ({ project, parent: parent as Project | null }))
1154+
),
1155+
timer(PARENT_FETCH_TIMEOUT_MS).pipe(map(() => ({ project, parent: null as Project | null })))
1156+
);
1157+
}),
1158+
take(1),
1159+
takeUntilDestroyed(this.destroyRef)
1160+
)
1161+
.subscribe(({ project, parent }) => {
1162+
const isTopLevel = !project.parent_uid;
1163+
this.plausibleService.trackPage(
1164+
PlausibleService.buildPageviewContext({
1165+
foundationSlug: isTopLevel ? project.slug : parent?.slug,
1166+
foundationName: isTopLevel ? project.name : parent?.name,
1167+
projectSlug: isTopLevel ? undefined : project.slug,
1168+
projectName: isTopLevel ? undefined : project.name,
1169+
})
1170+
);
1171+
});
1172+
}
1173+
11311174
private initializeRegistrants(): Signal<MeetingRegistrant[]> {
11321175
return toSignal(
11331176
combineLatest([

apps/lfx-one/src/app/shared/services/plausible.service.ts

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
66
import { NavigationEnd, Router } from '@angular/router';
77
import { environment } from '@environments/environment';
88
import { PLAUSIBLE_DOMAIN, PLAUSIBLE_SRC } from '@lfx-one/shared/constants';
9-
import { PlausibleCall } from '@lfx-one/shared/interfaces';
9+
import { PlausibleCall, PlausiblePageviewContext } from '@lfx-one/shared/interfaces';
1010
import { filter } from 'rxjs';
1111

12+
import { LensService } from './lens.service';
13+
import { ProjectContextService } from './project-context.service';
14+
1215
/**
1316
* Plausible analytics service for privacy-friendly page and event tracking.
1417
* Uses Angular's afterNextRender for SSR-safe script loading.
@@ -19,6 +22,11 @@ import { filter } from 'rxjs';
1922
export class PlausibleService {
2023
private readonly router = inject(Router);
2124
private readonly destroyRef = inject(DestroyRef);
25+
private readonly projectContextService = inject(ProjectContextService);
26+
private readonly lensService = inject(LensService);
27+
28+
// Routes whose owning component fires `trackPage()` itself once async context resolves.
29+
private static readonly deferredPageviewPattern = /^\/meetings\/\d+(-\d{13})?$/;
2230

2331
private scriptLoaded = false;
2432
private analyticsReady = false;
@@ -47,22 +55,43 @@ export class PlausibleService {
4755
});
4856
}
4957

50-
/**
51-
* Track a page view
52-
* @param properties Optional page properties
53-
*/
54-
public trackPage(properties?: Record<string, unknown>): void {
58+
// Auto-prepends sanitized path/url/title — callers only supply context.
59+
public trackPage(context?: PlausiblePageviewContext): void {
5560
if (typeof window === 'undefined' || this.impersonating || !this.analyticsReady || !window.plausible) {
5661
return;
5762
}
5863

5964
try {
60-
window.plausible('pageview', { u: this.getSanitizedUrl(), props: properties });
65+
const url = this.getSanitizedUrl();
66+
const props: Record<string, unknown> = {
67+
path: this.getSanitizedPath(window.location.pathname),
68+
url,
69+
title: typeof document !== 'undefined' ? document.title : undefined,
70+
...(context as Record<string, unknown> | undefined),
71+
};
72+
window.plausible('pageview', { u: url, props });
6173
} catch (error) {
6274
console.error('Error tracking page with Plausible:', error);
6375
}
6476
}
6577

78+
// Single owner of the pageview custom-prop schema — keep new dimensions here, not at callsites.
79+
public static buildPageviewContext(input: {
80+
foundationSlug?: string | null;
81+
foundationName?: string | null;
82+
projectSlug?: string | null;
83+
projectName?: string | null;
84+
lens?: string | null;
85+
}): PlausiblePageviewContext {
86+
const context: PlausiblePageviewContext = {};
87+
if (input.foundationSlug) context.foundation = input.foundationSlug;
88+
if (input.foundationName) context.foundation_name = input.foundationName;
89+
if (input.projectSlug) context.project = input.projectSlug;
90+
if (input.projectName) context.project_name = input.projectName;
91+
if (input.lens) context.lens = input.lens;
92+
return context;
93+
}
94+
6695
/**
6796
* Track a custom event
6897
* @param eventName Event name
@@ -117,7 +146,10 @@ export class PlausibleService {
117146
// only after the bundle has executed and replaced the queue stub.
118147
script.onload = () => {
119148
this.analyticsReady = true;
120-
this.trackPage();
149+
if (PlausibleService.deferredPageviewPattern.test(window.location.pathname)) {
150+
return;
151+
}
152+
this.trackPage(this.buildContextProps());
121153
};
122154

123155
script.onerror = (error) => {
@@ -149,14 +181,27 @@ export class PlausibleService {
149181
if (typeof document === 'undefined') {
150182
return;
151183
}
152-
this.trackPage({
153-
path: this.getSanitizedPath(event.urlAfterRedirects),
154-
url: this.getSanitizedUrl(),
155-
title: document.title,
156-
});
184+
const path = this.getSanitizedPath(event.urlAfterRedirects);
185+
if (PlausibleService.deferredPageviewPattern.test(path)) {
186+
return;
187+
}
188+
this.trackPage(this.buildContextProps());
157189
});
158190
}
159191

192+
// Foundation/project read independently (not via activeContext) so the project lens emits both.
193+
private buildContextProps(): PlausiblePageviewContext {
194+
const foundation = this.projectContextService.selectedFoundation();
195+
const project = this.projectContextService.selectedProject();
196+
return PlausibleService.buildPageviewContext({
197+
foundationSlug: foundation?.slug,
198+
foundationName: foundation?.name,
199+
projectSlug: project?.slug,
200+
projectName: project?.name,
201+
lens: this.lensService.activeLens(),
202+
});
203+
}
204+
160205
/**
161206
* Build a privacy-safe URL string for Plausible.
162207
* Strips query params and hash to avoid leaking auth tokens, OTPs, or

packages/shared/src/interfaces/plausible.interface.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,15 @@ export interface PlausibleConfig {
1313
enabled: boolean;
1414
}
1515

16+
// Custom-prop schema attached to Plausible pageviews — keys omitted when unknown.
17+
export interface PlausiblePageviewContext {
18+
foundation?: string;
19+
foundation_name?: string;
20+
project?: string;
21+
project_name?: string;
22+
lens?: string;
23+
}
24+
1625
/**
1726
* One queued Plausible call captured by the upstream snippet's queue stub
1827
* before the real script loads. Each entry is the full argument tuple the

0 commit comments

Comments
 (0)