This commit is contained in:
Carl-Gerhard Lindesvärd
2026-02-26 12:35:13 +01:00
parent d5513d8a47
commit 6ddea4a7bc
12 changed files with 84 additions and 405 deletions

View File

@@ -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)"
]
}
}

View File

@@ -194,7 +194,7 @@ async function handleTrack(
.filter(Boolean)
.join('-');
const promises = [];
const promises: Promise<unknown>[] = [];
// 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',
});

View File

@@ -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;

View File

@@ -33,7 +33,7 @@ This is the most common flow and most secure one. Your backend receives webhooks
<FlowStep step={2} actor="Visitor" description="Makes a purchase" icon="visitor" />
<FlowStep step={3} actor="Your website" description="Does a POST request to get the checkout URL" icon="website">
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<string>
op.getDeviceId(): string
```

View File

@@ -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',

View File

@@ -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);
})();

View File

@@ -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) {

View File

@@ -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 };

View File

@@ -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();
}

View File

@@ -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;

View File

@@ -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;
};

View File

@@ -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)