fix(analytics): send Umami page views through track(), not the removed trackView()
Current Umami script.js exposes `window.umami = { track, identify }`;
`trackView` was the v1 API. `trackPageView()` called it unguarded, so
every admin route change threw
`TypeError: window.umami.trackView is not a function`.
`trackPageView()` now prefers `track(fn)` with the sanitized URL merged
into the tracker's default payload (no `name` = page view), falls back to
`trackView` only on a legacy script, and no-ops when the script has not
loaded yet or offers neither. The call is wrapped so a throwing tracker
can never break navigation. The `window.umami` typing marks the legacy
methods optional so the compiler enforces the guard.
Relates to issue 1316
This commit is contained in:
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* Pins the page-view call against the Umami runtime API that is actually
|
||||||
|
* loaded.
|
||||||
|
*
|
||||||
|
* Current Umami `script.js` exposes `window.umami = { track, identify }`;
|
||||||
|
* `trackView` was the v1 API and is gone. `trackPageView()` used to call
|
||||||
|
* `window.umami.trackView(...)` unguarded, so every admin route change threw
|
||||||
|
* `TypeError: window.umami.trackView is not a function` (issue 1316). The
|
||||||
|
* service must use `track()` when present, fall back to `trackView` on a
|
||||||
|
* legacy script, and never throw when the script is missing or has neither.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { analyticsService } from '../analytics.service';
|
||||||
|
|
||||||
|
type UmamiGlobal = NonNullable<Window['umami']>;
|
||||||
|
|
||||||
|
function freshService() {
|
||||||
|
// The service is a module-level singleton with an `initialized` latch; each
|
||||||
|
// case needs its own instance.
|
||||||
|
return new (analyticsService.constructor as new () => typeof analyticsService)();
|
||||||
|
}
|
||||||
|
|
||||||
|
function umamiService() {
|
||||||
|
const service = freshService();
|
||||||
|
service.initialize({
|
||||||
|
provider: 'umami',
|
||||||
|
hostUrl: 'https://analytics.example.com',
|
||||||
|
websiteId: 'site-123',
|
||||||
|
doNotTrack: true,
|
||||||
|
});
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUmami(global: Partial<UmamiGlobal> | undefined) {
|
||||||
|
(window as unknown as { umami?: Partial<UmamiGlobal> }).umami = global;
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the real tracker hands to a `track(fn)` callback: its own default
|
||||||
|
// payload, built from the script's data-* attributes and window state.
|
||||||
|
const DEFAULT_PAYLOAD = {
|
||||||
|
website: 'site-123',
|
||||||
|
screen: '1280x800',
|
||||||
|
language: 'en',
|
||||||
|
title: 'PicPeak',
|
||||||
|
hostname: 'picpeak.example.com',
|
||||||
|
url: 'https://picpeak.example.com/admin/login',
|
||||||
|
referrer: 'https://google.com/',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('analyticsService.trackPageView against the loaded Umami API', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
document.head.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setUmami(undefined);
|
||||||
|
document.head.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses track() with a page-view payload when the script has no trackView (current Umami)', () => {
|
||||||
|
const track = vi.fn();
|
||||||
|
setUmami({ track });
|
||||||
|
|
||||||
|
expect(() => umamiService().trackPageView('/admin/events?page=2')).not.toThrow();
|
||||||
|
|
||||||
|
expect(track).toHaveBeenCalledTimes(1);
|
||||||
|
const [payloadFn] = track.mock.calls[0];
|
||||||
|
expect(typeof payloadFn).toBe('function');
|
||||||
|
|
||||||
|
const payload = payloadFn(DEFAULT_PAYLOAD);
|
||||||
|
// Tracker defaults are preserved, only the URL is overridden — and the
|
||||||
|
// query string is dropped by the sanitizer.
|
||||||
|
expect(payload).toMatchObject({ ...DEFAULT_PAYLOAD, url: '/admin/events' });
|
||||||
|
// No `name` → Umami records a page view, not a custom event.
|
||||||
|
expect(payload).not.toHaveProperty('name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the tracker\'s own referrer unless one is passed explicitly', () => {
|
||||||
|
const track = vi.fn();
|
||||||
|
setUmami({ track });
|
||||||
|
const service = umamiService();
|
||||||
|
|
||||||
|
service.trackPageView('/admin/dashboard');
|
||||||
|
expect(track.mock.calls[0][0](DEFAULT_PAYLOAD).referrer).toBe(DEFAULT_PAYLOAD.referrer);
|
||||||
|
|
||||||
|
service.trackPageView('/admin/dashboard', 'https://picpeak.example.com/admin/events');
|
||||||
|
expect(track.mock.calls[1][0](DEFAULT_PAYLOAD).referrer).toBe(
|
||||||
|
'https://picpeak.example.com/admin/events'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still redacts the gallery share token on the track() path (GHSA-7m6c)', () => {
|
||||||
|
const track = vi.fn();
|
||||||
|
setUmami({ track });
|
||||||
|
|
||||||
|
umamiService().trackPageView('/gallery/summer-wedding/0123456789abcdef0123456789abcdef?x=1');
|
||||||
|
|
||||||
|
expect(track.mock.calls[0][0](DEFAULT_PAYLOAD).url).toBe('/gallery/summer-wedding/[redacted]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the legacy trackView() when that is all the script offers', () => {
|
||||||
|
const trackView = vi.fn();
|
||||||
|
setUmami({ trackView });
|
||||||
|
|
||||||
|
expect(() => umamiService().trackPageView('/admin/events?page=2')).not.toThrow();
|
||||||
|
|
||||||
|
expect(trackView).toHaveBeenCalledTimes(1);
|
||||||
|
expect(trackView).toHaveBeenCalledWith('/admin/events', undefined, 'site-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers track() over trackView() when a script exposes both', () => {
|
||||||
|
const track = vi.fn();
|
||||||
|
const trackView = vi.fn();
|
||||||
|
setUmami({ track, trackView });
|
||||||
|
|
||||||
|
umamiService().trackPageView('/admin/events');
|
||||||
|
|
||||||
|
expect(track).toHaveBeenCalledTimes(1);
|
||||||
|
expect(trackView).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op while the script has not loaded yet', () => {
|
||||||
|
setUmami(undefined);
|
||||||
|
|
||||||
|
expect(() => umamiService().trackPageView('/admin/events')).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when the global has neither page-view API', () => {
|
||||||
|
setUmami({ identify: vi.fn() } as unknown as Partial<UmamiGlobal>);
|
||||||
|
|
||||||
|
expect(() => umamiService().trackPageView('/admin/events')).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let a throwing tracker break navigation', () => {
|
||||||
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
setUmami({
|
||||||
|
track: () => {
|
||||||
|
throw new Error('collector unreachable');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => umamiService().trackPageView('/admin/events')).not.toThrow();
|
||||||
|
expect(warn).toHaveBeenCalledTimes(1);
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -65,9 +65,17 @@ type InitConfig = UmamiInitConfig | RybbitInitConfig | CustomInitConfig | NoneIn
|
|||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
umami?: {
|
umami?: {
|
||||||
track: (eventName: string, eventData?: any) => void;
|
// Current script.js (v2): `track(name, data)` sends a named event;
|
||||||
trackView: (url?: string, referrer?: string, websiteId?: string) => void;
|
// `track(fn)` sends fn(defaultPayload) — a payload without `name` is a
|
||||||
trackEvent: (
|
// page view. This is the only page-view API the shipped tracker has.
|
||||||
|
track?: {
|
||||||
|
(eventName: string, eventData?: any): void;
|
||||||
|
(payload: (props: Record<string, unknown>) => Record<string, unknown>): void;
|
||||||
|
};
|
||||||
|
// Legacy (v1) API. Absent from current script.js — calling it
|
||||||
|
// unguarded is what threw on every admin route change (issue 1316).
|
||||||
|
trackView?: (url?: string, referrer?: string, websiteId?: string) => void;
|
||||||
|
trackEvent?: (
|
||||||
eventValue: string,
|
eventValue: string,
|
||||||
eventType: string,
|
eventType: string,
|
||||||
url?: string,
|
url?: string,
|
||||||
@@ -185,7 +193,7 @@ class AnalyticsService {
|
|||||||
track(eventName: string, eventData?: Record<string, any>) {
|
track(eventName: string, eventData?: Record<string, any>) {
|
||||||
if (!this.initialized) return;
|
if (!this.initialized) return;
|
||||||
if (this.provider === 'umami' && typeof window !== 'undefined' && window.umami) {
|
if (this.provider === 'umami' && typeof window !== 'undefined' && window.umami) {
|
||||||
window.umami.track(eventName, eventData);
|
window.umami.track?.(eventName, eventData);
|
||||||
} else if (this.provider === 'rybbit' && typeof window !== 'undefined' && window.rybbit) {
|
} else if (this.provider === 'rybbit' && typeof window !== 'undefined' && window.rybbit) {
|
||||||
window.rybbit.event(eventName, eventData);
|
window.rybbit.event(eventName, eventData);
|
||||||
}
|
}
|
||||||
@@ -217,10 +225,30 @@ class AnalyticsService {
|
|||||||
// ONLY page-view source. Rybbit keeps its own auto-tracking with
|
// ONLY page-view source. Rybbit keeps its own auto-tracking with
|
||||||
// data-mask-patterns doing the redaction, so a manual call would
|
// data-mask-patterns doing the redaction, so a manual call would
|
||||||
// double-count — skip it. 'none'/'custom' have no page-view API.
|
// double-count — skip it. 'none'/'custom' have no page-view API.
|
||||||
if (this.provider !== 'umami' || typeof window === 'undefined' || !window.umami) return;
|
if (this.provider !== 'umami' || typeof window === 'undefined') return;
|
||||||
|
// The script tag is injected async, so `window.umami` is absent until it
|
||||||
|
// has loaded; a route change before that is simply not recorded.
|
||||||
|
const umami = window.umami;
|
||||||
|
if (!umami) return;
|
||||||
const raw = url ?? window.location.pathname;
|
const raw = url ?? window.location.pathname;
|
||||||
const safe = this.sanitizeTrackedUrl(raw);
|
const safe = this.sanitizeTrackedUrl(raw);
|
||||||
window.umami.trackView(safe, referrer, this.websiteId || undefined);
|
try {
|
||||||
|
if (typeof umami.track === 'function') {
|
||||||
|
// Umami v2 page view: merge the sanitized URL into the tracker's own
|
||||||
|
// default payload (website, screen, language, title, …). Without a
|
||||||
|
// `name` the collector records it as a page view.
|
||||||
|
umami.track((props) => ({
|
||||||
|
...props,
|
||||||
|
url: safe,
|
||||||
|
...(referrer !== undefined ? { referrer } : {}),
|
||||||
|
}));
|
||||||
|
} else if (typeof umami.trackView === 'function') {
|
||||||
|
umami.trackView(safe, referrer, this.websiteId || undefined);
|
||||||
|
}
|
||||||
|
// Neither API → no-op. Analytics must never break navigation.
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Analytics: page-view tracking failed', err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gallery-specific tracking events
|
// Gallery-specific tracking events
|
||||||
|
|||||||
Reference in New Issue
Block a user