Skip to content

feat: working matomo analytics (test environment) #41

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jun 5, 2025
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions src/argo-archive-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import filingDrawer from "assets/images/filing-drawer.avif";

import { getLocalOption } from "./localstorage";
import { Index as FlexIndex } from "flexsearch";
import { onPageClicked } from "./events";

@customElement("argo-archive-list")
export class ArgoArchiveList extends LitElement {
Expand Down Expand Up @@ -434,6 +435,7 @@ export class ArgoArchiveList extends LitElement {
}

private async _openPage(page: { ts: string; url: string }) {
onPageClicked(page.url);
const tsParam = new Date(Number(page.ts))
.toISOString()
.replace(/[-:TZ.]/g, "");
Expand Down
78 changes: 78 additions & 0 deletions src/events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { trackEvent } from "./matomo";
import { getLocalOption } from "./localstorage";

// Track when a user clicks on an archived page to view it
export async function onPageClicked(pageUrl: string): Promise<void> {
console.log("onPageClicked called with URL:", pageUrl);
await trackEvent("Packrat Chrome Extension: Archive", "ViewPage", pageUrl);
}

// Track when a torrent is created for sharing
export async function onTorrentCreated(numPages: number): Promise<void> {
console.log("onTorrentCreated called with pages:", numPages);
await trackEvent(
"Packrat Chrome Extension: Sharing",
"TorrentCreated",
`${numPages} pages`,
);
}

// Track when a page is successfully archived
export async function onPageArchived(
pageUrl: string,
pageSize?: number,
): Promise<void> {
console.log("onPageArchived called:", pageUrl, pageSize);
await trackEvent(
"Packrat Chrome Extension: Archive",
"PageArchived",
pageUrl,
);

// If page size is provided, track it separately
if (pageSize !== undefined) {
await trackEvent(
"Packrat Chrome Extension: Archive",
"PageSize",
`${Math.round(pageSize / 1024)}KB`,
);
}
}

// Track settings changes
export async function onSettingsChanged(
settingName: string,
value: string | boolean | number,
): Promise<void> {
console.log("onSettingsChanged:", settingName, value);
await trackEvent(
"Packrat Chrome Extension: Settings",
settingName,
String(value),
);
}

// Track total archive size
export async function trackArchiveSize(totalSizeBytes: number): Promise<void> {
const sizeMB = Math.round(totalSizeBytes / (1024 * 1024));
console.log("trackArchiveSize:", sizeMB, "MB");
await trackEvent(
"Packrat Chrome Extension: Archive",
"TotalSize",
`${sizeMB}MB`,
);
}

// Track when archiving starts
export async function onArchivingStarted(pageUrl: string): Promise<void> {
console.log("onArchivingStarted:", pageUrl);
await trackEvent("Packrat Chrome Extension: Archive", "Started", pageUrl);
}

// Track when archiving stops
export async function onArchivingStopped(
reason: string = "manual",
): Promise<void> {
console.log("onArchivingStopped:", reason);
await trackEvent("Packrat Chrome Extension: Archive", "Stopped", reason);
}
74 changes: 74 additions & 0 deletions src/ext/bg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ import {
getSharedArchives,
} from "../localstorage";
import { isValidUrl } from "../utils";

import {
trackArchiveSize,
onArchivingStarted,
onArchivingStopped,
} from "../events";
// ===========================================================================
self.recorders = {};
self.newRecId = null;
Expand All @@ -24,6 +30,7 @@ let defaultCollId = null;
let autorun = false;
let isRecordingEnabled = false;
let skipDomains = [] as string[];
let lastTrackedTotalSize = 0;

const openWinMap = new Map();

Expand All @@ -39,6 +46,31 @@ let sidepanelPort = null;
skipDomains = (await getLocalOption("skipDomains")) || [];
})();

async function checkAndTrackArchiveSize() {
try {
const collId = await getLocalOption("defaultCollId");
if (!collId) return;

const coll = await collLoader.loadColl(collId);
if (!coll?.store?.getAllPages) return;

const pages = await coll.store.getAllPages();
// @ts-expect-error any
const totalSize = pages.reduce((sum, page) => sum + (page.size || 0), 0);

const sizeDiff = totalSize - lastTrackedTotalSize;
if (
sizeDiff > 10 * 1024 * 1024 ||
(totalSize > 0 && lastTrackedTotalSize === 0)
) {
await trackArchiveSize(totalSize);
lastTrackedTotalSize = totalSize;
}
} catch (error) {
console.error("Error tracking archive size:", error);
}
}

// ===========================================================================

