Skip to content
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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,24 @@ and `seo_injected` events under `github-actions`. Generated files stay in the
temporary runner; this workflow does not commit changes or publish the site.
Pull request tests and preset-update jobs continue to run without analytics.

### Website usage analytics

`analytics.js` loads the PostHog browser SDK on `presets.polymaker.com` using the
public write-only project token. Local and preview hosts do not send PostHog
events. This is separate from the Node build SDK and GitHub Actions secrets.

PostHog records pageviews (`$pageview`), page leaves, automatic interactions
(`$autocapture`), and `filter_changed` with the selected filter and value.
The existing successful-download handlers send `download_single`,
`download_selected`, `download_bundle`, and `download_bundle_batch` to both
Google Analytics and PostHog, including material/printer/slicer metadata where
available. A download event means the browser was handed the generated file;
it cannot confirm the user saved or imported it. Session recording is disabled.

View these events in PostHog **Activity**, or use **Web analytics** for visits
and **Product analytics** for filter and download usage. Ad blockers or a
visitor's analytics opt-out can prevent events from arriving.

## 🔗 Links

- **Download Page**: [https://presets.polymaker.com](https://presets.polymaker.com)
Expand Down
47 changes: 47 additions & 0 deletions analytics.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Browser analytics. This is the public, write-only PostHog project token.
// The Node build client is configured separately through GitHub Actions secrets.
(function () {
if (window.location.hostname !== 'presets.polymaker.com') return;
if (window.posthog && (window.posthog.__loaded || window.posthog.__SV)) return;

// PostHog's asynchronous snippet queues early interactions while the SDK loads.
// https://posthog.com/docs/libraries/js
var posthog = window.posthog = [];
posthog._i = [];
posthog.__SV = 1;
posthog.people = [];
posthog.init = function (token, config) {
var script = document.createElement('script');
script.type = 'text/javascript';
script.crossOrigin = 'anonymous';
script.async = true;
script.src = config.api_host.replace('.i.posthog.com', '-assets.i.posthog.com') + '/static/array.js';
var firstScript = document.getElementsByTagName('script')[0];
firstScript.parentNode.insertBefore(script, firstScript);

function queueMethod(name) {
posthog[name] = function () {
posthog.push([name].concat(Array.prototype.slice.call(arguments)));
};
}
var methods = ['capture', 'register', 'identify', 'reset', 'set_config', 'opt_in_capturing', 'opt_out_capturing'];
for (var i = 0; i < methods.length; i++) queueMethod(methods[i]);
posthog._i.push([token, config, 'posthog']);
};

window.posthog.init('phc_CdvtTDKLvkXysqQN43CbH674J2p9r9LoEZBGEHbrfsYi', {
api_host: 'https://us.i.posthog.com',
ui_host: 'https://us.posthog.com',
defaults: '2026-05-30',
capture_pageview: true,
capture_pageleave: true,
autocapture: true,
person_profiles: 'identified_only',
cross_subdomain_cookie: false,
disable_session_recording: true,
disable_surveys: true,
loaded: function (posthog) {
posthog.register({ site: 'polymaker-presets' });
}
});
}());
37 changes: 28 additions & 9 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,23 @@ function getGaValue(value) {
return value;
}

function trackGaEvent(eventName, params) {
function trackUsageEvent(eventName, params) {
if (typeof window === 'undefined') return;
if (typeof window.gtag !== 'function') return;
// Each provider is independent: blocked analytics must never break downloads.
try {
window.gtag('event', eventName, params || {});
if (typeof window.gtag === 'function') {
window.gtag('event', eventName, params || {});
}
} catch (e) {
console.warn('GA event failed:', eventName, e);
}
try {
if (window.posthog && typeof window.posthog.capture === 'function') {
window.posthog.capture(eventName, params || {});
}
} catch (e) {
console.warn('PostHog event failed:', eventName, e);
}
}

function applyTheme(theme) {
Expand Down Expand Up @@ -537,7 +546,7 @@ function init() {
document.body.removeChild(a);
if (originalPresets && originalPresets.length) {
var firstPreset = originalPresets[0];
trackGaEvent('download_bundle', {
trackUsageEvent('download_bundle', {
file_type: 'bbsflmt',
slicer: getGaValue(firstPreset.slicer || 'BambuStudio'),
material: getGaValue(firstPreset.material),
Expand Down Expand Up @@ -776,6 +785,15 @@ function init() {
}

render();
trackUsageEvent('filter_changed', {
filter: name,
value: value || 'all',
series: filterState.series || 'all',
brand: filterState.brand || 'all',
model: filterState.model || 'all',
slicer: filterState.slicer || 'all',
strict: filterState.strict
});
});
}

Expand Down Expand Up @@ -1046,7 +1064,7 @@ function init() {
a.click();
document.body.removeChild(a);
var filters = getEffectiveFilters();
trackGaEvent('download_selected', {
trackUsageEvent('download_selected', {
file_type: 'zip',
selected_count: presetIds.length,
slicer: getGaValue(filters.effectiveSlicer || filterState.slicer),
Expand Down Expand Up @@ -1269,7 +1287,7 @@ function init() {
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
trackGaEvent('download_bundle_batch', {
trackUsageEvent('download_bundle_batch', {
file_type: 'zip',
material_count: materials.length,
slicer: 'BambuStudio'
Expand Down Expand Up @@ -1516,6 +1534,7 @@ function init() {
strictCheckbox.addEventListener('change', function () {
filterState.strict = strictCheckbox.checked;
render();
trackUsageEvent('filter_changed', { filter: 'strict', value: filterState.strict });
});
}

Expand Down Expand Up @@ -1823,8 +1842,8 @@ function init() {
a.click();
document.body.removeChild(a);
var rowContext = getRowGaContext(directJsonLink.closest('tr'));
trackGaEvent('download_single', {
file_type: 'json',
trackUsageEvent('download_single', {
file_type: /\.ini$/i.test(djFilename) ? 'ini' : 'json',
slicer: rowContext.slicer,
material: rowContext.material,
brand: rowContext.brand,
Expand Down Expand Up @@ -1879,7 +1898,7 @@ function init() {
a.click();
document.body.removeChild(a);
var rowContext = getRowGaContext(bambuJsonLink.closest('tr'), 'BambuStudio');
trackGaEvent('download_single', {
trackUsageEvent('download_single', {
file_type: 'json',
slicer: rowContext.slicer,
material: getGaValue(bjMaterial || rowContext.material),
Expand Down
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<link rel="stylesheet" href="style.css">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" crossorigin="anonymous">
<script src="analytics.js"></script>
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-KXL1MCXGJZ"></script>
<script>
Expand Down
94 changes: 94 additions & 0 deletions scripts/website-analytics.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';

const analytics = readFileSync(new URL('../analytics.js', import.meta.url), 'utf8');
const app = readFileSync(new URL('../app.js', import.meta.url), 'utf8');
const helper = app.slice(app.indexOf('function trackUsageEvent('), app.indexOf('function applyTheme('));

function browser(hostname = 'presets.polymaker.com') {
const scripts = [];
const context = vm.createContext({
window: { location: { hostname } },
document: {
createElement: () => ({}),
getElementsByTagName: () => [{ parentNode: { insertBefore: (script) => scripts.push(script) } }],
},
console: { warn() {} },
});
vm.runInContext(analytics, context);
vm.runInContext(helper, context);
return { context, scripts };
}

it('loads the browser SDK asynchronously and queues usage before it arrives', () => {
const { context, scripts } = browser();
assert.equal(scripts.length, 1);
assert.equal(scripts[0].src, 'https://us-assets.i.posthog.com/static/array.js');
assert.equal(scripts[0].async, true);
const [, config] = context.window.posthog._i[0];
assert.equal(config.capture_pageview, true);
assert.equal(config.autocapture, true);
assert.equal(config.disable_session_recording, true);
vm.runInContext("trackUsageEvent('download_single', {material: 'Panchroma PLA'});", context);
assert.equal(context.window.posthog[0][0], 'capture');
assert.equal(context.window.posthog[0][1], 'download_single');
assert.equal(context.window.posthog[0][2].material, 'Panchroma PLA');
vm.runInContext(analytics, context);
assert.equal(scripts.length, 1, 'reloading bootstrap must not initialize twice');
});

it('does not load production analytics on local or preview hosts', () => {
for (const host of ['localhost', '127.0.0.1', 'preview.example.com']) {
const { context, scripts } = browser(host);
assert.equal(scripts.length, 0);
assert.doesNotThrow(() => vm.runInContext("trackUsageEvent('download_single');", context));
}
});

it('sends to both providers independently, even when one is unavailable or throws', () => {
const { context } = browser();
const ga = [];
const ph = [];
context.window.gtag = (...args) => ga.push(args);
context.window.posthog = { capture: (...args) => ph.push(args) };
vm.runInContext("trackUsageEvent('download_selected', {selected_count: 2});", context);
assert.equal(ga.length, 1);
assert.equal(ph.length, 1);
assert.equal(ph[0][1].selected_count, 2);
context.window.gtag = () => { throw new Error('blocked GA'); };
vm.runInContext("trackUsageEvent('download_bundle');", context);
assert.equal(ph.length, 2);
context.window.gtag = (...args) => ga.push(args);
context.window.posthog.capture = () => { throw new Error('blocked PostHog'); };
assert.doesNotThrow(() => vm.runInContext("trackUsageEvent('download_bundle_batch');", context));
assert.equal(ga.length, 2);
});

it('captures a real dropdown selection after rendering its new filter state', () => {
const { context } = browser();
let select;
let rendered = false;
const option = { getAttribute: () => 'OrcaSlicer', textContent: 'OrcaSlicer', classList: { add() {} } };
const menu = { addEventListener: (_, fn) => { select = fn; }, querySelector: () => null };
const dropdown = {
querySelector: (selector) => selector === '.dropdown-menu' ? menu : { addEventListener() {} },
classList: { remove() {} },
};
Object.assign(context, {
filterState: { slicer: '', series: '', brand: '', model: '', strict: true },
selectedPresets: {}, escapeHtml: (s) => s, t: (s) => s,
updateVisibility() {}, updateBrandDropdownState() {}, updateSelectedCount() {},
render() { rendered = true; },
});
context.document.querySelector = () => dropdown;
vm.runInContext(app.slice(app.indexOf('function setupDropdown('), app.indexOf('function closeAllDropdowns(')), context);
vm.runInContext("setupDropdown('slicer', ['OrcaSlicer'], true);", context);
select({ target: { closest: () => option } });
assert.equal(rendered, true);
const event = context.window.posthog[0];
assert.equal(event[1], 'filter_changed');
assert.equal(event[2].filter, 'slicer');
assert.equal(event[2].slicer, 'OrcaSlicer');
});
Loading