From 6ddea4a7bc654c109969faa0480417515b0c14ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Thu, 26 Feb 2026 12:35:13 +0100 Subject: [PATCH] comments --- .claude/notnow-settings.local.json | 28 -- apps/api/src/controllers/track.controller.ts | 6 +- apps/api/src/utils/ids.ts | 47 ++- .../docs/(tracking)/revenue-tracking.mdx | 6 +- .../content/guides/ecommerce-tracking.mdx | 2 +- apps/public/public/op1.bak.js | 337 ------------------ .../sessions/replay/replay-player.tsx | 14 +- .../sessions/replay/replay-timeline.tsx | 3 + packages/sdks/nextjs/index.tsx | 4 + packages/sdks/web/src/replay/recorder.ts | 34 +- packages/sdks/web/src/types.d.ts | 6 +- packages/sdks/web/src/types.debug.ts | 2 + 12 files changed, 84 insertions(+), 405 deletions(-) delete mode 100644 .claude/notnow-settings.local.json delete mode 100644 apps/public/public/op1.bak.js diff --git a/.claude/notnow-settings.local.json b/.claude/notnow-settings.local.json deleted file mode 100644 index 6b12b49d..00000000 --- a/.claude/notnow-settings.local.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "permissions": { - "allow": [ - "mcp__acp__Bash", - "mcp__acp__Edit", - "mcp__gsc__gsc_pages_stats", - "mcp__gsc__gsc_queries_list", - "mcp__gsc__gsc_pages_list", - "mcp__gsc__gsc_queries_stats", - "mcp__gsc__gsc_queries_opportunities", - "mcp__gsc__spyfu_domain_compare", - "mcp__gsc__spyfu_seo_keywords", - "mcp__gsc__gsc_devices_list", - "mcp__acp__Write", - "mcp__gsc__spyfu_serp_analysis", - "mcp__gsc__spyfu_keyword_info", - "mcp__gsc__serpapi_search", - "mcp__searchapi__google_rank_tracking", - "WebFetch(domain:www.mparticle.com)", - "WebFetch(domain:posthog.com)", - "WebFetch(domain:openpanel.dev)", - "mcp__gsc__gsc_sites_list", - "WebFetch(domain:plausible.io)", - "WebFetch(domain:opennext.js.org)", - "WebFetch(domain:clickhouse.com)" - ] - } -} diff --git a/apps/api/src/controllers/track.controller.ts b/apps/api/src/controllers/track.controller.ts index d94bec58..4b0de8b8 100644 --- a/apps/api/src/controllers/track.controller.ts +++ b/apps/api/src/controllers/track.controller.ts @@ -194,7 +194,7 @@ async function handleTrack( .filter(Boolean) .join('-'); - const promises = []; + const promises: Promise[] = []; // If we have more than one property in the identity object, we should identify the user // Otherwise its only a profileId and we should not identify the user @@ -436,7 +436,7 @@ export async function fetchDeviceId( const data = JSON.parse(res?.[0]?.[1] as string); const sessionId = data.payload.sessionId; return reply.status(200).send({ - deviceId: sessionId, + deviceId: currentDeviceId, sessionId, message: 'current session exists for this device id', }); @@ -446,7 +446,7 @@ export async function fetchDeviceId( const data = JSON.parse(res?.[1]?.[1] as string); const sessionId = data.payload.sessionId; return reply.status(200).send({ - deviceId: sessionId, + deviceId: previousDeviceId, sessionId, message: 'previous session exists for this device id', }); diff --git a/apps/api/src/utils/ids.ts b/apps/api/src/utils/ids.ts index 2c5c74dd..db6b9aa5 100644 --- a/apps/api/src/utils/ids.ts +++ b/apps/api/src/utils/ids.ts @@ -1,5 +1,6 @@ import crypto from 'node:crypto'; import { generateDeviceId } from '@openpanel/common/server'; +import { getSafeJson } from '@openpanel/json'; import { getRedisCache } from '@openpanel/redis'; export async function getDeviceId({ @@ -16,11 +17,11 @@ export async function getDeviceId({ overrideDeviceId?: string; }) { if (overrideDeviceId) { - return { deviceId: overrideDeviceId, sessionId: undefined }; + return { deviceId: overrideDeviceId, sessionId: '' }; } if (!ua) { - return { deviceId: '', sessionId: undefined }; + return { deviceId: '', sessionId: '' }; } const currentDeviceId = generateDeviceId({ @@ -56,22 +57,30 @@ async function getDeviceIdFromSession({ const multi = getRedisCache().multi(); multi.hget( `bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`, - 'data', + 'data' ); multi.hget( `bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`, - 'data', + 'data' ); const res = await multi.exec(); if (res?.[0]?.[1]) { - const data = JSON.parse(res?.[0]?.[1] as string); - const sessionId = data.payload.sessionId; - return { deviceId: currentDeviceId, sessionId }; + const data = getSafeJson<{ payload: { sessionId: string } }>( + (res?.[0]?.[1] as string) ?? '' + ); + if (data) { + const sessionId = data.payload.sessionId; + return { deviceId: currentDeviceId, sessionId }; + } } if (res?.[1]?.[1]) { - const data = JSON.parse(res?.[1]?.[1] as string); - const sessionId = data.payload.sessionId; - return { deviceId: previousDeviceId, sessionId }; + const data = getSafeJson<{ payload: { sessionId: string } }>( + (res?.[1]?.[1] as string) ?? '' + ); + if (data) { + const sessionId = data.payload.sessionId; + return { deviceId: previousDeviceId, sessionId }; + } } } catch (error) { console.error('Error getting session end GET /track/device-id', error); @@ -113,13 +122,21 @@ function getSessionId(params: { bytes = 16, } = params; - if (!projectId) throw new Error('projectId is required'); - if (!deviceId) throw new Error('deviceId is required'); - if (windowMs <= 0) throw new Error('windowMs must be > 0'); - if (graceMs < 0 || graceMs >= windowMs) + if (!projectId) { + throw new Error('projectId is required'); + } + if (!deviceId) { + throw new Error('deviceId is required'); + } + if (windowMs <= 0) { + throw new Error('windowMs must be > 0'); + } + if (graceMs < 0 || graceMs >= windowMs) { throw new Error('graceMs must be >= 0 and < windowMs'); - if (bytes < 8 || bytes > 32) + } + if (bytes < 8 || bytes > 32) { throw new Error('bytes must be between 8 and 32'); + } const bucket = Math.floor(eventMs / windowMs); const offset = eventMs - bucket * windowMs; diff --git a/apps/public/content/docs/(tracking)/revenue-tracking.mdx b/apps/public/content/docs/(tracking)/revenue-tracking.mdx index 67641843..00bf4349 100644 --- a/apps/public/content/docs/(tracking)/revenue-tracking.mdx +++ b/apps/public/content/docs/(tracking)/revenue-tracking.mdx @@ -33,7 +33,7 @@ This is the most common flow and most secure one. Your backend receives webhooks -When you create the checkout, you should first call `op.fetchDeviceId()`, which will return your visitor's current `deviceId`. Pass this to your checkout endpoint. +When you create the checkout, you should first call `op.getDeviceId()`, which will return your visitor's current `deviceId`. Pass this to your checkout endpoint. ```javascript fetch('https://domain.com/api/checkout', { @@ -42,7 +42,7 @@ fetch('https://domain.com/api/checkout', { 'Content-Type': 'application/json', }, body: JSON.stringify({ - deviceId: await op.fetchDeviceId(), // ✅ since deviceId is here we can link the payment now + deviceId: op.getDeviceId(), // ✅ since deviceId is here we can link the payment now // ... other checkout data }), }) @@ -360,5 +360,5 @@ op.clearRevenue(): void ### Fetch your current users device id ```javascript -op.fetchDeviceId(): Promise +op.getDeviceId(): string ``` diff --git a/apps/public/content/guides/ecommerce-tracking.mdx b/apps/public/content/guides/ecommerce-tracking.mdx index e554ba2d..cfa03304 100644 --- a/apps/public/content/guides/ecommerce-tracking.mdx +++ b/apps/public/content/guides/ecommerce-tracking.mdx @@ -136,7 +136,7 @@ For more accurate tracking, handle revenue in your backend webhook. This ensures ```tsx // Frontend: include deviceId when starting checkout -const deviceId = await op.fetchDeviceId(); +const deviceId = op.getDeviceId(); const response = await fetch('/api/checkout', { method: 'POST', diff --git a/apps/public/public/op1.bak.js b/apps/public/public/op1.bak.js deleted file mode 100644 index 37c957ed..00000000 --- a/apps/public/public/op1.bak.js +++ /dev/null @@ -1,337 +0,0 @@ -(() => { - var u = class { - constructor(e) { - (this.baseUrl = e.baseUrl), - (this.headers = { - 'Content-Type': 'application/json', - ...e.defaultHeaders, - }), - (this.maxRetries = e.maxRetries ?? 3), - (this.initialRetryDelay = e.initialRetryDelay ?? 500); - } - async resolveHeaders() { - const e = {}; - for (const [i, t] of Object.entries(this.headers)) { - const s = await t; - s !== null && (e[i] = s); - } - return e; - } - addHeader(e, i) { - this.headers[e] = i; - } - async post(e, i, t, s) { - try { - const n = await fetch(e, { - method: 'POST', - headers: await this.resolveHeaders(), - body: i ? JSON.stringify(i ?? {}) : void 0, - keepalive: !0, - ...t, - }); - if (n.status === 401) { - return null; - } - if (n.status !== 200 && n.status !== 202) { - throw new Error(`HTTP error! status: ${n.status}`); - } - const r = await n.text(); - return r ? JSON.parse(r) : null; - } catch (n) { - if (s < this.maxRetries) { - const r = this.initialRetryDelay * 2 ** s; - return ( - await new Promise((a) => setTimeout(a, r)), - this.post(e, i, t, s + 1) - ); - } - return console.error('Max retries reached:', n), null; - } - } - async fetch(e, i, t = {}) { - const s = `${this.baseUrl}${e}`; - return this.post(s, i, t, 0); - } - }, - l = class { - constructor(e) { - (this.options = e), (this.queue = []); - const i = { 'openpanel-client-id': e.clientId }; - e.clientSecret && (i['openpanel-client-secret'] = e.clientSecret), - (i['openpanel-sdk-name'] = e.sdk || 'node'), - (i['openpanel-sdk-version'] = e.sdkVersion || '1.0.3'), - (this.api = new u({ - baseUrl: e.apiUrl || 'https://api.openpanel.dev', - defaultHeaders: i, - })); - } - init() {} - ready() { - (this.options.waitForProfile = !1), this.flush(); - } - async send(e) { - return this.options.disabled || - (this.options.filter && !this.options.filter(e)) - ? Promise.resolve() - : this.options.waitForProfile && !this.profileId - ? (this.queue.push(e), Promise.resolve()) - : this.api.fetch('/track', e); - } - setGlobalProperties(e) { - this.global = { ...this.global, ...e }; - } - async track(e, i) { - return ( - this.log('track event', e, i), - this.send({ - type: 'track', - payload: { - name: e, - profileId: i?.profileId ?? this.profileId, - properties: { ...(this.global ?? {}), ...(i ?? {}) }, - }, - }) - ); - } - async identify(e) { - if ( - (this.log('identify user', e), - e.profileId && ((this.profileId = e.profileId), this.flush()), - Object.keys(e).length > 1) - ) { - return this.send({ - type: 'identify', - payload: { ...e, properties: { ...this.global, ...e.properties } }, - }); - } - } - async alias(e) {} - async increment(e) { - return this.send({ type: 'increment', payload: e }); - } - async decrement(e) { - return this.send({ type: 'decrement', payload: e }); - } - async revenue(e, i) { - const t = i?.deviceId; - return ( - delete i?.deviceId, - this.track('revenue', { - ...(i ?? {}), - ...(t ? { __deviceId: t } : {}), - __revenue: e, - }) - ); - } - async fetchDeviceId() { - return ( - ( - await this.api.fetch('/track/device-id', void 0, { - method: 'GET', - keepalive: !1, - }) - )?.deviceId ?? '' - ); - } - clear() { - this.profileId = void 0; - } - flush() { - this.queue.forEach((e) => { - this.send({ - ...e, - payload: { - ...e.payload, - profileId: e.payload.profileId ?? this.profileId, - }, - }); - }), - (this.queue = []); - } - log(...e) { - this.options.debug && console.log('[OpenPanel.dev]', ...e); - } - }; - function h(e) { - return e.replace(/([-_][a-z])/gi, (i) => - i.toUpperCase().replace('-', '').replace('_', '') - ); - } - var p = class extends l { - constructor(t) { - super({ sdk: 'web', sdkVersion: '1.0.6', ...t }); - this.options = t; - this.lastPath = ''; - this.pendingRevenues = []; - if (!this.isServer()) { - try { - const s = sessionStorage.getItem('openpanel-pending-revenues'); - if (s) { - const n = JSON.parse(s); - Array.isArray(n) && (this.pendingRevenues = n); - } - } catch { - this.pendingRevenues = []; - } - this.setGlobalProperties({ __referrer: document.referrer }), - this.options.trackScreenViews && - (this.trackScreenViews(), setTimeout(() => this.screenView(), 0)), - this.options.trackOutgoingLinks && this.trackOutgoingLinks(), - this.options.trackAttributes && this.trackAttributes(); - } - } - debounce(t, s) { - clearTimeout(this.debounceTimer), (this.debounceTimer = setTimeout(t, s)); - } - isServer() { - return typeof document > 'u'; - } - trackOutgoingLinks() { - this.isServer() || - document.addEventListener('click', (t) => { - const s = t.target, - n = s.closest('a'); - if (n && s) { - const r = n.getAttribute('href'); - if (r?.startsWith('http')) { - try { - const a = new URL(r), - o = window.location.hostname; - a.hostname !== o && - super.track('link_out', { - href: r, - text: - n.innerText || - n.getAttribute('title') || - s.getAttribute('alt') || - s.getAttribute('title'), - }); - } catch {} - } - } - }); - } - trackScreenViews() { - if (this.isServer()) { - return; - } - const t = history.pushState; - history.pushState = function (...a) { - const o = t.apply(this, a); - return ( - window.dispatchEvent(new Event('pushstate')), - window.dispatchEvent(new Event('locationchange')), - o - ); - }; - const s = history.replaceState; - (history.replaceState = function (...a) { - const o = s.apply(this, a); - return ( - window.dispatchEvent(new Event('replacestate')), - window.dispatchEvent(new Event('locationchange')), - o - ); - }), - window.addEventListener('popstate', () => { - window.dispatchEvent(new Event('locationchange')); - }); - const n = () => this.debounce(() => this.screenView(), 50); - this.options.trackHashChanges - ? window.addEventListener('hashchange', n) - : window.addEventListener('locationchange', n); - } - trackAttributes() { - this.isServer() || - document.addEventListener('click', (t) => { - const s = t.target, - n = s.closest('button'), - r = s.closest('a'), - a = n?.getAttribute('data-track') - ? n - : r?.getAttribute('data-track') - ? r - : null; - if (a) { - const o = {}; - for (const c of a.attributes) { - c.name.startsWith('data-') && - c.name !== 'data-track' && - (o[h(c.name.replace(/^data-/, ''))] = c.value); - } - const d = a.getAttribute('data-track'); - d && super.track(d, o); - } - }); - } - screenView(t, s) { - if (this.isServer()) { - return; - } - let n, r; - typeof t == 'string' - ? ((n = t), (r = s)) - : ((n = window.location.href), (r = t)), - this.lastPath !== n && - ((this.lastPath = n), - super.track('screen_view', { - ...(r ?? {}), - __path: n, - __title: document.title, - })); - } - async flushRevenue() { - const t = this.pendingRevenues.map((s) => - super.revenue(s.amount, s.properties) - ); - await Promise.all(t), this.clearRevenue(); - } - clearRevenue() { - if (((this.pendingRevenues = []), !this.isServer())) { - try { - sessionStorage.removeItem('openpanel-pending-revenues'); - } catch {} - } - } - pendingRevenue(t, s) { - if ( - (this.pendingRevenues.push({ amount: t, properties: s }), - !this.isServer()) - ) { - try { - sessionStorage.setItem( - 'openpanel-pending-revenues', - JSON.stringify(this.pendingRevenues) - ); - } catch {} - } - } - }; - ((e) => { - if (e.op) { - const i = e.op.q || [], - t = new p(i.shift()[1]); - i.forEach((n) => { - n[0] in t && t[n[0]](...n.slice(1)); - }); - const s = new Proxy( - (n, ...r) => { - const a = t[n] ? t[n].bind(t) : void 0; - typeof a == 'function' - ? a(...r) - : console.warn(`OpenPanel: ${n} is not a function`); - }, - { - get(n, r) { - if (r === 'q') { - return; - } - const a = t[r]; - return typeof a == 'function' ? a.bind(t) : a; - }, - } - ); - (e.op = s), (e.openpanel = t); - } - })(window); -})(); diff --git a/apps/start/src/components/sessions/replay/replay-player.tsx b/apps/start/src/components/sessions/replay/replay-player.tsx index a9ba3e7e..d80159cf 100644 --- a/apps/start/src/components/sessions/replay/replay-player.tsx +++ b/apps/start/src/components/sessions/replay/replay-player.tsx @@ -93,6 +93,9 @@ export function ReplayPlayer({ playerRef.current = player; + // Track play state from replayer (getMetaData() does not expose isPlaying) + let playingState = false; + // Wire rrweb's built-in event emitter — no RAF loop needed. // Note: rrweb-player does NOT emit ui-update-duration; duration is // read from getMetaData() on init and after each addEvent batch. @@ -102,16 +105,19 @@ export function ReplayPlayer({ }); player.addEventListener('ui-update-player-state', (e) => { - setIsPlaying(e.payload === 'playing'); + const playing = e.payload === 'playing'; + playingState = playing; + setIsPlaying(playing); }); - // Pause on tab hide; resume on show (prevents timer drift) + // Pause on tab hide; resume on show (prevents timer drift). + // getMetaData() does not expose isPlaying, so we use playingState + // kept in sync by ui-update-player-state above. let wasPlaying = false; handleVisibilityChange = () => { if (!player) return; if (document.hidden) { - const meta = player.getMetaData() as { isPlaying?: boolean }; - wasPlaying = meta.isPlaying ?? false; + wasPlaying = playingState; if (wasPlaying) player.pause(); } else { if (wasPlaying) { diff --git a/apps/start/src/components/sessions/replay/replay-timeline.tsx b/apps/start/src/components/sessions/replay/replay-timeline.tsx index 1df4dfda..33a9a3f5 100644 --- a/apps/start/src/components/sessions/replay/replay-timeline.tsx +++ b/apps/start/src/components/sessions/replay/replay-timeline.tsx @@ -56,6 +56,9 @@ export function ReplayTimeline({ events }: { events: IServiceEvent[] }) { (clientX: number) => { if (!trackRef.current || duration <= 0) return null; const rect = trackRef.current.getBoundingClientRect(); + if (rect.width <= 0 || !Number.isFinite(rect.width)) { + return null; + } const x = clientX - rect.left; const pct = Math.max(0, Math.min(1, x / rect.width)); return { pct, timeMs: pct * duration }; diff --git a/packages/sdks/nextjs/index.tsx b/packages/sdks/nextjs/index.tsx index 02b3b594..e2023455 100644 --- a/packages/sdks/nextjs/index.tsx +++ b/packages/sdks/nextjs/index.tsx @@ -136,6 +136,7 @@ export function useOpenPanel() { clearRevenue, pendingRevenue, fetchDeviceId, + getDeviceId, }; } @@ -171,6 +172,9 @@ function decrement(payload: DecrementPayload) { function fetchDeviceId() { return window.op.fetchDeviceId(); } +function getDeviceId() { + return window.op.getDeviceId(); +} function clearRevenue() { window.op.clearRevenue(); } diff --git a/packages/sdks/web/src/replay/recorder.ts b/packages/sdks/web/src/replay/recorder.ts index 55312abe..4d2bd8fd 100644 --- a/packages/sdks/web/src/replay/recorder.ts +++ b/packages/sdks/web/src/replay/recorder.ts @@ -48,8 +48,9 @@ export function startReplayRecorder( if (buffer.length === 0) return; const payloadJson = JSON.stringify(buffer); + const payloadBytes = new TextEncoder().encode(payloadJson).length; - if (payloadJson.length > maxPayloadBytes) { + if (payloadBytes > maxPayloadBytes) { if (buffer.length > 1) { const mid = Math.floor(buffer.length / 2); const firstHalf = buffer.slice(0, mid); @@ -70,17 +71,21 @@ export function startReplayRecorder( const startedAt = buffer[0]!.timestamp; const endedAt = buffer[buffer.length - 1]!.timestamp; - sendChunk({ - chunk_index: chunkIndex, - events_count: buffer.length, - is_full_snapshot: isFullSnapshot, - started_at: new Date(startedAt).toISOString(), - ended_at: new Date(endedAt).toISOString(), - payload: payloadJson, - }); - - chunkIndex += 1; - buffer = []; + try { + sendChunk({ + chunk_index: chunkIndex, + events_count: buffer.length, + is_full_snapshot: isFullSnapshot, + started_at: new Date(startedAt).toISOString(), + ended_at: new Date(endedAt).toISOString(), + payload: payloadJson, + }); + chunkIndex += 1; + buffer = []; + } catch (err) { + console.error('[ReplayRecorder] sendChunk failed', err); + throw err; + } } function flushIfNeeded(isCheckout: boolean): void { @@ -132,6 +137,11 @@ export function startReplayRecorder( window.addEventListener('pagehide', onPageHide); stopRecording = () => { + // Flush any buffered events before tearing down (same logic as flushTimer) + if (buffer.length > 0) { + const hasFullSnapshot = buffer.some((e) => e.type === 2); + flush(hasFullSnapshot); + } if (flushTimer) { clearInterval(flushTimer); flushTimer = null; diff --git a/packages/sdks/web/src/types.d.ts b/packages/sdks/web/src/types.d.ts index 4f7736bd..1c3efe8a 100644 --- a/packages/sdks/web/src/types.d.ts +++ b/packages/sdks/web/src/types.d.ts @@ -14,7 +14,9 @@ type ExposedMethodsNames = | 'clearRevenue' | 'pendingRevenue' | 'screenView' - | 'fetchDeviceId'; + | 'fetchDeviceId' + | 'getDeviceId' + | 'getSessionId'; export type ExposedMethods = { [K in ExposedMethodsNames]: OpenPanel[K] extends (...args: any[]) => any @@ -38,7 +40,7 @@ type OpenPanelMethodSignatures = { } & { screenView( pathOrProperties?: string | TrackProperties, - properties?: TrackProperties, + properties?: TrackProperties ): void; }; diff --git a/packages/sdks/web/src/types.debug.ts b/packages/sdks/web/src/types.debug.ts index 738f3b9a..e28583e9 100644 --- a/packages/sdks/web/src/types.debug.ts +++ b/packages/sdks/web/src/types.debug.ts @@ -1,4 +1,5 @@ // Test callable function API +/** biome-ignore-all lint/correctness/noUnusedVariables: test */ function testCallableAPI() { // ✅ Should work - correct callable syntax window.op('track', 'button_clicked', { location: 'header' }); @@ -29,6 +30,7 @@ function testDirectMethodAPI() { window.op.flushRevenue(); window.op.clearRevenue(); window.op.fetchDeviceId(); + window.op.getDeviceId(); // ❌ Should error - wrong arguments for track // @ts-expect-error - track expects (name: string, properties?: TrackProperties)