function main() {
Expand Down Expand Up @@ -110,6 +142,7 @@ function sidepanelHandler(port) {
if (coll?.store?.getAllPages) {
const pages = await coll.store.getAllPages();
port.postMessage({ type: "pages", pages });
await checkAndTrackArchiveSize();
} else {
port.postMessage({ type: "pages", pages: [] });
}
Expand All @@ -127,6 +160,7 @@ function sidepanelHandler(port) {
await coll.store.deletePage(id);
}

await checkAndTrackArchiveSize();
// now re-send the new list of pages
const pages = await coll.store.getAllPages();
port.postMessage({ type: "pages", pages });
Expand Down Expand Up @@ -156,6 +190,10 @@ function sidepanelHandler(port) {
//@ts-expect-error - 2 parameters but 3
tab.url,
);

if (tab.url) {
await onArchivingStarted(tab.url);
}
}

port.postMessage({
Expand All @@ -179,6 +217,9 @@ function sidepanelHandler(port) {
stopRecorder(tabId);
}

await checkAndTrackArchiveSize();
await onArchivingStopped("manual");

port.postMessage({
type: "status",
recording: false,
Expand Down Expand Up @@ -223,6 +264,21 @@ chrome.runtime.onMessage.addListener(
// @ts-expect-error - TS7006 - Parameter 'message' implicitly has an 'any' type.
(message /*sender, sendResponse*/) => {
console.log("onMessage", message);

if (message.type === "matomoTrack" && message.url) {
fetch(message.url, {
method: "GET",
mode: "no-cors",
})
.then(() => {
console.log("Matomo tracking sent from background:", message.url);
})
.catch((error) => {
console.error("Matomo tracking error in background:", error);
});
return true;
}

switch (message.msg) {
case "optionsChanged":
for (const rec of Object.values(self.recorders)) {
Expand All @@ -244,6 +300,11 @@ chrome.runtime.onMessage.addListener(
case "disableCSP":
disableCSPForTab(message.tabId);
break;

case "checkArchiveSize":
// Check and track archive size when requested
checkAndTrackArchiveSize();
break;
}
return true;
},
Expand Down Expand Up @@ -531,6 +592,19 @@ async function disableCSPForTab(tabId) {
// ===========================================================================
chrome.runtime.onInstalled.addListener(main);

chrome.runtime.onStartup.addListener(async () => {
await checkAndTrackArchiveSize();
});

setInterval(
async () => {
if (Object.keys(self.recorders).length > 0) {
await checkAndTrackArchiveSize();
}
},
5 * 60 * 1000,
);

if (self.importScripts) {
self.importScripts("sw.js");
}
130 changes: 130 additions & 0 deletions src/matomo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// matomo.ts - Matomo tracking with opt-out and persistent user ID

import { getLocalOption, setLocalOption } from "./localstorage";

const MATOMO_URL = "https://analytics.vaporware.network/matomo.php";
const SITE_ID = "1";
const USER_ID_KEY = "matomoUserId";

/**
* Ensure there is a persistent user ID in local storage.
* If one doesn't exist, generate a random hex string, store it, and return it.
*/
async function getOrCreateUserId(): Promise<string> {
let stored = await getLocalOption(USER_ID_KEY);
if (stored && typeof stored === "string") {
return stored;
}

// Generate a 16-byte (128-bit) hex string
const randomId = Array.from({ length: 16 })
.map(() =>
Math.floor(Math.random() * 256)
.toString(16)
.padStart(2, "0"),
)
.join("");

await setLocalOption(USER_ID_KEY, randomId);
return randomId;
}

/**
* Reads the "analyticsEnabled" key via getLocalOption.
* We expect it to be stored as "1" or "0".
* Returns true only if the stored value is exactly "1".
*/
async function checkAnalyticsEnabled(): Promise<boolean> {
const stored = await getLocalOption("analyticsEnabled");
return stored === "1";
}

/**
* Check if we're in the background/service worker context
*/
function isBackgroundContext(): boolean {
// Check if we have access to chrome.tabs (only available in background)
return typeof chrome !== "undefined" && chrome.tabs !== undefined;
}

/**
* Send a simple event to Matomo, but only if analyticsEnabled === "1".
* Includes a persistent user ID (uid) in every request.
*/
export async function trackEvent(
category: string,
action: string,
name?: string,
): Promise<void> {
try {
const isEnabled = await checkAnalyticsEnabled();
if (!isEnabled) {
console.log("Matomo tracking is disabled; skipping event:", {
category,
action,
name,
});
return;
}

const userId = await getOrCreateUserId();
const params = new URLSearchParams({
// Required
idsite: SITE_ID,
rec: "1",

// Event parameters
e_c: category,
e_a: action,
e_n: name || "",

// Basic info
url: "chrome-extension://" + chrome.runtime.id,
_id: Math.random().toString(16).substr(2, 16),
rand: Date.now().toString(),
apiv: "1",

// Don't return image
send_image: "0",

// Persistent user ID
uid: userId,
});

const url = `${MATOMO_URL}?${params.toString()}`;
console.log("Sending Matomo event:", {
category,
action,
name,
userId,
url,
});

// If we're in the background context, use fetch directly
if (isBackgroundContext()) {
await fetch(url, {
method: "GET",
mode: "no-cors",
});
console.log("Matomo event sent directly from background");
} else {
// Otherwise, try to send via message to background
try {
await chrome.runtime.sendMessage({
type: "matomoTrack",
url: url,
});
console.log("Matomo event sent via message");
} catch (error) {
// Fallback to image beacon if messaging fails
const img = new Image();
img.src = url;
console.log("Matomo event sent via image beacon");
}
}

console.log("Matomo event sent successfully");
} catch (error) {
console.error("Matomo tracking error:", error);
}
}
15 changes: 15 additions & 0 deletions src/recorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
} from "./consts";
import { getLocalOption } from "./localstorage";

import { onPageArchived } from "./events";

const encoder = new TextEncoder();

const MAX_CONCURRENT_FETCH = 6;
Expand Down Expand Up @@ -1136,6 +1138,19 @@ class Recorder {
// @ts-expect-error - TS2339 - Property '_cachePageInfo' does not exist on type 'Recorder'.
this._cachePageInfo = null;
}

if (finished && currPage.url) {
try {
// Fire and forget, but with immediate error handling
onPageArchived(currPage.url, currPage.size).catch((err) => {
console.error("onPageArchived failed:", err);
});
} catch (err) {
// Handle synchronous errors too
console.error("onPageArchived synchronous error:", err);
}
}

return res;
}

Expand Down
Loading
Loading