Skip to content

Commit 1331c5a

Browse files
committed
Create service worker to intercept third party APIs and log to analytics
1 parent e6f1b0e commit 1331c5a

5 files changed

Lines changed: 288 additions & 1 deletion

File tree

entry.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import { registerAnalyticsServiceWorker } from "./lib/Core/registerAnalyticsServiceWorker";
12
import { renderUi } from "./lib/Views/render";
23

4+
// Register Service Worker early to intercept requests as soon as possible
5+
registerAnalyticsServiceWorker();
6+
37
renderUi();

index.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import registerCustomComponentTypes from "terriajs/lib/ReactViews/Custom/registe
99
import updateApplicationOnHashChange from "terriajs/lib/ViewModels/updateApplicationOnHashChange";
1010
import updateApplicationOnMessageFromParentWindow from "terriajs/lib/ViewModels/updateApplicationOnMessageFromParentWindow";
1111
import loadPlugins from "./lib/Core/loadPlugins";
12+
import { configureExternalResourceAnalytics } from "./lib/Core/registerAnalyticsServiceWorker";
1213
import showGlobalDisclaimer from "./lib/Views/showGlobalDisclaimer";
1314
import plugins from "./plugins";
1415

@@ -68,6 +69,11 @@ export default terria
6869
terria.raiseErrorToUser(e);
6970
})
7071
.finally(function () {
72+
// Configure external resource analytics tracking
73+
configureExternalResourceAnalytics(terria, {
74+
enabled: true
75+
});
76+
7177
// Override the default document title with appName. Check first for default
7278
// title, because user might have already customized the title in
7379
// index.ejs
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/**
2+
* Registers the analytics Service Worker for tracking external resource loads.
3+
*
4+
* The Service Worker intercepts all external fetch requests and notifies the main
5+
* thread, which logs them through Terria's analytics system.
6+
*
7+
*/
8+
9+
import type Terria from "terriajs/lib/Models/Terria";
10+
11+
export interface ExternalResourceAnalyticsConfig {
12+
enabled: boolean;
13+
excludeHostnames?: string[];
14+
includeHostnames?: string[];
15+
proxyBaseUrl?: string;
16+
}
17+
18+
let serviceWorkerRegistration: ServiceWorkerRegistration | null = null;
19+
20+
/**
21+
* Register the analytics Service Worker.
22+
* Call this early in app initialization (e.g., in entry.js).
23+
*/
24+
export async function registerAnalyticsServiceWorker(): Promise<ServiceWorkerRegistration | null> {
25+
if (!("serviceWorker" in navigator)) {
26+
console.warn("[Analytics] Service Workers not supported in this browser");
27+
return null;
28+
}
29+
30+
// Check if we're on HTTPS or localhost (required for Service Workers)
31+
const isSecureContext =
32+
window.location.protocol === "https:" ||
33+
window.location.hostname === "localhost" ||
34+
window.location.hostname === "127.0.0.1";
35+
36+
if (!isSecureContext) {
37+
console.warn(
38+
"[Analytics] Service Workers require HTTPS (except on localhost)"
39+
);
40+
return null;
41+
}
42+
43+
try {
44+
serviceWorkerRegistration = await navigator.serviceWorker.register(
45+
"/analytics-sw.js",
46+
{
47+
scope: "/"
48+
}
49+
);
50+
51+
console.log("[Analytics] Service Worker registered successfully");
52+
53+
// Wait for the service worker to be active
54+
if (serviceWorkerRegistration.installing) {
55+
await new Promise<void>((resolve) => {
56+
serviceWorkerRegistration!.installing!.addEventListener(
57+
"statechange",
58+
function handler() {
59+
if (this.state === "activated") {
60+
this.removeEventListener("statechange", handler);
61+
resolve();
62+
}
63+
}
64+
);
65+
});
66+
}
67+
68+
return serviceWorkerRegistration;
69+
} catch (error) {
70+
console.error("[Analytics] Service Worker registration failed:", error);
71+
return null;
72+
}
73+
}
74+
75+
/**
76+
* Configure the analytics Service Worker and set up message listener.
77+
* Logs events via terria.analytics.logEvent().
78+
*/
79+
export function configureExternalResourceAnalytics(
80+
terria: Terria,
81+
config?: ExternalResourceAnalyticsConfig
82+
): void {
83+
if (!serviceWorkerRegistration) {
84+
console.warn("[Analytics] Service Worker not registered, cannot configure");
85+
return;
86+
}
87+
88+
const worker = serviceWorkerRegistration.active;
89+
if (!worker) {
90+
console.warn("[Analytics] Service Worker not active, cannot configure");
91+
return;
92+
}
93+
94+
// Set up listener for messages from Service Worker
95+
navigator.serviceWorker.addEventListener("message", (event) => {
96+
if (event.data?.type === "EXTERNAL_RESOURCE") {
97+
terria.analytics?.logEvent(
98+
"externalResource",
99+
event.data.hostname,
100+
event.data.success ? "success" : "failure"
101+
);
102+
}
103+
});
104+
105+
const enabled = config?.enabled ?? false;
106+
107+
worker.postMessage({
108+
type: "ANALYTICS_CONFIG",
109+
config: {
110+
enabled,
111+
excludeHostnames: config?.excludeHostnames ?? [],
112+
includeHostnames: config?.includeHostnames ?? [],
113+
proxyBaseUrl:
114+
config?.proxyBaseUrl ?? terria.corsProxy.baseProxyUrl ?? "proxy/"
115+
}
116+
});
117+
118+
console.log(
119+
"[Analytics] External resource tracking:",
120+
enabled ? "enabled" : "disabled"
121+
);
122+
}

wwwroot/analytics-sw.js

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/**
2+
* Service Worker for External Resource Analytics
3+
*
4+
* This service worker intercepts all fetch requests and notifies the main thread
5+
* about external resource loads. The main thread logs them via Terria's analytics.
6+
*
7+
* Configuration is passed via message from the main thread after registration.
8+
*/
9+
10+
let config = {
11+
enabled: false,
12+
// Hostnames to exclude from logging
13+
excludeHostnames: [],
14+
// Only log requests to these hostnames (empty = log all external)
15+
includeHostnames: [],
16+
// Proxy base URL to extract target hostname from proxied requests
17+
proxyBaseUrl: null
18+
};
19+
20+
/**
21+
* Extract the target hostname from a URL, handling proxied URLs.
22+
*/
23+
function getTargetHostname(url) {
24+
try {
25+
const parsed = new URL(url);
26+
27+
// Check if this URL goes through a configured proxy
28+
if (config.proxyBaseUrl && url.includes(config.proxyBaseUrl)) {
29+
const proxyIndex =
30+
url.indexOf(config.proxyBaseUrl) + config.proxyBaseUrl.length;
31+
let targetUrl = url.substring(proxyIndex);
32+
33+
// Skip optional cache flag (e.g., "_1d/", "_2d/")
34+
const cacheFlagMatch = targetUrl.match(/^_[^/]+\/(.+)$/);
35+
if (cacheFlagMatch) {
36+
targetUrl = cacheFlagMatch[1];
37+
}
38+
39+
// Normalize the URL (handle https:/ -> https://)
40+
const normalizedUrl = targetUrl.replace(/^(https?):\/([^/])/, "$1://$2");
41+
return new URL(normalizedUrl).hostname;
42+
}
43+
44+
return parsed.hostname;
45+
} catch {
46+
return "unknown";
47+
}
48+
}
49+
50+
/**
51+
* Check if a URL is a proxied request.
52+
*/
53+
function isProxiedRequest(url) {
54+
return config.proxyBaseUrl && url.includes(config.proxyBaseUrl);
55+
}
56+
57+
/**
58+
* Check if a request should be logged.
59+
*/
60+
function shouldLogRequest(url) {
61+
if (!config.enabled) {
62+
return false;
63+
}
64+
65+
try {
66+
const parsed = new URL(url);
67+
68+
// Skip non-http(s) protocols
69+
if (!parsed.protocol.startsWith("http")) {
70+
return false;
71+
}
72+
73+
// Skip same-origin requests UNLESS they're going through the proxy
74+
if (parsed.origin === self.location.origin && !isProxiedRequest(url)) {
75+
return false;
76+
}
77+
78+
const hostname = getTargetHostname(url);
79+
80+
// Skip excluded hostnames
81+
if (config.excludeHostnames.some((h) => hostname.includes(h))) {
82+
return false;
83+
}
84+
85+
// If includeHostnames is set, only log those
86+
if (config.includeHostnames.length > 0) {
87+
return config.includeHostnames.some((h) => hostname.includes(h));
88+
}
89+
90+
return true;
91+
} catch {
92+
return false;
93+
}
94+
}
95+
96+
/**
97+
* Notify the main thread about an external resource load.
98+
*/
99+
async function notifyMainThread(url, success) {
100+
const hostname = getTargetHostname(url);
101+
102+
// Send message to all clients (main thread)
103+
const clients = await self.clients.matchAll();
104+
clients.forEach((client) => {
105+
client.postMessage({
106+
type: "EXTERNAL_RESOURCE",
107+
hostname: hostname,
108+
success: success
109+
});
110+
});
111+
}
112+
113+
// Handle messages from the main thread (for configuration)
114+
self.addEventListener("message", (event) => {
115+
if (event.data && event.data.type === "ANALYTICS_CONFIG") {
116+
config = { ...config, ...event.data.config };
117+
console.log(
118+
"[Analytics SW] Configuration updated:",
119+
config.enabled ? "enabled" : "disabled"
120+
);
121+
}
122+
});
123+
124+
// Install event - take control immediately
125+
self.addEventListener("install", () => {
126+
self.skipWaiting();
127+
});
128+
129+
// Activate event - claim all clients immediately
130+
self.addEventListener("activate", (event) => {
131+
event.waitUntil(self.clients.claim());
132+
});
133+
134+
// Fetch event - intercept all requests
135+
self.addEventListener("fetch", (event) => {
136+
const url = event.request.url;
137+
138+
if (!shouldLogRequest(url)) {
139+
return; // Let the request proceed normally without interception
140+
}
141+
142+
// Intercept the request to track success/failure
143+
event.respondWith(
144+
fetch(event.request)
145+
.then((response) => {
146+
notifyMainThread(url, response.ok);
147+
return response;
148+
})
149+
.catch((error) => {
150+
notifyMainThread(url, false);
151+
throw error;
152+
})
153+
);
154+
});

wwwroot/config.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55
"googleUrlShortenerKey": null,
66
"googleAnalyticsKey": null,
77
"googleAnalyticsOptions": null,
8+
89
// Log ConsoleAnalytics events to console
9-
// logToConsole: true,
10+
"logToConsole": true,
1011
/* Text that appears at the bottom of the map */
1112
"disclaimer": {
1213
"text": "Disclaimer: This map must not be used for navigation or precise spatial analysis",

0 commit comments

Comments
 (0)