Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
PastMeetingParticipant,
PastMeetingRecording,
PastMeetingSummary,
PlausiblePageviewContext,
Project,
PublicPastMeetingResponse,
ROOT_PROJECT_SLUG,
Expand All @@ -44,6 +45,7 @@ import { LinkifyPipe } from '@pipes/linkify.pipe';
import { MeetingTimePipe } from '@pipes/meeting-time.pipe';
import { RecurrenceSummaryPipe } from '@pipes/recurrence-summary.pipe';
import { MeetingService } from '@services/meeting.service';
import { PlausibleService } from '@services/plausible.service';
import { ProjectContextService } from '@services/project-context.service';
import { ProjectService } from '@services/project.service';
import { UserService } from '@services/user.service';
Expand Down Expand Up @@ -115,6 +117,7 @@ export class MeetingJoinComponent implements OnInit {
private readonly userService = inject(UserService);
private readonly clipboard = inject(Clipboard);
private readonly projectContextService = inject(ProjectContextService);
private readonly plausibleService = inject(PlausibleService);
private readonly dialogService = inject(DialogService);
private readonly destroyRef = inject(DestroyRef);
private readonly platformId = inject(PLATFORM_ID);
Expand Down Expand Up @@ -297,6 +300,7 @@ export class MeetingJoinComponent implements OnInit {
this.registrants = this.initializeRegistrants();
this.parentProject = this.initializeParentProject();
this.initializeAutoJoin();
this.initializePublicMeetingPageviewTracking();
}

public ngOnInit(): void {
Expand Down Expand Up @@ -1128,6 +1132,76 @@ export class MeetingJoinComponent implements OnInit {
);
}

/**
* Fire one enriched Plausible pageview for this public meeting page once
* the project (and parent foundation, when relevant) have resolved. The
* PlausibleService skips the auto NavigationEnd pageview for /meetings/:id
* paths so we land exactly one event per visit carrying foundation/project
Comment thread
niravpatel27 marked this conversation as resolved.
Outdated
* custom props. Anonymous viewers of private meetings receive a redacted
* `project: null` payload and intentionally fall through without firing.
*/
private initializePublicMeetingPageviewTracking(): void {
if (isPlatformServer(this.platformId)) {
return;
}
toObservable(this.project)
.pipe(
filter((p): p is Partial<Project> => !!p?.slug),
switchMap((project) => {
// No parent_uid → project is itself a top-level foundation; fire immediately.
if (!project.parent_uid) {
return of({ project, parent: null as Project | null });
}
// Wait for the parent foundation lookup; race a 2s fallback so a slow
// upstream (or a parent that resolves to ROOT and is mapped to null)
// never blocks the pageview indefinitely.
return merge(
toObservable(this.parentProject).pipe(
filter((parent): parent is Project => !!parent),
Comment thread
niravpatel27 marked this conversation as resolved.
map((parent) => ({ project, parent: parent as Project | null }))
),
timer(2000).pipe(map(() => ({ project, parent: null as Project | null })))
);
Comment thread
niravpatel27 marked this conversation as resolved.
}),
take(1),
takeUntilDestroyed(this.destroyRef)
Comment thread
niravpatel27 marked this conversation as resolved.
)
.subscribe(({ project, parent }) => {
this.plausibleService.trackPage(this.buildMeetingPageviewProps(project, parent));
});
Comment thread
niravpatel27 marked this conversation as resolved.
Comment on lines +1158 to +1171
}

/**
* Build the Plausible pageview props payload for a public meeting page.
*
* Branches on `project.parent_uid` rather than on whether `parent` resolved,
* so a slow parent fetch (or an ROOT_PROJECT_SLUG mapping that resolves to
* null) does not roll a sub-project up into the `foundation_*` keys. When
* the parent is expected but unresolved we leave foundation empty — honest
* gap vs. misattribution of the dashboard dimensions this is enriching.
*/
private buildMeetingPageviewProps(project: Partial<Project>, parent: Project | null): Record<string, unknown> {
const context: PlausiblePageviewContext = {};
if (project.parent_uid) {
// Sub-project under a foundation. Attribute the project correctly even
// if the parent fetch hasn't (or won't) resolve.
if (project.slug) context.project = project.slug;
if (project.name) context.project_name = project.name;
if (parent?.slug) context.foundation = parent.slug;
if (parent?.name) context.foundation_name = parent.name;
} else {
// Top-level project IS the foundation — only foundation_* keys populated.
if (project.slug) context.foundation = project.slug;
if (project.name) context.foundation_name = project.name;
Comment thread
niravpatel27 marked this conversation as resolved.
Outdated
}
return {
path: window.location.pathname,
url: `${window.location.origin}${window.location.pathname}`,
title: document.title,
...context,
};
}

private initializeRegistrants(): Signal<MeetingRegistrant[]> {
return toSignal(
combineLatest([
Expand Down
63 changes: 60 additions & 3 deletions apps/lfx-one/src/app/shared/services/plausible.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router } from '@angular/router';
import { environment } from '@environments/environment';
import { PLAUSIBLE_DOMAIN, PLAUSIBLE_SRC } from '@lfx-one/shared/constants';
import { PlausibleCall } from '@lfx-one/shared/interfaces';
import { PlausibleCall, PlausiblePageviewContext } from '@lfx-one/shared/interfaces';
import { filter } from 'rxjs';

import { LensService } from './lens.service';
import { ProjectContextService } from './project-context.service';

/**
* Plausible analytics service for privacy-friendly page and event tracking.
* Uses Angular's afterNextRender for SSR-safe script loading.
Expand All @@ -19,6 +22,17 @@ import { filter } from 'rxjs';
export class PlausibleService {
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
private readonly projectContextService = inject(ProjectContextService);
private readonly lensService = inject(LensService);

// Paths where context resolves asynchronously (public meeting page loads
// project data from the API after navigation). The auto-pageview is skipped
// for these and the owning component fires `trackPage()` once data settles,
// so we record one enriched event per visit instead of a context-free hit.
// Matches both upcoming-meeting URLs (`/meetings/<id>`) and past-meeting
// occurrence URLs (`/meetings/<id>-<13-digit-timestamp>`); both forms route
// to MeetingJoinComponent — see isPastMeetingOccurrenceId in that file.
private static readonly deferredPageviewPattern = /^\/meetings\/\d+(-\d{13})?$/;

private scriptLoaded = false;
private analyticsReady = false;
Expand Down Expand Up @@ -117,7 +131,13 @@ export class PlausibleService {
// only after the bundle has executed and replaced the queue stub.
script.onload = () => {
this.analyticsReady = true;
this.trackPage();
// Skip the initial pageview on deferred paths so a refresh on a
// public meeting URL doesn't leak a context-free hit before the
// component fires its enriched pageview.
if (PlausibleService.deferredPageviewPattern.test(window.location.pathname)) {
return;
}
this.trackPage(this.buildContextProps());
Comment on lines +149 to +152
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't suppress the onload fallback before the deferred hit can actually be sent.

MeetingJoinComponent can resolve its project context before the Plausible script finishes loading. trackPage() drops pre-ready calls, so this early return removes the only fallback for /meetings/... and the initial pageview is lost on fast API / slow CDN loads. Either queue the deferred hit against the stub or wait for analytics readiness before suppressing the onload send.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/lfx-one/src/app/shared/services/plausible.service.ts` around lines 149 -
152, The early return that checks PlausibleService.deferredPageviewPattern and
then skips this.trackPage(this.buildContextProps()) causes lost pageviews when
MeetingJoinComponent resolves context before the Plausible script is ready;
change the logic so you do not suppress the onload fallback until the deferred
hit is actually queued or analytics is ready — either (A) call
this.trackPage(...) unconditionally but modify trackPage to enqueue hits when
the real Plausible stub isn’t ready (so deferred hits are stored and flushed
later), or (B) guard the deferred-page suppression by checking the
analytics-ready flag (e.g., PlausibleService.isReady or similar) and only return
if the service is ready and the pattern matches; update references in
PlausibleService (deferredPageviewPattern, trackPage, buildContextProps) and in
MeetingJoinComponent usage accordingly.

};

script.onerror = (error) => {
Expand Down Expand Up @@ -149,14 +169,51 @@ export class PlausibleService {
if (typeof document === 'undefined') {
return;
}
const path = this.getSanitizedPath(event.urlAfterRedirects);
if (PlausibleService.deferredPageviewPattern.test(path)) {
return;
}
this.trackPage({
path: this.getSanitizedPath(event.urlAfterRedirects),
path,
url: this.getSanitizedUrl(),
title: document.title,
...this.buildContextProps(),
});
});
}

/**
* Build the foundation / project / lens custom-props for the current
* pageview from the active app state. Keys with empty values are omitted
* so Plausible doesn't store empty strings as a distinct dimension value.
* Returned as a Record so callers can spread into the broader pageview
Comment thread
niravpatel27 marked this conversation as resolved.
Outdated
* props payload that `trackPage` forwards to Plausible.
*/
private buildContextProps(): Record<string, unknown> {
const context: PlausiblePageviewContext = {};
// Read foundation and project independently so the project lens emits
// both keys — letting the dashboard filter by either dimension.
const foundation = this.projectContextService.selectedFoundation();
if (foundation?.slug) {
context.foundation = foundation.slug;
}
if (foundation?.name) {
context.foundation_name = foundation.name;
}
const project = this.projectContextService.selectedProject();
if (project?.slug) {
context.project = project.slug;
}
if (project?.name) {
context.project_name = project.name;
}
const lens = this.lensService.activeLens();
if (lens) {
Comment thread
niravpatel27 marked this conversation as resolved.
Outdated
context.lens = lens;
}
return { ...context };
}

/**
* Build a privacy-safe URL string for Plausible.
* Strips query params and hash to avoid leaking auth tokens, OTPs, or
Expand Down
14 changes: 14 additions & 0 deletions packages/shared/src/interfaces/plausible.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,20 @@ export interface PlausibleConfig {
enabled: boolean;
}

/**
* Custom-property context attached to Plausible pageviews so the dashboard
* can be sliced by foundation / project / lens. Stays well under Plausible's
* 30-keys-per-site limit. Keys are omitted when their value isn't known so
* Plausible doesn't store empty strings as a distinct dimension value.
*/
export interface PlausiblePageviewContext {
foundation?: string;
foundation_name?: string;
project?: string;
project_name?: string;
lens?: string;
}

/**
* One queued Plausible call captured by the upstream snippet's queue stub
* before the real script loads. Each entry is the full argument tuple the
Expand Down
Loading