diff --git a/README.md b/README.md
index 654c2374..5710370f 100644
--- a/README.md
+++ b/README.md
@@ -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)
diff --git a/analytics.js b/analytics.js
new file mode 100644
index 00000000..80f8cc91
--- /dev/null
+++ b/analytics.js
@@ -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' });
+ }
+ });
+}());
diff --git a/app.js b/app.js
index 5ff57cb3..c8a7c4db 100644
--- a/app.js
+++ b/app.js
@@ -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) {
@@ -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),
@@ -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
+ });
});
}
@@ -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),
@@ -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'
@@ -1516,6 +1534,7 @@ function init() {
strictCheckbox.addEventListener('change', function () {
filterState.strict = strictCheckbox.checked;
render();
+ trackUsageEvent('filter_changed', { filter: 'strict', value: filterState.strict });
});
}
@@ -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,
@@ -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),
diff --git a/index.html b/index.html
index 49bc5bba..f19806fa 100644
--- a/index.html
+++ b/index.html
@@ -17,6 +17,7 @@
+