Skip to content

Feature/replay#302

Merged
lindesvard merged 7 commits into
mainfrom
feature/replay
Feb 26, 2026
Merged

Feature/replay#302
lindesvard merged 7 commits into
mainfrom
feature/replay

Conversation

@lindesvard

@lindesvard lindesvard commented Feb 26, 2026

Copy link
Copy Markdown
Contributor

Summary by CodeRabbit

  • New Features

    • Session replay: browser recording, chunked upload, playback UI (player, timeline, event feed) and Sessions tab in profiles
    • Central device/session ID tracking surfaced to SDKs and apps
    • SDK: new scriptUrl prop (cdnUrl deprecated)
  • Bug Fixes

    • Duplicate-event checks skip replay events
    • Improved session handling and fallback behavior
  • Documentation

    • Updated SDK and guides to use scriptUrl and new device ID getter (getDeviceId)

@coderabbitai

coderabbitai Bot commented Feb 26, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough
🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title 'Feature/replay' is vague and generic, using a non-descriptive pattern that doesn't convey meaningful information about the changeset. Replace with a clear, specific title like 'Add session replay recording and playback support' or 'Implement session replay feature with recorder and UI components'.
Docstring Coverage ⚠️ Warning Docstring coverage is 6.41% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/replay

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 18

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
packages/sdks/nextjs/index.tsx (2)

167-169: ⚠️ Potential issue | 🟡 Minor

Missing optional chaining on decrement call.

The decrement function uses window.op() directly without optional chaining, unlike other similar functions (track, identify, increment, etc.) that use window.op?.(). This could cause a runtime error if window.op is undefined.

