PageSourceSearch

https://kagn.app/analytics.js

js kagn.app collected 2026-09-25 13:38:27 UTC 13,763 bytes, 320 lines download raw bytes

1// KAGN Analytics - GA4 loader with EU/UK/EEA geo-gate.
2// Runs alongside telemetry.js. This file owns commerce/conversion events;
3// telemetry.js owns product behavior and errors.
4(function () {
5    'use strict';
6
7    var MEASUREMENT_ID = 'G-FP7G5794M0';
8    var GEO_KEY = 'kagn-geo';
9    var GEO_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
10    var GEO_ENDPOINT = '/api/geo';
11    var GTAG_SRC = 'https://www.googletagmanager.com/gtag/js?id=' + MEASUREMENT_ID;
12
13    var loaded = false;
14
15    // ── First-touch ad attribution ────────────────────────────────────
16    // Captured unconditionally, BEFORE the GA geo-gate: click IDs and UTMs
17    // are first-party data we need to join paid campaigns to signups/trials
18    // in our own telemetry, independent of whether gtag ever loads.
19    var ATTR_KEY = 'kagn-attribution';
20    var ATTR_PARAMS = [
21        'gclid', 'gbraid', 'wbraid',        // Google Ads
22        'fbclid',                           // Meta
23        'ttclid',                           // TikTok
24        'msclkid',                          // Microsoft
25        'utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term',
26        'ref', 'source', 'share', 'trip_share'
27    ];
28    // Keys stamped onto every GA4 event from the stored first touch: the URL
29    // params above plus the referrer fields captured below. Kept separate
30    // from ATTR_PARAMS so ?referrer_class=ai in a URL can never forge a
31    // channel — those two are only ever set from document.referrer.
32    var STAMP_KEYS = ATTR_PARAMS.concat(['referrer_host', 'referrer_class']);
33
34    function readAttribution() {
35        try {
36            var raw = localStorage.getItem(ATTR_KEY);
37            return raw ? JSON.parse(raw) : null;
38        } catch (e) { return null; }
39    }
40
41    // Where a first visit came from, when the URL carries no click ID or UTM.
42    // Answer engines (ChatGPT, Perplexity, Copilot, Gemini, Claude) send plain
43    // referrers, so without this the whole "AI search" channel reads as
44    // direct traffic. Only the hostname is kept — never the referrer path.
45    var REFERRER_CLASSES = [
46        ['ai', /(^|\.)(chatgpt\.com|openai\.com|perplexity\.ai|claude\.ai|anthropic\.com|gemini\.google\.com|copilot\.microsoft\.com|you\.com|phind\.com|poe\.com|meta\.ai|mistral\.ai|deepseek\.com|kagi\.com)$/],
47        ['search', /(^|\.)(google\.[a-z.]+|bing\.com|duckduckgo\.com|yahoo\.com|yandex\.[a-z]+|baidu\.com|ecosia\.org|brave\.com|startpage\.com|qwant\.com|search\.[a-z.]+)$/],
48        ['social', /(^|\.)(facebook\.com|fb\.com|instagram\.com|t\.co|twitter\.com|x\.com|reddit\.com|tiktok\.com|youtube\.com|youtu\.be|linkedin\.com|pinterest\.[a-z.]+|whatsapp\.com|threads\.net|mastodon\.[a-z]+|bsky\.app)$/],
49    ];
50    function classifyReferrer(host) {
51        if (!host) return '';
52        host = String(host).toLowerCase();
53        for (var i = 0; i < REFERRER_CLASSES.length; i++) {
54            if (REFERRER_CLASSES[i][1].test(host)) return REFERRER_CLASSES[i][0];
55        }
56        return 'referral';
57    }
58    function externalReferrerHost() {
59        try {
60            var ref = (typeof document !== 'undefined' && document.referrer) || '';
61            if (!ref) return '';
62            var host = new URL(ref).hostname.toLowerCase();
63            if (!host || host === 'kagn.app' || /(^|\.)kagn\.app$/.test(host) || host === 'localhost') return '';
64            return host.slice(0, 120);
65        } catch (e) { return ''; }
66    }
67
68    function captureAttribution() {
69        var search = (window.location && window.location.search) || '';
70        var refHost = externalReferrerHost();
71        if (!search && !refHost) return;
72        var query = {};
73        var pairs = search.replace(/^\?/, '').split('&');
74        for (var i = 0; i < pairs.length; i++) {
75            var eq = pairs[i].indexOf('=');
76            if (eq <= 0) continue;
77            try {
78                query[decodeURIComponent(pairs[i].slice(0, eq))] =
79                    decodeURIComponent(pairs[i].slice(eq + 1).replace(/\+/g, ' '));
80            } catch (e) { /* malformed escape — skip pair */ }
81        }
82        var attr = {};
83        var found = false;
84        for (var j = 0; j < ATTR_PARAMS.length; j++) {
85            var v = query[ATTR_PARAMS[j]];
86            if (v) { attr[ATTR_PARAMS[j]] = String(v).slice(0, 200); found = true; }
87        }
88        if (refHost) {
89            attr.referrer_host = refHost;
90            attr.referrer_class = classifyReferrer(refHost);
91            found = true;
92        }
93        if (!found) return;
94        if (readAttribution()) return; // first touch wins
95        attr.landing_page = (window.location && window.location.pathname) || '';
96        attr.ts = Date.now();
97        try {
98            localStorage.setItem(ATTR_KEY, JSON.stringify(attr));
99        } catch (e) { return; /* quota — don't emit an event we can't dedup */ }
100        // One-time telemetry event: lets D1 join acquisition source to the
101        // funnel (telemetry.js loads before analytics.js on every page).
102        if (window.KagnTelemetry && typeof window.KagnTelemetry.track === 'function') {
103            window.KagnTelemetry.track('ad_attribution', attr);
104        }
105    }
106
107    // Calls often happen before /api/geo resolves. Queue them instead of losing
108    // first_open/onboarding events during that async gap; discard the queue if
109    // the geo privacy gate blocks analytics.
110    var pendingCalls = [];
111    function queued(method) {
112        return function (props) { pendingCalls.push([method, props]); };
113    }
114    var stubApi = {
115        appOpen: queued('appOpen'),
116        onboardingCompleted: queued('onboardingCompleted'),
117        firstIdCompleted: queued('firstIdCompleted'),
118        signInCompleted: queued('signInCompleted'),
119        paywallViewed: queued('paywallViewed'),
120        trialStarted: queued('trialStarted'),
121        purchase: queued('purchase'),
122        upgradeComplete: queued('upgradeComplete')
123    };
124    // Expose the stub immediately so call sites never crash if gtag never loads.
125    window.kagnAnalytics = stubApi;
126
127    function loadGtag() {
128        if (loaded) return;
129        loaded = true;
130
131        // In the Capacitor iOS build, use the native Firebase SDK. That gives
132        // Google Ads an app data stream, first_open attribution and SKAN-aware
133        // post-install events. The remote PWA continues to use the web tag.
134        var nativeAnalytics = getNativeAnalytics();
135        if (nativeAnalytics) {
136            installEventApi();
137            return;
138        }
139        window.dataLayer = window.dataLayer || [];
140        if (typeof window.gtag !== 'function') {
141            window.gtag = function () { window.dataLayer.push(arguments); };
142        }
143        window.gtag('js', new Date());
144        window.gtag('config', MEASUREMENT_ID, { send_page_view: true });
145
146        var s = document.createElement('script');
147        s.async = true;
148        s.src = GTAG_SRC;
149        // Inject only after the window load event. A script inserted before
150        // `load` joins the load chain (gtag.js, then everything it pulls
151        // in), and inside the Android TWA Chrome keeps its progress bar up
152        // until `load` fires (2026-09-04). Nothing is lost: gtag() calls
153        // queue in dataLayer and replay when the script executes.
154        if (document.readyState === 'complete') {
155            document.head.appendChild(s);
156        } else {
157            window.addEventListener('load', function () { document.head.appendChild(s); }, { once: true });
158        }
159
160        installEventApi();
161    }
162
163    function getNativeAnalytics() {
164        try {
165            var cap = window.Capacitor;
166            if (!cap || typeof cap.isNativePlatform !== 'function' || !cap.isNativePlatform()) return null;
167            return cap.Plugins && cap.Plugins.FirebaseAnalytics || null;
168        } catch (e) { return null; }
169    }
170
171    function getContext() {
172        var ua = navigator.userAgent || '';
173        var platform = /android/i.test(ua) ? 'android'
174            : /iphone|ipad|ipod/i.test(ua) ? 'ios'
175            : 'web';
176        var pack = (window.PACK && window.PACK.id) || '';
177        var tier = (window.KagnFreemium && typeof window.KagnFreemium.getTier === 'function')
178            ? window.KagnFreemium.getTier() : 'free';
179        var isPwa = (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) ? 1 : 0;
180        return { pack_id: pack, tier: tier, platform: platform, is_pwa: isPwa };
181    }
182
183    function fire(name, props) {
184        var nativeAnalytics = getNativeAnalytics();
185        if (nativeAnalytics && typeof nativeAnalytics.logEvent === 'function') {
186            nativeAnalytics.logEvent({ name: name, params: props || {} }).catch(function () {});
187            return;
188        }
189        if (typeof window.gtag !== 'function') return;
190        var ctx = getContext();
191        var payload = Object.assign({}, ctx, props || {});
192        // Stamp stored first-touch attribution onto every event so
193        // conversions fired sessions later still carry their source.
194        var attr = readAttribution();
195        if (attr) {
196            for (var i = 0; i < STAMP_KEYS.length; i++) {
197                var k = STAMP_KEYS[i];
198                if (attr[k] && payload[k] === undefined) payload[k] = attr[k];
199            }
200        }
201        window.gtag('event', name, payload);
202    }
203
204    function onceLocal(key, fn) {
205        try {
206            if (localStorage.getItem(key) === '1') return;
207            localStorage.setItem(key, '1');
208        } catch (e) { /* quota — just fire */ }
209        fn();
210    }
211
212    function onceSession(key, fn) {
213        try {
214            if (sessionStorage.getItem(key) === '1') return;
215            sessionStorage.setItem(key, '1');
216        } catch (e) { /* quota — just fire */ }
217        fn();
218    }
219
220    function installEventApi() {
221        // Mutate the existing stub in-place so callers that captured
222        // window.kagnAnalytics before geo resolution automatically see
223        // the real methods once gtag loads.
224        stubApi.appOpen = function () {
225            onceSession('kagn-ga-app-open', function () { fire('app_open'); });
226        };
227        stubApi.onboardingCompleted = function (props) {
228            onceLocal('kagn-ga-onboarding', function () {
229                fire('onboarding_completed', {
230                    selected_packs: (props && props.selectedPacks || []).join(',')
231                });
232            });
233        };
234        stubApi.firstIdCompleted = function (props) {
235            onceLocal('kagn-ga-first-id', function () {
236                fire('first_id_completed', {
237                    mode: props && props.mode || 'unknown',
238                    result: props && props.result || 'unknown'
239                });
240            });
241        };
242        stubApi.signInCompleted = function (props) {
243            fire('sign_in_completed', { is_new_user: !!(props && props.isNewUser) });
244        };
245        stubApi.paywallViewed = function (props) {
246            fire('paywall_viewed', { trigger: (props && props.trigger) || 'manual' });
247        };
248        stubApi.trialStarted = function (props) {
249            fire('trial_started', {
250                sku: props && props.sku || '',
251                tier: props && props.tier || ''
252            });
253        };
254        stubApi.upgradeComplete = function (props) {
255            // GA4 key event + Google Ads Primary conversion. Callers dedupe
256            // (fires once per new store token) — never call on every boot.
257            fire('upgrade_complete', {
258                source: props && props.source || '',
259                tier: props && props.tier || ''
260            });
261        };
262        stubApi.purchase = function (props) {
263            var sku = props && props.sku || '';
264            var tier = props && props.tier || '';
265            var value = (props && typeof props.value === 'number') ? props.value : 0;
266            var currency = props && props.currency || 'USD';
267            var tid = props && props.transactionId || '';
268            fire('purchase', {
269                transaction_id: tid,
270                value: value,
271                currency: currency,
272                items: [{
273                    item_id: sku,
274                    item_name: tier === 'pro' ? 'Kagn Pro' : 'Kagn Plus',
275                    price: value,
276                    quantity: 1
277                }]
278            });
279        };
280
281        var replay = pendingCalls.splice(0);
282        for (var i = 0; i < replay.length; i++) {
283            var method = replay[i][0];
284            if (typeof stubApi[method] === 'function') stubApi[method](replay[i][1]);
285        }
286    }
287
288    function tryLoad() {
289        var cached = null;
290        try {
291            var raw = localStorage.getItem(GEO_KEY);
292            if (raw) cached = JSON.parse(raw);
293        } catch (e) { /* corrupt cache → refetch */ }
294
295        var now = Date.now();
296        if (cached && typeof cached.ts === 'number' && (now - cached.ts) < GEO_TTL_MS) {
297            if (!cached.blocked) loadGtag();
298            else pendingCalls.length = 0;
299            return;
300        }
301
302        fetch(GEO_ENDPOINT).then(function (r) {
303            return r.json();
304        }).then(function (g) {
305            if (typeof g.country !== 'string') return;
306            try {
307                localStorage.setItem(GEO_KEY, JSON.stringify({
308                    country: g.country, blocked: !!g.blocked, ts: now
309                }));
310            } catch (e) { /* quota */ }
311            if (!g.blocked) loadGtag();
312            else pendingCalls.length = 0;
313        }).catch(function () {
314            // Fail closed: no gtag on fetch failure.
315        });
316    }
317
318    captureAttribution();
319    tryLoad();
320})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.