🐛 Proposed fix
 function decrement(payload: DecrementPayload) {
-  window.op('decrement', payload);
+  window.op?.('decrement', payload);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/sdks/nextjs/index.tsx` around lines 167 - 169, The decrement
function calls window.op without optional chaining and can throw if window.op is
undefined; update the decrement function (named decrement and located alongside
track, identify, increment) to call window.op using optional chaining like the
other handlers (e.g., window.op?.(...)) so it safely no-ops when window.op is
not present.

171-185: ⚠️ Potential issue | 🟡 Minor

Missing optional chaining on multiple functions.

Several functions (fetchDeviceId, clearRevenue, pendingRevenue, revenue, flushRevenue) access window.op properties directly without optional chaining, which could cause runtime errors if the SDK hasn't loaded yet.

🛡️ Proposed fix for defensive access
 function fetchDeviceId() {
-  return window.op.fetchDeviceId();
+  return window.op?.fetchDeviceId();
 }
 function clearRevenue() {
-  window.op.clearRevenue();
+  window.op?.clearRevenue();
 }
 function pendingRevenue(amount: number, properties?: Record<string, unknown>) {
-  window.op.pendingRevenue(amount, properties);
+  window.op?.pendingRevenue(amount, properties);
 }
 function revenue(amount: number, properties?: Record<string, unknown>) {
-  return window.op.revenue(amount, properties);
+  return window.op?.revenue(amount, properties);
 }
 function flushRevenue() {
-  return window.op.flushRevenue();
+  return window.op?.flushRevenue();
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/sdks/nextjs/index.tsx` around lines 171 - 185, The listed functions
(fetchDeviceId, clearRevenue, pendingRevenue, revenue, flushRevenue) access
window.op directly and should defensively use optional chaining to avoid runtime
errors if the SDK isn't loaded; update each function to call
window.op?.<methodName>? with safe returns (e.g., return undefined or a Promise
if original returned one) and ensure pendingRevenue and revenue still accept
parameters while guarding calls with window.op?.pendingRevenue?.(amount,
properties) and window.op?.revenue?.(amount, properties); similarly guard
fetchDeviceId, clearRevenue and flushRevenue with window.op?.fetchDeviceId?.(),
window.op?.clearRevenue?.(), and window.op?.flushRevenue?.() so missing
window.op or missing methods won’t throw.
packages/sdks/sdk/src/index.ts (1)

177-188: ⚠️ Potential issue | 🟡 Minor

Avoid mutating the input parameter.

delete properties?.deviceId mutates the caller's object, which can cause unexpected side effects for code that retains a reference to it. Destructure instead to avoid mutation.

♻️ Proposed fix using destructuring
   async revenue(
     amount: number,
     properties?: TrackProperties & { deviceId?: string }
   ) {
-    const deviceId = properties?.deviceId;
-    delete properties?.deviceId;
+    const { deviceId, ...restProperties } = properties ?? {};
     return this.track('revenue', {
-      ...(properties ?? {}),
+      ...restProperties,
       ...(deviceId ? { __deviceId: deviceId } : {}),
       __revenue: amount,
     });
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/sdks/sdk/src/index.ts` around lines 177 - 188, The revenue method
currently mutates the caller's properties by using delete properties?.deviceId;
fix this by destructuring deviceId out of the incoming properties (e.g., const {
deviceId, ...rest } = properties ?? {}) so you create a new object without
mutating the original, then call this.track('revenue', { ...rest, ...(deviceId ?
{ __deviceId: deviceId } : {}), __revenue: amount }); update the revenue
function to use the new local rest object instead of deleting from properties.
🟡 Minor comments (6)
apps/public/public/op1.bak.js-115-124 (1)

115-124: ⚠️ Potential issue | 🟡 Minor

Mutating the input parameter can cause unexpected side effects.

delete i?.deviceId modifies the caller's object directly. If the caller reuses the options object, deviceId will be unexpectedly missing. Consider destructuring to avoid mutation.

Suggested fix using destructuring
     async revenue(e, i) {
-      const t = i?.deviceId;
-      return (
-        delete i?.deviceId,
-        this.track('revenue', {
-          ...(i ?? {}),
-          ...(t ? { __deviceId: t } : {}),
-          __revenue: e,
-        })
-      );
+      const { deviceId: t, ...rest } = i ?? {};
+      return this.track('revenue', {
+        ...rest,
+        ...(t ? { __deviceId: t } : {}),
+        __revenue: e,
+      });
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/public/public/op1.bak.js` around lines 115 - 124, The revenue method is
mutating the caller's options object by using "delete i?.deviceId"; instead,
avoid side effects by destructuring the parameter (e.g., const { deviceId,
...rest } = i ?? {}) and then call this.track('revenue', { ...rest, ...(deviceId
? { __deviceId: deviceId } : {}), __revenue: e }); update the revenue function
to use these local variables (deviceId/rest) so the original i is not modified.
apps/public/public/op1.bak.js-1-337 (1)

1-337: ⚠️ Potential issue | 🟡 Minor

Remove or exclude the outdated op1.bak.js file.

This is a minified backup of an older version of the OpenPanel SDK. The file contains the same logic as op1.js but with less aggressive minification. Since op1.js is the production version being used, the .bak.js backup should either be removed from the repository or added to .gitignore. Keeping both adds maintenance burden without providing value.

The minified variable names (u, l, p, etc.) are expected in compiled/minified code and don't violate coding standards.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/public/public/op1.bak.js` around lines 1 - 337, Remove the outdated
backup bundle op1.bak.js (duplicate of the production op1.js): either delete
op1.bak.js from the repository and any build/deploy inputs that include it, or
add op1.bak.js to .gitignore so it isn’t committed; ensure no runtime or build
scripts reference op1.bak.js (look for usages of window.op / window.openpanel
assignment in your build config or HTML includes) before committing the change.
apps/start/src/modals/event-details.tsx-278-281 (1)

278-281: ⚠️ Potential issue | 🟡 Minor

Replace as any casts and fix falsy value filtering to follow type safety guidelines.

Line 278–281: Change item.value as any to as unknown.

Lines 298–304, 306–307: The pattern item.value && (filterable as any)[item.name] has two issues:

  • item.value && skips valid falsy values like 0, false, or empty strings
  • (filterable as any) bypasses type checking on an already properly typed object

Use a type guard to safely narrow the key type and a helper function to check for non-null/undefined values:

Proposed fix
+const hasFilterValue = (value: unknown) =>
+  value !== null && value !== undefined;
+
+const isFilterableKey = (
+  name: string,
+): name is keyof typeof filterable => Object.hasOwn(filterable, name);

 <KeyValueGrid
   columns={1}
   data={properties}
   onItemClick={(item) => {
     popModal();
-    setFilter(`properties.${item.name}`, item.value as any);
+    setFilter(`properties.${item.name}`, item.value as unknown);
   }}
 />

 <KeyValueGrid
   columns={1}
   data={data}
   onItemClick={(item) => {
-    const isFilterable = item.value && (filterable as any)[item.name];
-    if (isFilterable) {
+    const isFilterable =
+      hasFilterValue(item.value) && isFilterableKey(item.name);
+    if (isFilterable) {
       popModal();
       setFilter(item.name as keyof IServiceEvent, item.value);
     }
   }}
   renderValue={(item) => {
-    const isFilterable = item.value && (filterable as any)[item.name];
+    const isFilterable =
+      hasFilterValue(item.value) && isFilterableKey(item.name);
     if (isFilterable) {
       return (

Per coding guidelines: prefer unknown over any, and leverage TypeScript's type narrowing instead of type assertions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/start/src/modals/event-details.tsx` around lines 278 - 281, Replace the
unsafe casts and faulty falsy filtering in the event-details modal: change the
cast in the onItemClick handler from item.value as any to item.value as unknown
when calling setFilter (referencing popModal and setFilter), implement a type
guard to narrow item.name to a valid key of the filterable object (used where
you currently do (filterable as any)[item.name]) and remove the `item.value &&`
guard; instead add a small helper (e.g., isPresent) that checks value !== null
&& value !== undefined so falsy-but-valid values (0, false, '') are kept, and
use the type guard + isPresent to safely access and test (filterable)[item.name]
without any casts.
apps/start/src/components/events/table/columns.tsx-151-160 (1)

151-160: ⚠️ Potential issue | 🟡 Minor

Extract magic number 6 to a named constant for clarity.

Line 158 uses a hardcoded magic number 6 for session ID preview length. Per coding guidelines, extract this to a meaningfully named constant.

Proposed fix
      cell({ row }) {
        const { sessionId } = row.original;
+       const SESSION_ID_PREVIEW_LENGTH = 6;
        return (
          <ProjectLink
            href={`/sessions/${encodeURIComponent(sessionId)}`}
            className="whitespace-nowrap font-medium hover:underline"
          >
-           {sessionId.slice(0,6)}
+           {sessionId.slice(0, SESSION_ID_PREVIEW_LENGTH)}
          </ProjectLink>
        );
      },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/start/src/components/events/table/columns.tsx` around lines 151 - 160,
Extract the hardcoded "6" used in the cell renderer for session preview into a
named constant (e.g., SESSION_ID_PREVIEW_LENGTH) and use that constant in the
slice call inside the cell({ row }) function where sessionId.slice(0,6) is used;
place the constant near the top of the module or above the columns definition so
it's clearly visible and update the ProjectLink display to use
sessionId.slice(0, SESSION_ID_PREVIEW_LENGTH).
apps/api/src/utils/ids.ts-76-78 (1)

76-78: ⚠️ Potential issue | 🟡 Minor

Replace console.error with the project logger path.

Use structured app logging here so this utility follows production logging standards and central observability.

As per coding guidelines "Remove console.log, debugger, and alert statements from production code".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/utils/ids.ts` around lines 76 - 78, Replace the console.error
call in the catch block in apps/api/src/utils/ids.ts with the centralized
project logger: import the shared logger instance used across the app (e.g.,
logger or appLogger) at the top of the file and call logger.error with a
structured message and the error object (include context like "GET
/track/device-id" and any relevant identifiers). Ensure the new call uses
structured fields (message, error) rather than console, so replace
console.error('Error getting session end GET /track/device-id', error) with
logger.error({ msg: 'Error getting session end GET /track/device-id', error })
or the project logger's equivalent.
apps/start/src/components/sessions/replay/replay-timeline.tsx-109-137 (1)

109-137: ⚠️ Potential issue | 🟡 Minor

Event grouping threshold uses stale width on container resize.

Line 116 reads trackRef.current?.offsetWidth during render, but groupedEvents only depends on [eventsWithOffset, duration]—not width. On window resize, fullscreen toggle, or responsive layout changes, the threshold stays based on the initial (or fallback 600) width, causing inaccurate grouping.

Wrap width tracking with a ResizeObserver in useEffect, store it in state, and add it to the dependency array:

Proposed fix
+  const [trackWidth, setTrackWidth] = useState(0);
+
+  useEffect(() => {
+    const node = trackRef.current;
+    if (!node) return;
+    const observer = new ResizeObserver(() => {
+      setTrackWidth(node.offsetWidth);
+    });
+    setTrackWidth(node.offsetWidth);
+    observer.observe(node);
+    return () => observer.disconnect();
+  }, []);

   const groupedEvents = useMemo(() => {
     if (!eventsWithOffset.length || duration <= 0) return [];
@@
-    const trackWidth = trackRef.current?.offsetWidth ?? 600;
-    const thresholdMs = (24 / trackWidth) * duration;
+    const effectiveTrackWidth = trackWidth || 600;
+    const thresholdMs = (24 / effectiveTrackWidth) * duration;
@@
-  }, [eventsWithOffset, duration]);
+  }, [eventsWithOffset, duration, trackWidth]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/start/src/components/sessions/replay/replay-timeline.tsx` around lines
109 - 137, The grouping threshold uses trackRef.current?.offsetWidth inside the
useMemo for groupedEvents but width isn't in the dependency array, causing stale
thresholds on resize; fix by tracking the container width in state (e.g.,
containerWidth) updated via a ResizeObserver in a useEffect that observes
trackRef (create/cleanup observer), use containerWidth (fall back to 600)
instead of reading trackRef directly, and add containerWidth to the useMemo deps
so thresholdMs and grouping recompute when the container resizes; update
symbols: groupedEvents, trackRef, thresholdMs, and the useMemo deps.
🧹 Nitpick comments (23)
apps/start/src/components/ui/scroll-area.tsx (1)

6-30: Consider migrating from React.forwardRef to ref-as-prop pattern for consistency.

VirtualScrollArea uses the legacy React.forwardRef pattern, while ScrollArea (line 37) already uses the modern React 19+ ref-as-prop approach. For consistency and alignment with React 19+ conventions, consider refactoring this component.

♻️ Proposed refactor to ref-as-prop pattern
-export const VirtualScrollArea = React.forwardRef<
-  HTMLDivElement,
-  {
-    children: React.ReactNode;
-    className?: string;
-  }
->(({ children, className }, ref) => {
+export function VirtualScrollArea({
+  children,
+  className,
+  ref,
+}: {
+  children: React.ReactNode;
+  className?: string;
+  ref?: React.Ref<HTMLDivElement>;
+}) {
   // The ref MUST point directly to the scrollable element
   // This element MUST have:
   // 1. overflow-y-auto (or overflow: auto)
   // 2. A constrained height (via flex-1 min-h-0 or fixed height)
   return (
     <div
       ref={ref}
       className={cn('overflow-y-auto w-full', className)}
       style={{
         // Ensure height is constrained by flex parent
         height: '100%',
         maxHeight: '100%',
       }}
     >
       {children}
     </div>
   );
-});
-
-VirtualScrollArea.displayName = 'VirtualScrollArea';
+}

As per coding guidelines: "Use ref as a prop instead of React.forwardRef in React 19+".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/start/src/components/ui/scroll-area.tsx` around lines 6 - 30,
VirtualScrollArea currently uses React.forwardRef; change it to the ref-as-prop
pattern by converting the forwardRef wrapper into a normal functional component
that accepts a typed ref prop (e.g. ref?: React.Ref<HTMLDivElement> or
alternatively scrollRef?: React.Ref<HTMLDivElement> if you prefer avoiding the
name collision), update the props type to include that ref prop alongside
children and className, pass that prop directly to the div's ref attribute, and
remove the React.forwardRef export; keep the existing className, style, and
comments intact so the scrollable element still has overflow-y-auto and
constrained height.
apps/start/src/routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.sessions.tsx (1)

7-11: Consider adding a loader for data prefetching.

The route lacks a loader function to prefetch session data. Comparing to similar routes in the codebase (e.g., the session detail route), adding a loader would improve perceived performance by prefetching the initial page of sessions during navigation:

export const Route = createFileRoute(
  '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions',
)({
  component: Component,
  loader: async ({ context, params }) => {
    await context.queryClient.prefetchInfiniteQuery(
      context.trpc.session.list.infiniteQueryOptions({
        projectId: params.projectId,
        profileId: params.profileId,
        take: 50,
        search: '',
      }),
    );
  },
});

This is optional for infinite queries since users may be accustomed to seeing the loading state, but it would provide consistency with other routes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/start/src/routes/_app`.$organizationId.$projectId.profiles.$profileId._tabs.sessions.tsx
around lines 7 - 11, The Route created by createFileRoute for the sessions tab
(exported as Route with component Component) lacks a loader to prefetch the
sessions list; add a loader property to the Route that calls
context.queryClient.prefetchInfiniteQuery using
context.trpc.session.list.infiniteQueryOptions with params.projectId and
params.profileId (e.g., take: 50, search: ''), so the first page of sessions is
prefetched during navigation and the component can render with cached data.
apps/public/public/op1.bak.js (5)

139-150: Prefer for...of over .forEach() for iterating the queue.

As per coding guidelines, for...of loops are preferred over .forEach() and indexed for loops.

Suggested refactor
     flush() {
-      this.queue.forEach((e) => {
-        this.send({
+      for (const e of this.queue) {
+        this.send({
           ...e,
           payload: {
             ...e.payload,
             profileId: e.payload.profileId ?? this.profileId,
           },
         });
-      }),
-        (this.queue = []);
+      }
+      this.queue = [];
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/public/public/op1.bak.js` around lines 139 - 150, The flush method
currently uses this.queue.forEach(...) which violates the guideline preferring
for...of; change flush to iterate with a for...of loop over this.queue (e.g.,
for (const e of this.queue) { this.send({ ...e, payload: { ...e.payload,
profileId: e.payload.profileId ?? this.profileId } }); }) and then clear the
queue (this.queue = []) after the loop, keeping the existing use of this.send
and the profileId fallback logic intact.

28-28: Redundant null coalescing operator.

When i is truthy (which it must be to enter this branch), i ?? {} will always evaluate to i. The ?? {} fallback is unreachable.

Suggested simplification
-            body: i ? JSON.stringify(i ?? {}) : void 0,
+            body: i ? JSON.stringify(i) : void 0,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/public/public/op1.bak.js` at line 28, The ternary branch uses an
unreachable null coalescing fallback: change the body expression from using
JSON.stringify(i ?? {}) to JSON.stringify(i) when i is truthy; locate the
assignment that contains "body: i ? JSON.stringify(i ?? {}) : void 0" and remove
the "?? {}" so the branch becomes "body: i ? JSON.stringify(i) : void 0".

314-316: Prefer for...of over .forEach() for processing the queue.

As per coding guidelines, for...of loops are preferred over .forEach().

Suggested refactor
-      i.forEach((n) => {
-        n[0] in t && t[n[0]](...n.slice(1));
-      });
+      for (const n of i) {
+        if (n[0] in t) {
+          t[n[0]](...n.slice(1));
+        }
+      }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/public/public/op1.bak.js` around lines 314 - 316, Replace the i.forEach
callback with a for...of loop to iterate the queue `i` (preserving the exact
semantics): iterate using `for (const n of i)` and inside check `if (n[0] in t)`
then call `t[n[0]](...n.slice(1))`; update the block containing `i.forEach((n)
=> { n[0] in t && t[n[0]](...n.slice(1)); });` to use this `for...of` pattern so
`i`, `n`, and `t` references remain the same and behavior is unchanged.

173-175: Empty catch blocks silently swallow errors.

Multiple empty catch blocks (lines 173, 209, 293, 306) hide failures without any logging or handling. While these may be intentional for non-critical operations (sessionStorage access, URL parsing), consider at minimum logging in debug mode to aid troubleshooting.

Example: Add debug logging to catch blocks
         } catch {
-          this.pendingRevenues = [];
+          this.pendingRevenues = [];
+          this.log('Failed to load pending revenues from sessionStorage');
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/public/public/op1.bak.js` around lines 173 - 175, Empty catch blocks
(e.g., the one swallowing errors before setting this.pendingRevenues) hide
failures; update each catch to capture the error (catch (err)) and emit a
minimal debug/log message (e.g., console.debug or a designated logger) including
the error and context (mentioning the affected symbol like this.pendingRevenues,
sessionStorage access, or URL parsing) and only suppress the error in
production/non-debug modes—ensure you use the same pattern in all catch sites to
aid troubleshooting without changing behavior in production.

318-323: Consider gating the console.warn behind debug mode.

The console.warn on invalid method calls is helpful for developers integrating the SDK, but may be noisy in production. Consider gating it behind the debug flag for consistency with the log method.

Suggested change
         const a = t[n] ? t[n].bind(t) : void 0;
         typeof a == 'function'
           ? a(...r)
-          : console.warn(`OpenPanel: ${n} is not a function`);
+          : t.log(`${n} is not a function`);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/public/public/op1.bak.js` around lines 318 - 323, The anonymous
dispatcher (the arrow function that looks up a = t[n] and calls it) currently
emits console.warn when a method name is invalid; change it to respect the
module's debug flag and use the same logging mechanism as the existing log
method instead of unconditionally calling console.warn. Specifically, replace
the standalone console.warn(...) in the else branch with a conditional that
checks the existing debug flag (the same flag used by the log method) and, when
enabled, calls the central logger (the module's log function) or console.warn
through that path, keeping the message `OpenPanel: ${n} is not a function`;
ensure you reference the same context object (t) so the debug check and log
invocation use the module instance's debug/log settings.
apps/start/src/modals/event-details.tsx (1)

239-243: Use Next.js Image and provide avatar alt text.

Using raw <img> here misses the project guideline and accessibility requirement for meaningful alt text.

Proposed fix
+import Image from 'next/image';

 {profile.avatar && (
-  <img
-    className="size-4 rounded-full bg-border"
-    src={profile.avatar}
-  />
+  <Image
+    alt={`${getProfileName(profile, false)} avatar`}
+    className="size-4 rounded-full bg-border"
+    height={16}
+    src={profile.avatar}
+    width={16}
+  />
 )}

As per coding guidelines, "Use proper image components (e.g., Next.js <Image>) over <img> tags" and "provide meaningful alt text for images".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/start/src/modals/event-details.tsx` around lines 239 - 243, The code
uses a raw <img> for profile.avatar which violates project guidelines and lacks
accessibility alt text; replace the <img> with Next.js Image: import Image from
"next/image" at the top, render <Image src={profile.avatar} alt={`${profile.name
?? "User"} avatar`} className="size-4 rounded-full bg-border" width={40}
height={40} /> (adjust width/height to match "size-4"), or wrap Image in a
container if responsive styling is needed, ensuring the profile.avatar value is
a valid URL and preserving the original className styling.
.claude/notnow-settings.local.json (1)

1-28: Avoid committing broad local-agent permissions into the shared repo.

This .local config grants high-privilege capabilities (Bash, Edit, Write) plus multiple outbound fetch scopes. Prefer keeping this local-only (gitignored) or reducing to a least-privilege shared baseline.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.claude/notnow-settings.local.json around lines 1 - 28, The .local settings
currently grant broad local-agent powers via the permissions.allow array (e.g.,
entries like "mcp__acp__Bash", "mcp__acp__Edit", "mcp__acp__Write" and many
"WebFetch(domain:...)" scopes); remove or restrict these sensitive entries from
the committed file and either (a) move this file out of the repo and add it to
.gitignore, or (b) replace it with a sanitized template (e.g.,
.claude/notnow-settings.local.json.example) that contains a minimal
least-privilege baseline in permissions.allow; ensure the live high-privilege
scopes remain only in local developer copies and update docs to instruct
developers to create their own ignored local config from the template.
apps/worker/src/jobs/events.incoming-events.test.ts (1)

102-105: Move UUID regex to a top-level constant.

Keeping the UUID pattern as a module-level const avoids the inline suppression and makes expectations easier to reuse across tests.

As per coding guidelines: "Use top-level regex literals instead of creating them in loops".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/worker/src/jobs/events.incoming-events.test.ts` around lines 102 - 105,
Extract the inline UUID regex used in the sessionId expectation (the
expect.stringMatching(...) call) into a module-level constant (e.g., UUID_REGEX)
and replace the inline pattern with that constant; remove the biome-ignore
lint/performance/useTopLevelRegex comment, and update any other tests in the
file that can reuse the new UUID_REGEX to keep expectations consistent and avoid
creating regexes in-loop.
packages/db/src/buffers/session-buffer.ts (1)

188-195: Extract cache TTL constant and type the public getter return.

The duplicated 60 * 60 is a magic value, and getBufferSize() is clearer with an explicit Promise<number> return type.

♻️ Suggested cleanup
+const SESSION_CACHE_TTL_SECONDS = 60 * 60;
+
-  async add(event: IClickhouseEvent) {
+  async add(event: IClickhouseEvent) {
@@
-        60 * 60
+        SESSION_CACHE_TTL_SECONDS
@@
-          60 * 60
+          SESSION_CACHE_TTL_SECONDS
@@
-  getBufferSize() {
+  getBufferSize(): Promise<number> {
     return this.getBufferSizeWithCounter(() => this.redis.llen(this.redisKey));
   }

As per coding guidelines: "Use meaningful variable names instead of magic numbers - extract constants with descriptive names" and "Use explicit types for function parameters and return values when they enhance clarity".

Also applies to: 262-263

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/db/src/buffers/session-buffer.ts` around lines 188 - 195, Replace
the duplicated magic TTL (60 * 60) used when setting session cache keys with a
named constant (e.g., SESSION_CACHE_TTL_SECONDS) and use that constant in both
places where multi.set is called for session cache; also add an explicit return
type Promise<number> to the public getter getBufferSize() to improve clarity and
typing—update the function signature of getBufferSize() and any related callers
to respect the Promise<number> return. Ensure references to
newSession.profile_id and multi.set(...) remain unchanged except for using the
new constant.
packages/db/src/buffers/replay-buffer.ts (1)

36-37: Add explicit return types for public methods.

This class is a shared buffer primitive; explicit returns (Promise<void>, Promise<number>) make contracts clearer at call sites.

As per coding guidelines: "Use explicit types for function parameters and return values when they enhance clarity".

Also applies to: 55-56, 88-90

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/db/src/buffers/replay-buffer.ts` around lines 36 - 37, The public
methods on the ReplayBuffer class (e.g., async add(chunk:
IClickhouseSessionReplayChunk)) need explicit return types: annotate add as
Promise<void> if it does not return a value, and annotate the other public
methods in the same class with their appropriate Promise types (for example
Promise<number> for size/count methods or Promise<void> for flush/clear
methods); update each method signature in replay-buffer.ts to include the
explicit Promise<> return type matching the actual return behavior so callers
have a clear contract.
packages/sdks/web/src/replay/index.ts (1)

1-2: Avoid introducing a barrel file in replay/index.ts.

This file is purely a re-export barrel; prefer importing from ./recorder directly from call sites or expose a non-barrel entrypoint.

As per coding guidelines **/index.{js,ts,jsx,tsx}: “Avoid barrel files (index files that re-export everything)”.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/sdks/web/src/replay/index.ts` around lines 1 - 2, Remove the barrel
re-export in replay/index.ts: delete this file (or remove its export lines) and
update all call sites to import startReplayRecorder, stopReplayRecorder,
ReplayChunkPayload, and ReplayRecorderConfig directly from './recorder' instead
of from the replay index; locate usages that import from the replay folder and
change them to import { startReplayRecorder, stopReplayRecorder } from
'./recorder' and import type { ReplayChunkPayload, ReplayRecorderConfig } from
'./recorder'.
packages/sdks/sdk/src/index.ts (1)

211-231: Unawaited send() calls may fail silently.

The flush() method fires off send() calls without awaiting them. If any fail, errors are lost. Consider capturing the promises and logging failures, or returning them for the caller to handle.

Also, the for...of loop aligns well with the coding guidelines.

♻️ Optional: capture and handle errors from flushed sends
   flush() {
     const remaining: TrackHandlerPayload[] = [];
+    const sendPromises: Promise<unknown>[] = [];
     for (const item of this.queue) {
       if (this.shouldQueue(item)) {
         remaining.push(item);
         continue;
       }
       const payload =
         item.type === 'replay'
           ? item.payload
           : {
               ...item.payload,
               profileId:
                 'profileId' in item.payload
                   ? (item.payload.profileId ?? this.profileId)
                   : this.profileId,
             };
-      this.send({ ...item, payload } as TrackHandlerPayload);
+      sendPromises.push(
+        this.send({ ...item, payload } as TrackHandlerPayload).catch((err) =>
+          this.log('Failed to flush payload', err)
+        )
+      );
     }
     this.queue = remaining;
+    return Promise.all(sendPromises);
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/sdks/sdk/src/index.ts` around lines 211 - 231, The flush() method
currently invokes send() without awaiting results, risking silent failures;
update flush() (which iterates this.queue and calls this.send for items that
shouldn't be queued) to collect the send() promises (e.g., into an array) and
await them (preferably via Promise.allSettled) so failures can be detected and
logged via the existing logger, and ensure any items that failed to send are
left in this.queue (or re-queued) rather than being dropped; reference the
flush, send, shouldQueue, queue, TrackHandlerPayload, and profileId symbols when
making this change.
apps/start/src/components/sessions/replay/replay-event-feed.tsx (1)

46-59: Effect dependency could be more precise.

The effect's dependency array is [visibleEvents.length], but the effect also reads visibleEvents to check its length. While this works because you're comparing against prevCountRef, if visibleEvents reference changes but length stays the same, the effect won't run (which is likely the desired behavior for performance).

This is a subtle but intentional optimization. Consider adding a brief comment to clarify:

+  // Depend only on length to avoid re-running when events change but count doesn't
   }, [visibleEvents.length]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/start/src/components/sessions/replay/replay-event-feed.tsx` around lines
46 - 59, The effect in the ReplayEventFeed using useEffect (references
viewportRef, visibleEvents, prevCountRef) intentionally depends only on
visibleEvents.length for performance, but this intent isn’t documented; add a
short inline comment above the useEffect explaining that the dependency is
intentionally limited to visibleEvents.length so the effect only runs on length
changes (not on reference changes) and that prevCountRef.current is used to
detect new items for smooth scrolling, preserving the current behavior and
optimization.
apps/start/src/components/sessions/replay/replay-player.tsx (2)

153-155: Cleanup accesses containerRef.current which may be stale.

In the cleanup function, containerRef.current is captured when the effect runs but may be null or point to a different element by the time cleanup executes (e.g., during unmount). The ref itself is stable, so accessing it at cleanup time is fine, but the current check if (containerRef.current) should use the ref's value at cleanup time (which it does). However, if the component unmounts, the ref may already be cleared.

This is typically safe but worth noting. A safer pattern would be to capture the container element at effect setup time:

♻️ Safer cleanup pattern
   useEffect(() => {
     if (!events.length || !containerRef.current) return;

+    const container = containerRef.current;
     // Clear any previous player DOM
-    containerRef.current.innerHTML = '';
+    container.innerHTML = '';

     // ... rest of effect using `container` instead of `containerRef.current`

     return () => {
       // ... other cleanup
-      if (containerRef.current) {
-        containerRef.current.innerHTML = '';
-      }
+      container.innerHTML = '';
       // ...
     };
   }, [/* deps */]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/start/src/components/sessions/replay/replay-player.tsx` around lines 153
- 155, The cleanup currently reads containerRef.current directly which can be
cleared or changed before cleanup runs; capture the element at effect setup and
use that captured reference in the cleanup instead: inside the effect (where
containerRef is accessed) set const container = containerRef.current and then in
the returned cleanup callback use container (e.g., if (container)
container.innerHTML = '') rather than accessing containerRef.current there; this
ensures the cleanup targets the same DOM node referenced by the effect and
avoids stale/null ref races.

56-159: Consider the effect's dependency array completeness.

The effect depends on recordedDimensions which is derived from events via useMemo. Since both events and recordedDimensions are in the dependency array, changes to events will trigger the effect twice if recordedDimensions also changes. This is likely intentional but could cause the player to reinitialize unnecessarily.

The current implementation should work correctly, but if you notice performance issues with frequent reinitializations, consider removing recordedDimensions from the dependency array since it's derived from events.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/start/src/components/sessions/replay/replay-player.tsx` around lines 56
- 159, The effect useEffect(...) currently lists both events and
recordedDimensions in its dependency array which causes double re-renders when
recordedDimensions (derived from events) changes; to prevent unnecessary player
reinitialization, remove recordedDimensions from the dependency array (keep
events and the callbacks onPlayerReady/onPlayerDestroy and setters) or ensure
recordedDimensions is memoized/stable before including it; update the dependency
list referenced in the useEffect containing containerRef, playerRef,
calcDimensions, and player lifecycle handling so the effect only reruns when
events or true external inputs change.
apps/start/src/components/sessions/replay/browser-chrome.tsx (1)

17-17: Clarify the url !== false condition or adjust the type.

The url prop is typed as ReactNode, but the condition url !== false on line 32 suggests an intent to use false as a special sentinel value to hide the URL bar. Since ReactNode implicitly allows false (falsy JSX renders nothing), this works but may confuse readers.

Consider either:

  1. Using undefined as the sentinel (more conventional): url !== undefined or just url &&
  2. Updating the type to be explicit: url?: ReactNode | false
♻️ Option 1: Use undefined as sentinel
-        {url !== false && (
+        {url != null && (
           <div className="flex-1 mx-4 px-3 h-8 py-1 text-sm bg-def-100 rounded-md border border-border flex items-center truncate">
             {url}
           </div>
         )}

Also applies to: 32-36

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/start/src/components/sessions/replay/browser-chrome.tsx` at line 17, The
prop `url` is treated as a false sentinel in the check `url !== false` but the
prop type is only `ReactNode`; make the intent explicit by updating the prop
signature to `url?: ReactNode | false` and keep the existing conditional (`url
!== false`) where the URL bar is toggled (references: the `url` prop and the
`url !== false` check in the render logic), so readers know `false` is an
intentional sentinel value for hiding the URL bar.
apps/start/src/components/sessions/replay/replay-utils.ts (1)

7-10: Simplify the Date type check.

The typeof event.createdAt === 'object' check is redundant when followed by instanceof Date, since instanceof Date already implies it's an object and handles null (returns false).

♻️ Suggested simplification
   const t =
-    typeof event.createdAt === 'object' && event.createdAt instanceof Date
+    event.createdAt instanceof Date
       ? event.createdAt.getTime()
       : new Date(event.createdAt).getTime();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/start/src/components/sessions/replay/replay-utils.ts` around lines 7 -
10, Simplify the Date type check by removing the redundant typeof check: in the
assignment to t (variable t in replay-utils.ts) use only "event.createdAt
instanceof Date" to detect Date instances and call getTime(), otherwise fall
back to new Date(event.createdAt).getTime(); update the conditional expression
accordingly so it relies solely on instanceof Date.
apps/start/src/types/rrweb-player.d.ts (1)

37-40: Consider narrowing the addEventListener handler signature.

The handler type (...args: unknown[]) => void is very loose. Based on the actual usage in replay-player.tsx (lines 99-106), the handler receives an object with a payload property. A more precise type would improve type safety:

     addEventListener?: (
       event: string,
-      handler: (...args: unknown[]) => void,
+      handler: (e: { payload: unknown }) => void,
     ) => void;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/start/src/types/rrweb-player.d.ts` around lines 37 - 40, The
addEventListener declaration uses a too-broad handler type; tighten it to
reflect that handlers receive an object with a payload by changing the handler
signature on addEventListener to accept a single parameter shaped like {
payload: <specificType> } (or unknown if generic), e.g., addEventListener?:
(event: string, handler: (evt: { payload: unknown }) => void) => void; update
the type for the handler used in replay-player.tsx accordingly (replace the
(...args: unknown[]) => void handler) so event handlers expect a single {
payload } object.
apps/start/src/components/sessions/replay/replay-context.tsx (1)

162-164: Use narrowing instead of a type assertion in setSpeed.

Line 163 uses a cast to satisfy includes, which weakens type-safety. A small type guard keeps this check explicit and assertion-free.

💡 Proposed refactor
+const isSpeedOption = (
+  value: number,
+): value is (typeof SPEED_OPTIONS)[number] => {
+  return SPEED_OPTIONS.some((option) => option === value);
+};
+
   const setSpeed = useCallback((s: number) => {
-    if (!SPEED_OPTIONS.includes(s as (typeof SPEED_OPTIONS)[number])) return;
+    if (!isSpeedOption(s)) return;
     playerRef.current?.setSpeed(s);
   }, []);

As per coding guidelines, "Leverage TypeScript's type narrowing instead of type assertions".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/start/src/components/sessions/replay/replay-context.tsx` around lines
162 - 164, The setSpeed function currently uses a type assertion to check
membership in SPEED_OPTIONS which weakens type-safety; replace the assertion
with a proper type guard (e.g., add an isSpeedOption(s: number): s is typeof
SPEED_OPTIONS[number] function) and use that guard inside setSpeed to narrow the
type before calling playerRef.current?.setSpeed(s), so the includes check is
assertion-free and the compiler knows s is a valid speed option.
apps/start/src/components/sessions/replay/replay-timeline.tsx (1)

26-39: Remove or complete the drag scaffolding.

isDragging, dragCleanupRef, and rafDragRef are currently unused in behavior, so Line 35-38 cleanup is effectively a no-op. Either implement drag lifecycle or remove the dead path to reduce maintenance noise.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/start/src/components/sessions/replay/replay-timeline.tsx` around lines
26 - 39, The unused drag scaffolding (isDragging, setIsDragging, dragCleanupRef,
rafDragRef and the useEffect cleanup) should be either removed or completed:
either delete the state/refs and the cleanup effect to eliminate dead code, or
implement the drag lifecycle by wiring mousedown/touchstart to
setIsDragging(true), adding mousemove/touchmove listeners that use rafDragRef
for requestAnimationFrame updates and update hoverInfo, and store a cleanup
function on dragCleanupRef that removes the listeners and cancels rafDragRef;
ensure the existing useEffect cleanup calls dragCleanupRef.current() to remove
listeners on unmount.
apps/start/src/components/sessions/replay/index.tsx (1)

22-42: Avoid sorting all events on every 250ms URL update.

Line 27-33 rebuilds and sorts the whole list whenever currentTime changes. Precompute sorted offsets once (on events/startTime) and use binary search per tick to keep updates cheap.

💡 Proposed refactor
+  const sortedWithOffset = useMemo(() => {
+    if (startTime == null || !events.length) return [];
+    return events
+      .map((event) => ({ event, offsetMs: getEventOffsetMs(event, startTime) }))
+      .filter(({ offsetMs }) => offsetMs >= -10_000)
+      .sort((a, b) => a.offsetMs - b.offsetMs);
+  }, [events, startTime]);
+
   const currentUrl = useMemo(() => {
-    if (startTime == null || !events.length) {
+    if (!sortedWithOffset.length) {
       return '';
     }
-
-    const withOffset = events
-      .map((ev) => ({
-        event: ev,
-        offsetMs: getEventOffsetMs(ev, startTime),
-      }))
-      .filter(({ offsetMs }) => offsetMs >= -10_000 && offsetMs <= currentTime)
-      .sort((a, b) => a.offsetMs - b.offsetMs);
-
-    const latest = withOffset.at(-1);
+    let lo = 0;
+    let hi = sortedWithOffset.length;
+    while (lo < hi) {
+      const mid = (lo + hi) >>> 1;
+      if ((sortedWithOffset[mid]?.offsetMs ?? 0) <= currentTime) lo = mid + 1;
+      else hi = mid;
+    }
+    const latest = sortedWithOffset[lo - 1];
     if (!latest) {
       return '';
     }
@@
-  }, [events, currentTime, startTime]);
+  }, [currentTime, sortedWithOffset]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/start/src/components/sessions/replay/index.tsx` around lines 22 - 42,
Precompute and sort event offsets once instead of rebuilding on every
currentTime tick: create a useMemo (dependent on events and startTime) that maps
events to {offsetMs,event}, filters offsetMs >= -10_000, sorts by offsetMs and
returns that sortedOffsets array; then change the currentUrl useMemo to only
perform a binary search on sortedOffsets (using getEventOffsetMs-derived
offsetMs stored in the precomputed entries) to find the latest entry with
offsetMs <= currentTime and return its origin+path (falling back to ''/default
path as before). Reference: useMemo that computes currentUrl, the withOffset
logic, getEventOffsetMs, and variables events/startTime/currentTime.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 38d9b65 and d5513d8.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (64)
  • .claude/CLAUDE.md
  • .claude/notnow-settings.local.json
  • apps/api/src/controllers/event.controller.ts
  • apps/api/src/controllers/track.controller.ts
  • apps/api/src/hooks/duplicate.hook.ts
  • apps/api/src/hooks/request-logging.hook.ts
  • apps/api/src/index.ts
  • apps/api/src/routes/track.router.ts
  • apps/api/src/utils/ids.ts
  • apps/public/content/docs/(tracking)/sdks/astro.mdx
  • apps/public/content/docs/(tracking)/sdks/nextjs.mdx
  • apps/public/content/guides/nextjs-analytics.mdx
  • apps/public/public/op1-replay.js
  • apps/public/public/op1.bak.js
  • apps/public/public/op1.js
  • apps/public/src/app/layout.tsx
  • apps/start/package.json
  • apps/start/src/components/events/table/columns.tsx
  • apps/start/src/components/sessions/replay/browser-chrome.tsx
  • apps/start/src/components/sessions/replay/index.tsx
  • apps/start/src/components/sessions/replay/replay-context.tsx
  • apps/start/src/components/sessions/replay/replay-controls.tsx
  • apps/start/src/components/sessions/replay/replay-event-feed.tsx
  • apps/start/src/components/sessions/replay/replay-event-item.tsx
  • apps/start/src/components/sessions/replay/replay-player.tsx
  • apps/start/src/components/sessions/replay/replay-timeline.tsx
  • apps/start/src/components/sessions/replay/replay-utils.ts
  • apps/start/src/components/sessions/table/columns.tsx
  • apps/start/src/components/ui/scroll-area.tsx
  • apps/start/src/hooks/use-page-tabs.ts
  • apps/start/src/modals/event-details.tsx
  • apps/start/src/routeTree.gen.ts
  • apps/start/src/routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.sessions.tsx
  • apps/start/src/routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.tsx
  • apps/start/src/routes/_app.$organizationId.$projectId.sessions_.$sessionId.tsx
  • apps/start/src/types/rrweb-player.d.ts
  • apps/start/src/utils/op.ts
  • apps/worker/src/boot-cron.ts
  • apps/worker/src/jobs/cron.ts
  • apps/worker/src/jobs/events.incoming-event.ts
  • apps/worker/src/jobs/events.incoming-events.test.ts
  • apps/worker/src/metrics.ts
  • apps/worker/src/utils/session-handler.ts
  • packages/common/src/id.ts
  • packages/db/code-migrations/10-add-session-replay.ts
  • packages/db/code-migrations/migrate.ts
  • packages/db/src/buffers/index.ts
  • packages/db/src/buffers/replay-buffer.ts
  • packages/db/src/buffers/session-buffer.ts
  • packages/db/src/clickhouse/client.ts
  • packages/db/src/services/session.service.ts
  • packages/queue/src/queues.ts
  • packages/sdks/astro/src/OpenPanelComponent.astro
  • packages/sdks/nextjs/index.tsx
  • packages/sdks/sdk/src/index.ts
  • packages/sdks/web/package.json
  • packages/sdks/web/src/index.ts
  • packages/sdks/web/src/replay/index.ts
  • packages/sdks/web/src/replay/recorder.ts
  • packages/sdks/web/tsup.config.ts
  • packages/trpc/src/routers/session.ts
  • packages/validation/src/track.validation.ts
  • test.ts
  • tooling/publish/publish.ts
💤 Files with no reviewable changes (1)
  • test.ts

Comment thread apps/api/src/controllers/track.controller.ts Outdated
Comment thread apps/api/src/utils/ids.ts
Comment thread apps/start/src/components/sessions/replay/index.tsx
Comment thread apps/start/src/components/sessions/replay/replay-player.tsx
Comment thread apps/start/src/components/sessions/replay/replay-timeline.tsx
Comment thread packages/sdks/web/src/index.ts
Comment thread packages/sdks/web/src/replay/recorder.ts
Comment thread packages/sdks/web/src/replay/recorder.ts Outdated
Comment thread packages/sdks/web/src/replay/recorder.ts
Comment thread packages/validation/src/track.validation.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 15

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/worker/src/jobs/events.incoming-event.ts (1)

169-173: ⚠️ Potential issue | 🟠 Major

Use incoming identifiers as fallback when no cached session exists.

In the server/past-event branch, Line 171 and Line 172 currently default to empty strings. If no session is found, this drops provided deviceId/sessionId and can orphan event-session linkage.

🛠️ Suggested fix
     const payload = {
       ...baseEvent,
-      deviceId: session?.device_id ?? '',
-      sessionId: session?.id ?? '',
+      deviceId: session?.device_id ?? deviceId,
+      sessionId: session?.id ?? sessionId,
       referrer: session?.referrer ?? undefined,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/worker/src/jobs/events.incoming-event.ts` around lines 169 - 173, The
payload construction is overwriting incoming device/session IDs with empty
strings when session is missing; update the deviceId and sessionId assignments
in the payload to fall back to the incoming identifiers from baseEvent before
defaulting to ''. Specifically, change the deviceId and sessionId expressions
used when building payload (currently using session?.device_id ?? '' and
session?.id ?? '') to session?.device_id ?? baseEvent.deviceId ?? '' and
session?.id ?? baseEvent.sessionId ?? '' so the incoming IDs are preserved when
no cached session exists.
packages/sdks/nextjs/index.tsx (1)

168-177: ⚠️ Potential issue | 🟡 Minor

Inconsistent optional chaining usage.

The decrement, fetchDeviceId, and getDeviceId functions access window.op without optional chaining (?.), while other functions like track, identify, and clear use window.op?.(). This inconsistency could cause runtime errors if window.op is undefined.

💡 Suggested fix
 function decrement(payload: DecrementPayload) {
-  window.op('decrement', payload);
+  window.op?.('decrement', payload);
 }

 function fetchDeviceId() {
-  return window.op.fetchDeviceId();
+  return window.op?.fetchDeviceId();
 }
 function getDeviceId() {
-  return window.op.getDeviceId();
+  return window.op?.getDeviceId();
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/sdks/nextjs/index.tsx` around lines 168 - 177, The functions
decrement, fetchDeviceId, and getDeviceId call window.op directly and should use
optional chaining like the other functions to avoid runtime errors when
window.op is undefined; update decrement (function decrement(payload:
DecrementPayload)), fetchDeviceId (function fetchDeviceId()), and getDeviceId
(function getDeviceId()) to call window.op?.decrement(...),
window.op?.fetchDeviceId(), and window.op?.getDeviceId() respectively so calls
are no-ops when window.op is missing.
♻️ Duplicate comments (10)
apps/worker/src/utils/session-handler.ts (1)

137-154: ⚠️ Potential issue | 🟠 Major

Legacy fallback lookup should not require both IDs.

The condition on line 138 requires both currentDeviceId AND previousDeviceId to be truthy. If only one ID is provided (non-empty), the legacy lookup is skipped entirely, potentially missing valid session-end jobs.

The checks should be separated to run independently:

🔧 Proposed fix
-  // TODO: Remove this when migrated to deviceId
-  if (args.currentDeviceId && args.previousDeviceId) {
-    // Check current device job
-    const currentJob = await sessionsQueue.getJob(
-      getSessionEndJobId(args.projectId, args.currentDeviceId),
-    );
-    if (currentJob) {
-      return await handleJobStates(currentJob, args.currentDeviceId);
-    }
-
-    // Check previous device job
-    const previousJob = await sessionsQueue.getJob(
-      getSessionEndJobId(args.projectId, args.previousDeviceId),
-    );
-    if (previousJob) {
-      return await handleJobStates(previousJob, args.previousDeviceId);
-    }
-  }
+  // TODO: Remove this when migrated to deviceId
+  if (args.currentDeviceId) {
+    const currentJob = await sessionsQueue.getJob(
+      getSessionEndJobId(args.projectId, args.currentDeviceId),
+    );
+    if (currentJob) {
+      return await handleJobStates(currentJob, args.currentDeviceId);
+    }
+  }
+
+  if (args.previousDeviceId) {
+    const previousJob = await sessionsQueue.getJob(
+      getSessionEndJobId(args.projectId, args.previousDeviceId),
+    );
+    if (previousJob) {
+      return await handleJobStates(previousJob, args.previousDeviceId);
+    }
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/worker/src/utils/session-handler.ts` around lines 137 - 154, The legacy
fallback currently requires both args.currentDeviceId and args.previousDeviceId
to be truthy before checking session-end jobs, which skips lookup when only one
ID is present; update the logic in session-handler.ts to check each ID
independently: if args.currentDeviceId is present, call
sessionsQueue.getJob(getSessionEndJobId(args.projectId, args.currentDeviceId))
and pass any found job to handleJobStates(args.currentDeviceId); separately, if
args.previousDeviceId is present, call
sessionsQueue.getJob(getSessionEndJobId(args.projectId, args.previousDeviceId))
and pass any found job to handleJobStates(args.previousDeviceId); keep the same
await/return behavior when a job is handled.
packages/sdks/web/src/index.ts (1)

135-138: ⚠️ Potential issue | 🟠 Major

__OPENPANEL_REPLAY_URL__ is checked but never used in URL construction.

The build-time injected constant is checked for existence at line 135, but line 137 never actually uses its value. The URL fallback chain should include __OPENPANEL_REPLAY_URL__.

💡 Suggested fix
       if (typeof __OPENPANEL_REPLAY_URL__ !== 'undefined') {
         const scriptEl = document.currentScript as HTMLScriptElement | null;
-        const url = this.options.sessionReplay?.scriptUrl || scriptEl?.src?.replace('.js', '-replay.js') || 'https://openpanel.dev/op1-replay.js';
+        const url =
+          this.options.sessionReplay?.scriptUrl ??
+          __OPENPANEL_REPLAY_URL__ ??
+          scriptEl?.src?.replace('.js', '-replay.js') ??
+          'https://openpanel.dev/op1-replay.js';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/sdks/web/src/index.ts` around lines 135 - 138, The code checks
__OPENPANEL_REPLAY_URL__ but never uses it when constructing the replay script
URL; update the url construction in the block that sets the url variable (using
this.options.sessionReplay?.scriptUrl, document.currentScript?.src replacement,
and the default) to include __OPENPANEL_REPLAY_URL__ as a higher-priority
fallback (e.g., before the default) so the build-time injected constant is
honored when present.
apps/start/src/modals/event-details.tsx (1)

211-213: ⚠️ Potential issue | 🟠 Major

Add an accessible name to the close icon button (still unresolved).

At Line 211-213, the icon-only button still has no accessible name; screen readers won’t announce its action.

As per coding guidelines, "Use semantic HTML and ARIA attributes for accessibility".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/start/src/modals/event-details.tsx` around lines 211 - 213, The Button
with the XIcon in event-details.tsx (the onClick calling popModal) is icon-only
and lacks an accessible name; add an accessible label (e.g., aria-label or
title) to the Button component so screen readers announce its purpose (for
example aria-label="Close" or aria-label="Close event details"), keeping the
existing onClick={ () => popModal() } and the XIcon intact.
packages/sdks/sdk/src/index.ts (1)

98-112: ⚠️ Potential issue | 🟠 Major

Wrap send() network dispatch in try/catch and avoid inline flush recursion.

At Line 99, a rejected this.api.fetch(...) currently bubbles out of send(). Also, triggering flush() inline at Line 109 can recursively start more sends in the same call path. Please handle failures locally and schedule flush asynchronously after first session acquisition.

Proposed fix
-    // Disable keepalive for replay since it has a hard body limit and breaks the request
-    const result = await this.api.fetch<
-      TrackHandlerPayload,
-      { deviceId: string; sessionId: string }
-    >('/track', payload, { keepalive: payload.type !== 'replay' });
-    this.deviceId = result?.deviceId;
-    const hadSession = !!this.sessionId;
-    this.sessionId = result?.sessionId;
-
-    // Flush queued items (e.g. replay chunks) when sessionId first arrives
-    if (!hadSession && this.sessionId) {
-      this.flush();
-    }
-
-    return result;
+    try {
+      // Disable keepalive for replay since it has a hard body limit and breaks the request
+      const result = await this.api.fetch<
+        TrackHandlerPayload,
+        { deviceId: string; sessionId: string }
+      >('/track', payload, { keepalive: payload.type !== 'replay' });
+      this.deviceId = result?.deviceId;
+      const hadSession = Boolean(this.sessionId);
+      this.sessionId = result?.sessionId;
+
+      // Flush queued items (e.g. replay chunks) when sessionId first arrives
+      if (!hadSession && this.sessionId) {
+        queueMicrotask(() => {
+          void this.flush();
+        });
+      }
+
+      return result;
+    } catch (error) {
+      this.log('Failed to send payload', error);
+      return undefined;
+    }

As per coding guidelines: "Handle errors appropriately in async code with try-catch blocks."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/sdks/sdk/src/index.ts` around lines 98 - 112, The network dispatch
in send() must be wrapped in try/catch so a rejected this.api.fetch(...) does
not bubble out; call this.api.fetch inside a try block, handle/log the error in
the catch, and ensure send() returns a sensible value (e.g., undefined or an
error result) instead of throwing. Also avoid invoking this.flush()
synchronously inside the same call path after setting this.sessionId — schedule
the flush asynchronously (e.g., via setTimeout(..., 0) or queueMicrotask) when
!hadSession && this.sessionId so subsequent sends aren’t recursively re-entered;
keep references to this.api.fetch, this.deviceId, this.sessionId, and flush()
when applying the changes.
apps/start/src/components/sessions/replay/index.tsx (1)

65-93: ⚠️ Potential issue | 🟠 Major

Fix stale-closure effect and unsafe recursive chunk loading.

At Line 65, the empty dependency array captures mutable values (sessionId, projectId, fromIndex, queryClient, addEvent, refreshDuration, trpc), and Line 83 can recurse indefinitely when hasMore is true but res.data is empty. There’s also no cancellation guard for unmount/session changes.

💡 Proposed fix
   useEffect(() => {
-    function recursive(fromIndex: number) {
-      queryClient
-        .fetchQuery(
-          trpc.session.replayChunksFrom.queryOptions({
-            sessionId,
-            projectId,
-            fromIndex,
-          })
-        )
-        .then((res) => {
-          res.data.forEach((row) => {
-            row?.events?.forEach((event) => {
-              addEvent(event);
-            });
-          });
-          refreshDuration();
-          if (res.hasMore) {
-            recursive(fromIndex + res.data.length);
-          }
-        })
-        .catch(() => {
-          // chunk loading failed — replay may be incomplete
-        });
-    }
-
-    recursive(fromIndex);
-  }, []);
+    let cancelled = false;
+
+    const loadRemainingChunks = async () => {
+      let nextIndex = fromIndex;
+      try {
+        while (!cancelled) {
+          const res = await queryClient.fetchQuery(
+            trpc.session.replayChunksFrom.queryOptions({
+              sessionId,
+              projectId,
+              fromIndex: nextIndex,
+            }),
+          );
+
+          for (const row of res.data) {
+            for (const event of row?.events ?? []) {
+              addEvent(event);
+            }
+          }
+          refreshDuration();
+
+          if (!res.hasMore || res.data.length === 0) break;
+          nextIndex += res.data.length;
+        }
+      } catch {
+        // chunk loading failed — replay may be incomplete
+      }
+    };
+
+    void loadRemainingChunks();
+    return () => {
+      cancelled = true;
+    };
+  }, [addEvent, fromIndex, projectId, queryClient, refreshDuration, sessionId, trpc]);

Based on learnings, dependencies in hook dependency arrays should be specified correctly. As per coding guidelines, "Specify all dependencies in hook dependency arrays correctly" and "Use async/await syntax instead of promise chains for better readability."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/start/src/components/sessions/replay/index.tsx` around lines 65 - 93,
The useEffect with the inner recursive function captures stale values and can
recurse forever when res.hasMore is true but res.data is empty, and it lacks
cancellation on unmount or param changes; update useEffect to include all
referenced dependencies (sessionId, projectId, fromIndex, queryClient, addEvent,
refreshDuration, trpc), convert the fetchQuery promise chain to an async
function using async/await, replace recursion with a safe loop that breaks if
res.data.length === 0 to avoid infinite loops, and add an abort/cancel flag (or
AbortController) checked before processing results so work stops when the
component unmounts or session/project changes; keep the same identifiers
(recursive function name or replace with a loop inside the effect,
trpc.session.replayChunksFrom.queryOptions, addEvent, refreshDuration,
queryClient.fetchQuery) so reviewers can locate and verify the fix.
packages/db/src/buffers/replay-buffer.ts (1)

36-43: ⚠️ Potential issue | 🟠 Major

Validate UInt16-backed fields before enqueueing replay chunks.

Line 36-43 accepts unbounded chunk_index/events_count, but the schema defines both as UInt16 (see packages/db/code-migrations/10-add-session-replay.ts Line 21 and Line 24). Out-of-range values can repeatedly fail inserts and block healthy batch progress.

🛠️ Suggested fix
+const MAX_UINT16 = 65_535 as const;
+
 export class ReplayBuffer extends BaseBuffer {
@@
   async add(chunk: IClickhouseSessionReplayChunk) {
     try {
+      const isChunkIndexValid =
+        Number.isInteger(chunk.chunk_index) &&
+        chunk.chunk_index >= 0 &&
+        chunk.chunk_index <= MAX_UINT16;
+      const isEventsCountValid =
+        Number.isInteger(chunk.events_count) &&
+        chunk.events_count >= 0 &&
+        chunk.events_count <= MAX_UINT16;
+
+      if (!isChunkIndexValid || !isEventsCountValid) {
+        throw new Error(
+          `Invalid replay chunk numeric bounds: chunk_index=${chunk.chunk_index}, events_count=${chunk.events_count}`,
+        );
+      }
+
       const redis = getRedisCache();
#!/bin/bash
set -euo pipefail

echo "Schema constraints (should show UInt16 fields):"
rg -n "chunk_index|events_count|UInt16" packages/db/code-migrations/10-add-session-replay.ts

echo
echo "ReplayBuffer numeric guards (should currently be missing):"
rg -n "MAX_UINT16|65535|Number\\.isInteger\\(chunk\\.(chunk_index|events_count)\\)" packages/db/src/buffers/replay-buffer.ts || true
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/db/src/buffers/replay-buffer.ts` around lines 36 - 43, The add
method is pushing IClickhouseSessionReplayChunk values without validating
chunk_index and events_count against the UInt16 schema; add a guard at the start
of replay-buffer.add that checks Number.isInteger(chunk.chunk_index) and
Number.isInteger(chunk.events_count) and that both are within 0..65535 (use a
MAX_UINT16 constant = 65535), and if validation fails do NOT call
redis.multi()/rpush/incr/llen—instead log or throw a clear error and return so
the invalid chunk isn't enqueued or counted.
packages/validation/src/track.validation.ts (1)

66-73: ⚠️ Potential issue | 🟠 Major

Replay time-window ordering validation is still missing.

Line 70 and Line 71 validate datetime format, but payloads with ended_at < started_at still pass.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/validation/src/track.validation.ts` around lines 66 - 73,
zReplayPayload currently only checks datetime format but allows ended_at <
started_at; add a refinement to zReplayPayload (using .refine or .superRefine on
zReplayPayload) that parses/compares started_at and ended_at (convert the
z.string().datetime() values to Date or use a preprocess/transform) and rejects
the object if ended_at is earlier than started_at, returning a clear validation
error referencing started_at/ended_at.
packages/db/src/services/session.service.ts (3)

359-367: ⚠️ Potential issue | 🟠 Major

Return persisted replay chunk indexes.

chunkIndex: index + fromIndex can drift from stored chunk_index when rows are sparse/reordered.

🛠️ Proposed fix
-      .map((row, index) => {
+      .map((row) => {
         const events = getSafeJson<
           { type: number; data: unknown; timestamp: number }[]
         >(row.payload);
         if (!events) {
           return null;
         }
-        return { chunkIndex: index + fromIndex, events };
+        return { chunkIndex: row.chunk_index, events };
       })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/db/src/services/session.service.ts` around lines 359 - 367, The
mapped chunkIndex is currently computed as index + fromIndex which can drift
when DB rows are sparse/reordered; instead read the persisted chunk index from
the row (e.g., use row.chunk_index or the actual column name holding the stored
chunk index) and use that value when returning { chunkIndex, events }; ensure
you parse/coerce it to a number (and still return null when events is falsy) so
the returned chunkIndex always matches the stored DB value in the
session.service.ts mapping block.

241-242: ⚠️ Potential issue | 🟠 Major

Align replay alias with transformer field.

transformSession() reads session.has_replay, but this select aliases to hasReplay. That can yield hasReplay: undefined in list responses.

🛠️ Proposed fix
-  sb.select.has_replay = `toBool(src.session_id != '') as hasReplay`;
+  sb.select.has_replay = `toBool(src.session_id != '') as has_replay`;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/db/src/services/session.service.ts` around lines 241 - 242, The
SELECT alias for replay is mismatched with transformSession:
sb.select.has_replay currently sets `toBool(src.session_id != '') as hasReplay`
but transformSession expects session.has_replay; update the alias to
`has_replay` (i.e., `toBool(src.session_id != '') as has_replay`) in
sb.select.has_replay so the query result maps correctly to transformSession,
leaving the JOIN (sb.joins.has_replay) and session_replay_chunks subquery
unchanged.

346-354: 🛠️ Refactor suggestion | 🟠 Major

Use the ClickHouse query builder for new replay queries.

The newly added replay queries are raw SQL strings. Please migrate these to the project ClickHouse query builder/query-functions stack.

As per coding guidelines **/*.{ts,tsx,js,jsx}: “When writing ClickHouse queries, always use the custom query builder from ./packages/db/src/clickhouse/query-builder.ts and ./packages/db/src/clickhouse/query-functions.ts”.

Also applies to: 385-391

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/db/src/services/session.service.ts` around lines 346 - 354, Replace
the raw SQL string used in chQuery in session.service.ts with the project's
ClickHouse query builder/query-functions: stop using string interpolation and
sqlstring.escape for sessionId/projectId and instead import and use the builders
from packages/db/src/clickhouse/query-builder.ts and query-functions.ts to
construct the SELECT of chunk_index and payload from
TABLE_NAMES.session_replay_chunks, add WHERE clauses for session_id and
project_id using parameterized equality helpers, apply ORDER BY started_at,
ended_at, chunk_index, and apply LIMIT (REPLAY_CHUNKS_PAGE_SIZE + 1) and OFFSET
(fromIndex) via the builder before passing the generated query to chQuery; make
the same change for the similar block at lines 385-391 to ensure all replay
queries use the query-builder/query-functions stack.
🧹 Nitpick comments (13)
packages/common/src/id.ts (1)

7-8: Consider adding an explicit return type and validating length edge cases.

The implementation is correct and maintains backward compatibility. A few optional improvements:

  1. Per coding guidelines, adding an explicit return type enhances clarity.
  2. If length is passed as 0 or a negative value, nanoid may produce unexpected results (empty or invalid string).
✨ Optional improvement with return type and length validation
-export function generateId(prefix?: string, length?: number) {
-  return prefix ? `${prefix}_${nanoid(length ?? 8)}` : nanoid(length ?? 8);
+const DEFAULT_ID_LENGTH = 8;
+
+export function generateId(prefix?: string, length?: number): string {
+  const idLength = length && length > 0 ? length : DEFAULT_ID_LENGTH;
+  return prefix ? `${prefix}_${nanoid(idLength)}` : nanoid(idLength);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/common/src/id.ts` around lines 7 - 8, Add an explicit return type to
generateId (e.g., string) and validate the length parameter before passing it to
nanoid: ensure length is a positive integer (treat undefined as default, coerce
or clamp values <= 0 to a sensible default like 1 or fall back to undefined) so
nanoid never receives 0 or negative values; keep the current prefix logic using
generateId and nanoid names to locate the function and update its signature and
parameter handling accordingly.
packages/sdks/web/src/replay/recorder.ts (3)

85-88: Consider removing console.error before rethrowing.

Per coding guidelines, console.log, debugger, and alert should be removed from production code. Logging then rethrowing causes double-logging if callers also handle errors. Either log and swallow (for resilience) or rethrow without logging (let caller decide).

💡 Suggested alternatives

Option A – Silent rethrow (let caller log):

     } catch (err) {
-      console.error('[ReplayRecorder] sendChunk failed', err);
       throw err;
     }

Option B – Log and retain buffer for retry (no throw):

     } catch (err) {
-      console.error('[ReplayRecorder] sendChunk failed', err);
-      throw err;
+      // Keep buffer intact for next flush attempt
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/sdks/web/src/replay/recorder.ts` around lines 85 - 88, In the catch
block inside sendChunk in replay/recorder.ts, remove the direct console.error
call so errors are not double-logged; simply rethrow the caught error (throw
err) and let callers decide how to log/handle it (alternatively implement a
retry/retain buffer strategy elsewhere if you need in-recorder resilience),
ensuring the change is made in the catch handler of the sendChunk function.

66-68: Silently dropping oversized single events may complicate debugging.

When a single event exceeds maxPayloadBytes, it's dropped without any indication. Consider adding a warning to help diagnose issues with very large DOM snapshots.

💡 Suggested fix
       // Single event exceeds limit — drop it to avoid server rejection
+      console.warn('[ReplayRecorder] Dropping oversized event', buffer[0]?.type);
       buffer = [];
       return;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/sdks/web/src/replay/recorder.ts` around lines 66 - 68, The code
currently clears buffer and returns when a single event exceeds maxPayloadBytes,
dropping it silently; update the branch that handles "Single event exceeds
limit" so it logs a warning (include the event's byte size and the configured
maxPayloadBytes) before clearing buffer and returning—refer to the buffer
variable and maxPayloadBytes constant/parameter in recorder.ts and use the
established logging mechanism (e.g., a logger instance or console.warn) so
oversized DOM snapshots are visible in logs for debugging.

92-94: Consider extracting magic number 2 to a named constant.

The comment clarifies that 2 is EventType.FullSnapshot, but extracting it improves readability and reduces duplication across lines 59, 94, 117, 124, 131, and 142.

💡 Suggested fix
+const FULL_SNAPSHOT_EVENT_TYPE = 2; // rrweb EventType.FullSnapshot
+
 export function startReplayRecorder(
   ...
-      buffer.some((e) => e.type === 2); /* EventType.FullSnapshot */
+      buffer.some((e) => e.type === FULL_SNAPSHOT_EVENT_TYPE);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/sdks/web/src/replay/recorder.ts` around lines 92 - 94, Replace the
magic literal 2 with a named constant by importing or declaring the EventType
enum (or a constant) and using EventType.FullSnapshot wherever the code
currently uses 2 (e.g., the isFullSnapshot calculation that uses buffer.some((e)
=> e.type === 2)). Update all other occurrences mentioned in the review (the
spots around the checks at the same conceptual places) to reference
EventType.FullSnapshot so the intent is explicit and duplicates are removed;
ensure the import or declaration is added near the top of recorder.ts and all
comparisons use the named symbol instead of the numeric literal.
packages/sdks/web/src/types.debug.ts (1)

33-33: Missing test for getSessionId.

The type definitions in types.d.ts added both getDeviceId and getSessionId to ExposedMethodsNames, but only getDeviceId is tested here. Consider adding a test call for getSessionId to maintain consistency and verify the type works correctly.

♻️ Proposed fix
   window.op.fetchDeviceId();
   window.op.getDeviceId();
+  window.op.getSessionId();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/sdks/web/src/types.debug.ts` at line 33, Add a test invocation for
getSessionId alongside the existing getDeviceId call to verify the
ExposedMethodsNames type; update the debug/types test file (where
window.op.getDeviceId() is called) to also call window.op.getSessionId() so both
newly added methods are exercised by the type-check.
apps/start/src/components/sessions/replay/replay-context.tsx (1)

179-198: Consider memoizing the context value object.

The value object is recreated on every render. While most properties are stable callbacks (via useCallback), the object identity itself changes, which could cause unnecessary re-renders in deeply nested consumers that depend on the entire context.

♻️ Proposed refactor using useMemo
+  const value: ReplayContextValue = useMemo(
+    () => ({
+      currentTimeRef,
+      subscribeToCurrentTime,
+      isPlaying,
+      duration,
+      startTime,
+      isReady,
+      play,
+      pause,
+      toggle,
+      seek,
+      setSpeed,
+      addEvent,
+      refreshDuration,
+      onPlayerReady,
+      onPlayerDestroy,
+      setCurrentTime,
+      setIsPlaying: setIsPlayingWithRef,
+      setDuration,
+    }),
+    [
+      isPlaying,
+      duration,
+      startTime,
+      isReady,
+      play,
+      pause,
+      toggle,
+      seek,
+      setSpeed,
+      addEvent,
+      refreshDuration,
+      onPlayerReady,
+      onPlayerDestroy,
+      setCurrentTime,
+      setIsPlayingWithRef,
+      setDuration,
+      subscribeToCurrentTime,
+    ],
+  );
-  const value: ReplayContextValue = {
-    currentTimeRef,
-    subscribeToCurrentTime,
-    isPlaying,
-    duration,
-    startTime,
-    isReady,
-    play,
-    pause,
-    toggle,
-    seek,
-    setSpeed,
-    addEvent,
-    refreshDuration,
-    onPlayerReady,
-    onPlayerDestroy,
-    setCurrentTime,
-    setIsPlaying: setIsPlayingWithRef,
-    setDuration,
-  };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/start/src/components/sessions/replay/replay-context.tsx` around lines
179 - 198, The context value object named value (of type ReplayContextValue) is
recreated on every render which can trigger unnecessary re-renders in consumers;
wrap the construction of value in a useMemo so its identity only changes when
any of its dependencies change (include currentTimeRef, subscribeToCurrentTime,
isPlaying, duration, startTime, isReady, play, pause, toggle, seek, setSpeed,
addEvent, refreshDuration, onPlayerReady, onPlayerDestroy, setCurrentTime,
setIsPlayingWithRef, setDuration) to stabilize the object identity and avoid
extra renders.
apps/start/src/components/sessions/replay/browser-chrome.tsx (1)

17-17: Clarify the url prop type for the "hide URL bar" behavior.

The url !== false check on line 32 implies false has special semantics (hide the URL bar), but this isn't obvious from the ReactNode type. Consider using a more explicit union type.

♻️ Proposed type clarification
 }: {
-  url?: ReactNode;
+  url?: ReactNode | false;  // Pass `false` to hide the URL bar entirely
   children: ReactNode;

Note: ReactNode technically includes false, so this is purely for documentation clarity.

Also applies to: 32-32

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/start/src/components/sessions/replay/browser-chrome.tsx` at line 17, The
prop declaration for url should explicitly include false to document the "hide
URL bar" semantics: change the type from url?: ReactNode to url?: ReactNode |
false in the BrowserChrome component's props (and any related
prop/interface/type aliases), keep the existing runtime check (url !== false)
unchanged, and update any JSDoc or prop comments for BrowserChrome to state that
false hides the URL bar.
apps/start/src/components/sessions/replay/replay-event-feed.tsx (1)

86-101: Consider extracting skeleton count as a constant.

Minor readability improvement — the magic number 5 could be a named constant.

♻️ Proposed refactor
+const SKELETON_COUNT = 5;
+
 export function ReplayEventFeed({ events, replayLoading }: { events: IServiceEvent[]; replayLoading: boolean }) {
           {replayLoading &&
-            Array.from({ length: 5 }).map((_, i) => (
+            Array.from({ length: SKELETON_COUNT }).map((_, i) => (
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/start/src/components/sessions/replay/replay-event-feed.tsx` around lines
86 - 101, The skeleton list uses a magic number 5; extract that into a named
constant (e.g., SKELETON_COUNT) and replace Array.from({ length: 5 }) with
Array.from({ length: SKELETON_COUNT }) to improve readability; add the constant
near the top of the component (or module) so replayLoading rendering code (the
block using Array.from, key={i}, and the animated skeleton divs) references
SKELETON_COUNT instead of the literal 5.
packages/db/src/buffers/session-buffer.ts (1)

188-195: Extract the Redis session TTL into a named constant.

Line 188 and Line 195 repeat 60 * 60. Please lift this into a descriptive constant to avoid magic numbers and keep TTL changes single-source.

♻️ Suggested refactor
 export class SessionBuffer extends BaseBuffer {
+  private static readonly SESSION_CACHE_TTL_SECONDS = 60 * 60 as const;
@@
       multi.set(
         `session:${newSession.id}`,
         JSON.stringify(newSession),
         'EX',
-        60 * 60
+        SessionBuffer.SESSION_CACHE_TTL_SECONDS
       );
@@
         multi.set(
           `session:${newSession.project_id}:${newSession.profile_id}`,
           JSON.stringify(newSession),
           'EX',
-          60 * 60
+          SessionBuffer.SESSION_CACHE_TTL_SECONDS
         );

As per coding guidelines, **/*.{js,ts,jsx,tsx}: "Use meaningful variable names instead of magic numbers - extract constants with descriptive names".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/db/src/buffers/session-buffer.ts` around lines 188 - 195, The code
repeats the literal TTL value 60 * 60 when setting Redis keys (seen around the
use of multi.set with newSession and keys like
`session:${newSession.project_id}:${newSession.profile_id}`); extract this into
a descriptive constant (e.g., SESSION_TTL_SECONDS = 60 * 60) at the top of the
module and replace both occurrences with that constant so TTL changes are
single-source and remove the magic numbers.
packages/db/src/buffers/index.ts (1)

13-16: Avoid adding new re-exports to the barrel index.ts.

Line 13 and Line 16 expand a barrel file. Prefer importing ReplayBuffer/IClickhouseSessionReplayChunk directly from ./replay-buffer at usage sites to keep module boundaries explicit.

As per coding guidelines, **/index.{js,ts,jsx,tsx}: "Avoid barrel files (index files that re-export everything)".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/db/src/buffers/index.ts` around lines 13 - 16, The barrel index.ts
currently re-exports a ReplayBuffer instance (replayBuffer) and types
(IClickhouseSessionReplayChunk), which violates the guideline to avoid expanding
barrel files; remove the exported const and type re-exports from this file and
instead import ReplayBuffer/IClickhouseSessionReplayChunk (and
ProfileBackfillEntry) directly from their source modules (./replay-buffer and
./profile-backfill-buffer) at every usage site; update all files that currently
import from this barrel to reference the original modules and delete the two
export lines (export const replayBuffer = new ReplayBuffer(); and the export
type {...} lines) from packages/db/src/buffers/index.ts.
apps/start/src/routes/_app.$organizationId.$projectId.sessions_.$sessionId.tsx (1)

327-332: Potential type mismatch in paths mapping.

The filter ensures e.path is truthy, but TypeScript may still infer e.path as string | undefined. Consider using a type guard or non-null assertion after the filter.

💡 Suggested fix
           <VisitedRoutes
             paths={events
               .filter((e) => e.name === 'screen_view' && e.path)
-              .map((e) => e.path)}
+              .map((e) => e.path!)}
           />

Or use a type-narrowing filter:

           <VisitedRoutes
-            paths={events
-              .filter((e) => e.name === 'screen_view' && e.path)
-              .map((e) => e.path)}
+            paths={events
+              .filter((e): e is typeof e & { path: string } => e.name === 'screen_view' && !!e.path)
+              .map((e) => e.path)}
           />
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/start/src/routes/_app`.$organizationId.$projectId.sessions_.$sessionId.tsx
around lines 327 - 332, VisitedRoutes is receiving paths mapped from events
where e.path may still be inferred as string | undefined; narrow the type before
mapping by adding a type guard or non-null assertion so map yields string[]
only. For example, change the filter on events (used to build paths for
VisitedRoutes) to a type-narrowing predicate like filter((e): e is { path:
string } => !!e.path) or keep the existing filter and use .map(e => e.path!) to
assert non-null, ensuring VisitedRoutes receives string[] instead of (string |
undefined)[].
apps/start/src/routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.sessions.tsx (1)

23-23: Consider naming the page size constant.

take: 50 would be easier to maintain as a named constant (e.g., SESSIONS_PAGE_SIZE).

As per coding guidelines "Use meaningful variable names instead of magic numbers - extract constants with descriptive names".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/start/src/routes/_app`.$organizationId.$projectId.profiles.$profileId._tabs.sessions.tsx
at line 23, Replace the magic number 50 with a named constant: declare a
descriptive constant (e.g., SESSIONS_PAGE_SIZE = 50) near the top of this module
(or exported from a constants file) and use that constant in the query/options
object where you currently have take: 50; update any other occurrences in this
file that use the same page size to reference SESSIONS_PAGE_SIZE to improve
clarity and maintainability (check usages around the sessions loader/route
handlers that construct the pagination params).
packages/validation/src/track.validation.ts (1)

67-72: Extract replay limits into named constants for readability.

Inline limits are hard to reason about during maintenance; named constants would make intent clearer.

As per coding guidelines "Use meaningful variable names instead of magic numbers - extract constants with descriptive names".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/validation/src/track.validation.ts` around lines 67 - 72, Extract
the magic numeric limits into clearly named constants (e.g., MAX_CHUNK_INDEX =
65_535, MIN_EVENTS_COUNT = 1, MAX_PAYLOAD_BYTES = 1_048_576 * 2) and replace
inline literals in the Zod schema (fields chunk_index, events_count, payload,
and any min/max uses) with those constants; declare the constants near the top
of packages/validation/src/track.validation.ts (or immediately above the schema)
so the schema reads z.number().int().min(0).max(MAX_CHUNK_INDEX),
z.number().int().min(MIN_EVENTS_COUNT), and z.string().max(MAX_PAYLOAD_BYTES)
for improved readability and maintainability.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 38d9b65 and 6ddea4a.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (66)
  • .claude/CLAUDE.md
  • apps/api/src/controllers/event.controller.ts
  • apps/api/src/controllers/track.controller.ts
  • apps/api/src/hooks/duplicate.hook.ts
  • apps/api/src/hooks/request-logging.hook.ts
  • apps/api/src/index.ts
  • apps/api/src/routes/track.router.ts
  • apps/api/src/utils/ids.ts
  • apps/public/content/docs/(tracking)/revenue-tracking.mdx
  • apps/public/content/docs/(tracking)/sdks/astro.mdx
  • apps/public/content/docs/(tracking)/sdks/nextjs.mdx
  • apps/public/content/guides/ecommerce-tracking.mdx
  • apps/public/content/guides/nextjs-analytics.mdx
  • apps/public/public/op1-replay.js
  • apps/public/public/op1.js
  • apps/public/src/app/layout.tsx
  • apps/start/package.json
  • apps/start/src/components/events/table/columns.tsx
  • apps/start/src/components/sessions/replay/browser-chrome.tsx
  • apps/start/src/components/sessions/replay/index.tsx
  • apps/start/src/components/sessions/replay/replay-context.tsx
  • apps/start/src/components/sessions/replay/replay-controls.tsx
  • apps/start/src/components/sessions/replay/replay-event-feed.tsx
  • apps/start/src/components/sessions/replay/replay-event-item.tsx
  • apps/start/src/components/sessions/replay/replay-player.tsx
  • apps/start/src/components/sessions/replay/replay-timeline.tsx
  • apps/start/src/components/sessions/replay/replay-utils.ts
  • apps/start/src/components/sessions/table/columns.tsx
  • apps/start/src/components/ui/scroll-area.tsx
  • apps/start/src/hooks/use-page-tabs.ts
  • apps/start/src/modals/event-details.tsx
  • apps/start/src/routeTree.gen.ts
  • apps/start/src/routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.sessions.tsx
  • apps/start/src/routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.tsx
  • apps/start/src/routes/_app.$organizationId.$projectId.sessions_.$sessionId.tsx
  • apps/start/src/types/rrweb-player.d.ts
  • apps/start/src/utils/op.ts
  • apps/worker/src/boot-cron.ts
  • apps/worker/src/jobs/cron.ts
  • apps/worker/src/jobs/events.incoming-event.ts
  • apps/worker/src/jobs/events.incoming-events.test.ts
  • apps/worker/src/metrics.ts
  • apps/worker/src/utils/session-handler.ts
  • packages/common/src/id.ts
  • packages/db/code-migrations/10-add-session-replay.ts
  • packages/db/code-migrations/migrate.ts
  • packages/db/src/buffers/index.ts
  • packages/db/src/buffers/replay-buffer.ts
  • packages/db/src/buffers/session-buffer.ts
  • packages/db/src/clickhouse/client.ts
  • packages/db/src/services/session.service.ts
  • packages/queue/src/queues.ts
  • packages/sdks/astro/src/OpenPanelComponent.astro
  • packages/sdks/nextjs/index.tsx
  • packages/sdks/sdk/src/index.ts
  • packages/sdks/web/package.json
  • packages/sdks/web/src/index.ts
  • packages/sdks/web/src/replay/index.ts
  • packages/sdks/web/src/replay/recorder.ts
  • packages/sdks/web/src/types.d.ts
  • packages/sdks/web/src/types.debug.ts
  • packages/sdks/web/tsup.config.ts
  • packages/trpc/src/routers/session.ts
  • packages/validation/src/track.validation.ts
  • test.ts
  • tooling/publish/publish.ts
💤 Files with no reviewable changes (1)
  • test.ts

Comment thread .claude/CLAUDE.md
Comment on lines +218 to 222
deviceId,
sessionId,
currentDeviceId: '', // TODO: Remove
previousDeviceId: '', // TODO: Remove
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

rg -nP --type=ts -C3 '\b(currentDeviceId|previousDeviceId)\b'

Repository: Openpanel-dev/openpanel

Length of output: 18504


Do not send empty placeholders for device fields still consumed by session handler logic.

currentDeviceId and previousDeviceId are actively used in downstream session stitching (apps/worker/src/utils/session-handler.ts line 138-154). The code checks these fields to lookup existing session jobs via Redis. Sending empty strings will cause this conditional to fail silently, bypassing session stitching entirely.

Note: These values are already being computed in this controller (lines 411-423 in track.controller.ts); passing them through would maintain the correct behavior. If removing these fields, migrate the session handler to use deviceId first.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/controllers/track.controller.ts` around lines 218 - 222, The
payload is sending empty placeholders for currentDeviceId and previousDeviceId
which breaks downstream session stitching (see session-handler.ts logic); update
the object built in track.controller.ts to pass the computed currentDeviceId and
previousDeviceId values already calculated in this controller (instead of ''),
ensuring the properties match the names used by the session handler
(currentDeviceId, previousDeviceId) so Redis lookups and session stitching
continue to work; do not remove these fields here unless you also migrate
session-handler.ts to fall back to deviceId first.

Comment thread apps/api/src/utils/ids.ts
Comment on lines +85 to +87
} catch (error) {
console.error('Error getting session end GET /track/device-id', error);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Replace console.error with a proper logger.

Per coding guidelines, console.log, debugger, and alert statements should be removed from production code. Consider using a structured logger for error reporting.

💡 Suggested fix
+import { logger } from '../utils/logger';
 // ...
   } catch (error) {
-    console.error('Error getting session end GET /track/device-id', error);
+    logger.error('Error getting session end', { error, path: 'GET /track/device-id' });
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/utils/ids.ts` around lines 85 - 87, In apps/api/src/utils/ids.ts
replace the console.error in the catch block (the error handling for the GET
/track/device-id / session end flow) with your structured logger: import and use
the project's logger (e.g., logger.error) inside the catch for the function
handling session end (referenced in this file) and pass a descriptive message
plus the caught error object/stack and any relevant context (request id/device
id) instead of console.error; remove any leftover console.* usage.

Comment thread apps/public/public/op1.js
@@ -1 +1 @@
"use strict";(()=>{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(){let e={};for(let[i,t]of Object.entries(this.headers)){let s=await t;s!==null&&(e[i]=s)}return e}addHeader(e,i){this.headers[e]=i}async post(e,i,t,s){try{let 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}`);let r=await n.text();return r?JSON.parse(r):null}catch(n){if(s<this.maxRetries){let 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={}){let s=`${this.baseUrl}${e}`;return this.post(s,i,t,0)}},l=class{constructor(e){this.options=e,this.queue=[];let 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){let 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{let s=sessionStorage.getItem("openpanel-pending-revenues");if(s){let 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=>{let s=t.target,n=s.closest("a");if(n&&s){let r=n.getAttribute("href");if(r?.startsWith("http"))try{let 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;let t=history.pushState;history.pushState=function(...a){let o=t.apply(this,a);return window.dispatchEvent(new Event("pushstate")),window.dispatchEvent(new Event("locationchange")),o};let s=history.replaceState;history.replaceState=function(...a){let 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"))});let n=()=>this.debounce(()=>this.screenView(),50);this.options.trackHashChanges?window.addEventListener("hashchange",n):window.addEventListener("locationchange",n)}trackAttributes(){this.isServer()||document.addEventListener("click",t=>{let 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){let o={};for(let c of a.attributes)c.name.startsWith("data-")&&c.name!=="data-track"&&(o[h(c.name.replace(/^data-/,""))]=c.value);let 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(){let 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){let i=e.op.q||[],t=new p(i.shift()[1]);i.forEach(n=>{n[0]in t&&t[n[0]](...n.slice(1))});let s=new Proxy((n,...r)=>{let 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;let a=t[r];return typeof a=="function"?a.bind(t):a}});e.op=s,e.openpanel=t}})(window);})();
"use strict";(()=>{var g=Object.create;var h=Object.defineProperty;var m=Object.getOwnPropertyDescriptor;var v=Object.getOwnPropertyNames;var P=Object.getPrototypeOf,k=Object.prototype.hasOwnProperty;var b=(a,t)=>()=>(t||a((t={exports:{}}).exports,t),t.exports);var R=(a,t,e,s)=>{if(t&&typeof t=="object"||typeof t=="function")for(let n of v(t))!k.call(a,n)&&n!==e&&h(a,n,{get:()=>t[n],enumerable:!(s=m(t,n))||s.enumerable});return a};var w=(a,t,e)=>(e=a!=null?g(P(a)):{},R(t||!a||!a.__esModule?h(e,"default",{value:a,enumerable:!0}):e,a));var f=b((A,y)=>{"use strict";y.exports={}});var c=class{constructor(t){this.baseUrl=t.baseUrl,this.headers={"Content-Type":"application/json",...t.defaultHeaders},this.maxRetries=t.maxRetries??3,this.initialRetryDelay=t.initialRetryDelay??500}async resolveHeaders(){let t={};for(let[e,s]of Object.entries(this.headers)){let n=await s;n!==null&&(t[e]=n)}return t}addHeader(t,e){this.headers[t]=e}async post(t,e,s,n){try{let r=await fetch(t,{method:"POST",headers:await this.resolveHeaders(),body:e?JSON.stringify(e??{}):void 0,keepalive:!0,...s});if(r.status===401)return null;if(r.status!==200&&r.status!==202)throw new Error(`HTTP error! status: ${r.status}`);let i=await r.text();return i?JSON.parse(i):null}catch(r){if(n<this.maxRetries){let i=this.initialRetryDelay*2**n;return await new Promise(o=>setTimeout(o,i)),this.post(t,e,s,n+1)}return console.error("Max retries reached:",r),null}}async fetch(t,e,s={}){let n=`${this.baseUrl}${t}`;return this.post(n,e,s,0)}};var l=class{constructor(t){this.options=t;this.queue=[];let e={"openpanel-client-id":t.clientId};t.clientSecret&&(e["openpanel-client-secret"]=t.clientSecret),e["openpanel-sdk-name"]=t.sdk||"node",e["openpanel-sdk-version"]=t.sdkVersion||process.env.SDK_VERSION,this.api=new c({baseUrl:t.apiUrl||"https://api.openpanel.dev",defaultHeaders:e})}init(){}ready(){this.options.waitForProfile=!1,this.flush()}shouldQueue(t){return!!(t.type==="replay"&&!this.sessionId||this.options.waitForProfile&&!this.profileId)}async send(t){if(this.options.disabled||this.options.filter&&!this.options.filter(t))return Promise.resolve();if(this.shouldQueue(t))return this.queue.push(t),Promise.resolve();let e=await this.api.fetch("/track",t);this.deviceId=e?.deviceId;let s=!!this.sessionId;return this.sessionId=e?.sessionId,!s&&this.sessionId&&this.flush(),e}setGlobalProperties(t){this.global={...this.global,...t}}async track(t,e){return this.log("track event",t,e),this.send({type:"track",payload:{name:t,profileId:e?.profileId??this.profileId,properties:{...this.global??{},...e??{}}}})}async identify(t){if(this.log("identify user",t),t.profileId&&(this.profileId=t.profileId,this.flush()),Object.keys(t).length>1)return this.send({type:"identify",payload:{...t,properties:{...this.global,...t.properties}}})}async alias(t){}async increment(t){return this.send({type:"increment",payload:t})}async decrement(t){return this.send({type:"decrement",payload:t})}async revenue(t,e){let s=e?.deviceId;return delete e?.deviceId,this.track("revenue",{...e??{},...s?{__deviceId:s}:{},__revenue:t})}getDeviceId(){return this.deviceId??""}getSessionId(){return this.sessionId??""}async fetchDeviceId(){return Promise.resolve(this.deviceId??"")}clear(){this.profileId=void 0,this.deviceId=void 0,this.sessionId=void 0}flush(){let t=[];for(let e of this.queue){if(this.shouldQueue(e)){t.push(e);continue}let s=e.type==="replay"?e.payload:{...e.payload,profileId:"profileId"in e.payload?e.payload.profileId??this.profileId:this.profileId};this.send({...e,payload:s})}this.queue=t}log(...t){this.options.debug&&console.log("[OpenPanel.dev]",...t)}};function I(a){return a.replace(/([-_][a-z])/gi,t=>t.toUpperCase().replace("-","").replace("_",""))}var p=class extends l{constructor(e){super({sdk:"web",sdkVersion:"1.0.9",...e});this.options=e;this.lastPath="";this.pendingRevenues=[];if(!this.isServer()){try{let s=sessionStorage.getItem("openpanel-pending-revenues");if(s){let n=JSON.parse(s);Array.isArray(n)&&(this.pendingRevenues=n)}}catch{this.pendingRevenues=[]}if(this.setGlobalProperties({__referrer:document.referrer}),this.options.trackScreenViews&&(this.trackScreenViews(),setTimeout(()=>this.screenView(),0)),this.options.trackOutgoingLinks&&this.trackOutgoingLinks(),this.options.trackAttributes&&this.trackAttributes(),this.options.sessionReplay?.enabled){let s=this.options.sessionReplay.sampleRate??1;Math.random()<s&&this.loadReplayModule().then(r=>{r&&r.startReplayRecorder(this.options.sessionReplay,i=>{this.send({type:"replay",payload:{...i}})})})}}}async loadReplayModule(){try{{let e=this.options.sessionReplay?.scriptUrl??"https://openpanel.dev/op1-replay.js";return window.__openpanel_replay?window.__openpanel_replay:new Promise(s=>{let n=document.createElement("script");n.src=e,n.onload=()=>{s(window.__openpanel_replay??null)},n.onerror=()=>{console.warn("[OpenPanel] Failed to load replay script from",e),s(null)},document.head.appendChild(n)})}return await Promise.resolve().then(()=>w(f(),1))}catch(e){return console.warn("[OpenPanel] Failed to load replay module",e),null}}debounce(e,s){clearTimeout(this.debounceTimer),this.debounceTimer=setTimeout(e,s)}isServer(){return typeof document>"u"}trackOutgoingLinks(){this.isServer()||document.addEventListener("click",e=>{let s=e.target,n=s.closest("a");if(n&&s){let r=n.getAttribute("href");if(r?.startsWith("http"))try{let i=new URL(r),o=window.location.hostname;i.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;let e=history.pushState;history.pushState=function(...i){let o=e.apply(this,i);return window.dispatchEvent(new Event("pushstate")),window.dispatchEvent(new Event("locationchange")),o};let s=history.replaceState;history.replaceState=function(...i){let o=s.apply(this,i);return window.dispatchEvent(new Event("replacestate")),window.dispatchEvent(new Event("locationchange")),o},window.addEventListener("popstate",()=>{window.dispatchEvent(new Event("locationchange"))});let n=()=>this.debounce(()=>this.screenView(),50);this.options.trackHashChanges?window.addEventListener("hashchange",n):window.addEventListener("locationchange",n)}trackAttributes(){this.isServer()||document.addEventListener("click",e=>{let s=e.target,n=s.closest("button"),r=s.closest("a"),i=n?.getAttribute("data-track")?n:r?.getAttribute("data-track")?r:null;if(i){let o={};for(let d of i.attributes)d.name.startsWith("data-")&&d.name!=="data-track"&&(o[I(d.name.replace(/^data-/,""))]=d.value);let u=i.getAttribute("data-track");u&&super.track(u,o)}})}screenView(e,s){if(this.isServer())return;let n,r;typeof e=="string"?(n=e,r=s):(n=window.location.href,r=e),this.lastPath!==n&&(this.lastPath=n,super.track("screen_view",{...r??{},__path:n,__title:document.title}))}async flushRevenue(){let e=this.pendingRevenues.map(s=>super.revenue(s.amount,s.properties));await Promise.all(e),this.clearRevenue()}clearRevenue(){if(this.pendingRevenues=[],!this.isServer())try{sessionStorage.removeItem("openpanel-pending-revenues")}catch{}}pendingRevenue(e,s){if(this.pendingRevenues.push({amount:e,properties:s}),!this.isServer())try{sessionStorage.setItem("openpanel-pending-revenues",JSON.stringify(this.pendingRevenues))}catch{}}};(a=>{if(a.op){let t=a.op.q||[],e=new p(t.shift()[1]);t.forEach(n=>{n[0]in e&&e[n[0]](...n.slice(1))});let s=new Proxy((n,...r)=>{let i=e[n]?e[n].bind(e):void 0;typeof i=="function"?i(...r):console.warn(`OpenPanel: ${n} is not a function`)},{get(n,r){if(r==="q")return;let i=e[r];return typeof i=="function"?i.bind(e):i}});a.op=s,a.openpanel=e}})(window);})();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find the source file that generates this bundle
fd -t f "op1" --extension js --extension ts --extension jsx --extension tsx

Repository: Openpanel-dev/openpanel

Length of output: 125


🏁 Script executed:

# Search for SDK_VERSION and process.env references in the codebase
rg -n "SDK_VERSION|process\.env" --type ts --type js -C 3

Repository: Openpanel-dev/openpanel

Length of output: 50380


🏁 Script executed:

# Check the directory structure to understand the bundle location
find apps/public -type f -name "*.ts" -o -name "*.js" | head -20

Repository: Openpanel-dev/openpanel

Length of output: 769


🏁 Script executed:

# Look for build configuration files
fd -t f "package.json|tsconfig|webpack|esbuild|vite" apps/public

Repository: Openpanel-dev/openpanel

Length of output: 222


🏁 Script executed:

# Find the build configuration that generates op1.js
rg -n "op1" apps/public package.json vite.config esbuild --type json --type ts

Repository: Openpanel-dev/openpanel

Length of output: 166


🏁 Script executed:

# Check packages/sdks/web source code structure
fd -t f . packages/sdks/web/src | head -20

Repository: Openpanel-dev/openpanel

Length of output: 317


🏁 Script executed:

# Look at the actual source file that likely generates this bundle
cat packages/sdks/web/src/index.ts | head -100

Repository: Openpanel-dev/openpanel

Length of output: 2852


🏁 Script executed:

# Check for build/bundle configuration
find apps/public -name "*.config.*" -type f

Repository: Openpanel-dev/openpanel

Length of output: 186


🏁 Script executed:

# Check the main package.json for build scripts that might build op1.js
cat packages/sdks/web/package.json 2>/dev/null || echo "not found"

Repository: Openpanel-dev/openpanel

Length of output: 614


🏁 Script executed:

# Look at source.config.ts which might define the bundle
cat apps/public/source.config.ts

Repository: Openpanel-dev/openpanel

Length of output: 2076


🏁 Script executed:

# Check if there's a dedicated build script for op1.js
rg -n "op1" --type ts --type js --type json -C 2

Repository: Openpanel-dev/openpanel

Length of output: 14035


🏁 Script executed:

# Look for esbuild or bundle configs in packages/sdks/web
find packages/sdks/web -name "*.config.*" -o -name "tsconfig.json" | xargs cat 2>/dev/null

Repository: Openpanel-dev/openpanel

Length of output: 2645


🏁 Script executed:

# Check the tracker.ts source file that's used for the IIFE build
cat packages/sdks/web/src/tracker.ts

Repository: Openpanel-dev/openpanel

Length of output: 1473


🏁 Script executed:

# Check if WEB_VERSION is defined at build time
rg -n "WEB_VERSION|SDK_VERSION" packages/sdks --type ts -B 2 -A 2

Repository: Openpanel-dev/openpanel

Length of output: 701


🏁 Script executed:

# Verify the actual tsup build output
find packages/sdks/web/dist -name "*.js" 2>/dev/null | head -5

Repository: Openpanel-dev/openpanel

Length of output: 49


🏁 Script executed:

# Check if there's an env substitution in the build process
rg -n "WEB_VERSION|SDK_VERSION|define" tooling --type ts --type json

Repository: Openpanel-dev/openpanel

Length of output: 49


🏁 Script executed:

# Check if the base SDK class is bundled into op1.js by searching for the relevant code
grep -o "openpanel-sdk-version" packages/sdks/web/dist/src/tracker.global.js 2>/dev/null | head -1 || echo "Not found or dist doesn't exist"

Repository: Openpanel-dev/openpanel

Length of output: 49


🏁 Script executed:

# Let's check the actual content of dist files to understand what's bundled
ls -la packages/sdks/web/dist/src/ 2>/dev/null || echo "dist/ directory doesn't exist"

Repository: Openpanel-dev/openpanel

Length of output: 96


🏁 Script executed:

# Look at the base SDK constructor to see the full fallback chain
cat packages/sdks/sdk/src/index.ts | head -100

Repository: Openpanel-dev/openpanel

Length of output: 2462


🏁 Script executed:

# Check how the web SDK passes sdkVersion to the base class
cat packages/sdks/web/src/index.ts | grep -A 10 "constructor"

Repository: Openpanel-dev/openpanel

Length of output: 358


🏁 Script executed:

# Check if WEB_VERSION is injected at build time via define/env vars
rg -n "WEB_VERSION" tooling packages --type ts --type json -B 2 -A 2

Repository: Openpanel-dev/openpanel

Length of output: 336


🏁 Script executed:

# Check pnpm config or build scripts
cat pnpm-workspace.yaml 2>/dev/null || cat .npmrc 2>/dev/null || echo "No pnpm config found"

Repository: Openpanel-dev/openpanel

Length of output: 364


🏁 Script executed:

# Check if there's a script that runs before/after build
cat packages/sdks/web/package.json | grep -A 10 "scripts"

Repository: Openpanel-dev/openpanel

Length of output: 359


🏁 Script executed:

# Search for version references in the repository
rg -n "1\.0\." packages/sdks/web --type ts --type json | head -20

Repository: Openpanel-dev/openpanel

Length of output: 400


🏁 Script executed:

# Check if WEB_VERSION environment variable is set in build scripts
rg -n "WEB_VERSION\s*=" . --type sh --type json --type ts | head -20

Repository: Openpanel-dev/openpanel

Length of output: 49


🏁 Script executed:

# Look at how the version is actually supposed to be injected
rg -n "process.env.WEB_VERSION" . --type ts -B 3 -A 3

Repository: Openpanel-dev/openpanel

Length of output: 469


🏁 Script executed:

# Check GitHub workflows or CI configs that might set versions
find . -name "*.yml" -o -name "*.yaml" | xargs grep -l "WEB_VERSION\|SDK_VERSION" 2>/dev/null | head -5

Repository: Openpanel-dev/openpanel

Length of output: 49


🏁 Script executed:

# Verify what happens in the minified bundle - check if process.env fallback is actually there
python3 << 'EOF'
# The minified bundle shows: t.sdkVersion||process.env.SDK_VERSION
# This means if t.sdkVersion is falsy, it accesses process.env.SDK_VERSION
# Let's trace what sdkVersion is when the web SDK instantiates the base class

code = """
In packages/sdks/web/src/index.ts:
  sdkVersion: process.env.WEB_VERSION!,
  
In packages/sdks/sdk/src/index.ts:
  defaultHeaders['openpanel-sdk-version'] = options.sdkVersion || process.env.SDK_VERSION!;
"""

print("ANALYSIS:")
print("1. Web SDK passes: sdkVersion = process.env.WEB_VERSION!")
print("2. If WEB_VERSION is undefined at build time, sdkVersion becomes undefined")
print("3. Undefined is falsy, so parent class evaluates: process.env.SDK_VERSION")
print("4. In browser, process is undefined -> ReferenceError")
print()
print("CONCLUSION: Review comment is CORRECT - unguarded process.env access in browser")
EOF

Repository: Openpanel-dev/openpanel

Length of output: 417


Guard process.env access or provide a fallback for SDK version.

The base SDK class (packages/sdks/sdk/src/index.ts, line 56) accesses process.env.SDK_VERSION without checking if process is defined, which throws a ReferenceError in browsers if sdkVersion option is not provided. The web SDK attempts to pass process.env.WEB_VERSION! but this environment variable is not injected at build time in the tsup configuration, leaving the fallback unguarded.

Either guard the fallback with a default value:

defaultHeaders['openpanel-sdk-version'] = 
  options.sdkVersion || process.env.SDK_VERSION || '';

Or inject WEB_VERSION in the tsup build config alongside __OPENPANEL_REPLAY_URL__.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/public/public/op1.js` at line 1, The SDK reads process.env.SDK_VERSION
unguarded in the base class constructor (class l -> constructor) when building
defaultHeaders for new c, which throws in browsers; update the default
sdkVersion fallback to prefer options.sdkVersion and only read
process.env.SDK_VERSION when process exists (e.g. check typeof
process!=="undefined" and process.env) and otherwise use an empty string as the
final fallback so defaultHeaders['openpanel-sdk-version'] never accesses a
missing global.

Comment on lines +151 to +161
cell({ row }) {
const { sessionId } = row.original;
return (
<ProjectLink
href={`/sessions/${encodeURIComponent(sessionId)}`}
className="whitespace-nowrap font-medium hover:underline"
>
{sessionId.slice(0,6)}
</ProjectLink>
);
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard sessionId before encoding/slicing to avoid row render crashes.

On Line 155 and Line 158, sessionId is assumed to be always present. If it’s null/undefined for any record, this will throw and break table rendering.

Suggested fix
       cell({ row }) {
-        const { sessionId } = row.original;
+        const { sessionId } = row.original;
+        if (!sessionId) {
+          return null;
+        }
+        const SESSION_ID_PREVIEW_LENGTH = 6;
         return (
           <ProjectLink
             href={`/sessions/${encodeURIComponent(sessionId)}`}
             className="whitespace-nowrap font-medium hover:underline"
           >
-            {sessionId.slice(0,6)}
+            {sessionId.slice(0, SESSION_ID_PREVIEW_LENGTH)}
           </ProjectLink>
         );
       },

As per coding guidelines, "Use meaningful variable names instead of magic numbers - extract constants with descriptive names."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
cell({ row }) {
const { sessionId } = row.original;
return (
<ProjectLink
href={`/sessions/${encodeURIComponent(sessionId)}`}
className="whitespace-nowrap font-medium hover:underline"
>
{sessionId.slice(0,6)}
</ProjectLink>
);
},
cell({ row }) {
const { sessionId } = row.original;
if (!sessionId) {
return null;
}
const SESSION_ID_PREVIEW_LENGTH = 6;
return (
<ProjectLink
href={`/sessions/${encodeURIComponent(sessionId)}`}
className="whitespace-nowrap font-medium hover:underline"
>
{sessionId.slice(0, SESSION_ID_PREVIEW_LENGTH)}
</ProjectLink>
);
},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/start/src/components/events/table/columns.tsx` around lines 151 - 161,
The cell renderer uses row.original.sessionId without guarding it, which can
throw when null/undefined; update the cell({ row }) implementation in the same
component to first safely read sessionId from row.original, return a fallback
(e.g., a placeholder or null element) when sessionId is falsy, and only call
encodeURIComponent and sessionId.slice when sessionId is a non-empty string;
also replace the magic number 6 used in sessionId.slice(0,6) with a named
constant (e.g., SESSION_ID_PREVIEW_LEN) declared near the component for clarity.

Comment thread packages/sdks/web/src/index.ts
Comment thread packages/sdks/web/src/index.ts Outdated
Comment thread packages/sdks/web/src/types.debug.ts
Comment thread packages/sdks/web/tsup.config.ts
Comment thread packages/trpc/src/routers/session.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (2)
packages/sdks/web/src/index.ts (2)

142-144: ⚠️ Potential issue | 🟠 Major

Use the injected __OPENPANEL_REPLAY_URL__ value when building the replay script URL.

Line 144 checks that __OPENPANEL_REPLAY_URL__ is defined but never reads its value. The build-time replay URL configuration is effectively ignored.

🔧 Proposed fix
      if (typeof __OPENPANEL_REPLAY_URL__ !== 'undefined') {
        const scriptEl = _replayScriptRef;
-       const url = this.options.sessionReplay?.scriptUrl || scriptEl?.src?.replace('.js', '-replay.js') || 'https://openpanel.dev/op1-replay.js';
+       const url =
+         this.options.sessionReplay?.scriptUrl ??
+         __OPENPANEL_REPLAY_URL__ ??
+         scriptEl?.src?.replace('.js', '-replay.js') ??
+         'https://openpanel.dev/op1-replay.js';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/sdks/web/src/index.ts` around lines 142 - 144, The code checks for
the injected build-time constant __OPENPANEL_REPLAY_URL__ but never uses it when
constructing the replay URL; update the URL resolution in the block around
_replayScriptRef and the url variable to prefer the value of
__OPENPANEL_REPLAY_URL__ when it is defined, falling back to
this.options.sessionReplay?.scriptUrl, then to
scriptEl?.src?.replace('.js','-replay.js'), and finally the hardcoded default;
ensure references to __OPENPANEL_REPLAY_URL__, _replayScriptRef, and
this.options.sessionReplay?.scriptUrl are used in that precedence.

102-118: ⚠️ Potential issue | 🟡 Minor

Sampling decision is made on every page load, causing inconsistent replay sessions.

Math.random() < sampleRate is evaluated on each page load, so a user might be sampled on one page but not on subsequent navigations in the same session. Consider persisting the sampling decision to sessionStorage for consistent per-session behavior.

💡 Suggested approach
      if (this.options.sessionReplay?.enabled) {
        const sampleRate = this.options.sessionReplay.sampleRate ?? 1;
-       const sampled = Math.random() < sampleRate;
+       const REPLAY_SAMPLED_KEY = 'openpanel-replay-sampled';
+       let sampled: boolean;
+       try {
+         const stored = sessionStorage.getItem(REPLAY_SAMPLED_KEY);
+         if (stored !== null) {
+           sampled = stored === 'true';
+         } else {
+           sampled = Math.random() < sampleRate;
+           sessionStorage.setItem(REPLAY_SAMPLED_KEY, String(sampled));
+         }
+       } catch {
+         sampled = Math.random() < sampleRate;
+       }
        if (sampled) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/sdks/web/src/index.ts` around lines 102 - 118, The sampling decision
for session replay should be made once per browser session and persisted so it
remains consistent across page loads; update the logic around
this.options.sessionReplay.sampleRate to first read a sessionStorage key (e.g.,
"sessionReplaySampled"), if absent compute sampled = Math.random() < sampleRate,
store that boolean in sessionStorage, and then use that persisted value to
decide whether to call loadReplayModule() and
startReplayRecorder(this.options.sessionReplay!, ...); ensure the code
reads/writes the same sessionStorage key so subsequent navigations in the same
session reuse the decision.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@packages/sdks/web/src/index.ts`:
- Around line 142-144: The code checks for the injected build-time constant
__OPENPANEL_REPLAY_URL__ but never uses it when constructing the replay URL;
update the URL resolution in the block around _replayScriptRef and the url
variable to prefer the value of __OPENPANEL_REPLAY_URL__ when it is defined,
falling back to this.options.sessionReplay?.scriptUrl, then to
scriptEl?.src?.replace('.js','-replay.js'), and finally the hardcoded default;
ensure references to __OPENPANEL_REPLAY_URL__, _replayScriptRef, and
this.options.sessionReplay?.scriptUrl are used in that precedence.
- Around line 102-118: The sampling decision for session replay should be made
once per browser session and persisted so it remains consistent across page
loads; update the logic around this.options.sessionReplay.sampleRate to first
read a sessionStorage key (e.g., "sessionReplaySampled"), if absent compute
sampled = Math.random() < sampleRate, store that boolean in sessionStorage, and
then use that persisted value to decide whether to call loadReplayModule() and
startReplayRecorder(this.options.sessionReplay!, ...); ensure the code
reads/writes the same sessionStorage key so subsequent navigations in the same
session reuse the decision.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6ddea4a and 420f540.

📒 Files selected for processing (1)
  • packages/sdks/web/src/index.ts

@lindesvard lindesvard merged commit aa81bbf into main Feb 26, 2026
2 checks passed
@coderabbitai coderabbitai Bot mentioned this pull request Apr 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant