Skip to content

Implement session replay enhancements and batch processing improvements#1048

Open
goldflag wants to merge 3 commits into
masterfrom
ingestion-fixes
Open

Implement session replay enhancements and batch processing improvements#1048
goldflag wants to merge 3 commits into
masterfrom
ingestion-fixes

Conversation

@goldflag

@goldflag goldflag commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator
  • Introduced a stable batch ID generation for session replay events, ensuring consistency during retries.
  • Enhanced event handling by assigning globally increasing sequence numbers across batches.
  • Updated the session replay recorder to manage pending batches more effectively, improving event flushing and delivery reliability.
  • Modified the tracking system to wait for earlier requests before sending identify events, ensuring accurate user identification.
  • Added tests to verify the stability of batch IDs and sequence number assignments during event processing.

Summary by CodeRabbit

  • New Features

    • Upgraded session replay to reliable queued batching with stable batch IDs, globally increasing event sequencing, sequential draining, and batch persistence support.
    • Introduced durable batching queues for bot events and pageviews with capacity limits, retries, and improved shutdown draining.
    • Improved site import coordination using DB locking to enforce single active imports and tighten quota window handling.
  • Bug Fixes

    • Session replay now rejects non-OK delivery responses; ingestion/retrieval uses stricter validation, deduplication, and deterministic replay IDs/keys.
    • Identify now waits for earlier in-flight tracking requests before sending identify and refreshing feature flags.
    • Added clearer handling for queue capacity and tighter dimension/payload bounds.
  • Tests

    • Expanded coverage for replay retry stability, sequencing, ordering, capacity behavior, and ingest validation edge cases.

- Introduced a stable batch ID generation for session replay events, ensuring consistency during retries.
- Enhanced event handling by assigning globally increasing sequence numbers across batches.
- Updated the session replay recorder to manage pending batches more effectively, improving event flushing and delivery reliability.
- Modified the tracking system to wait for earlier requests before sending identify events, ensuring accurate user identification.
- Added tests to verify the stability of batch IDs and sequence number assignments during event processing.
@vercel

vercel Bot commented Jul 14, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
rybbit Ready Ready Preview, Comment Jul 14, 2026 7:54am

Request Review

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ab283c3b-0c43-48d4-ad2f-7d060cafa5da

📥 Commits

Reviewing files that changed from the base of the PR and between 7ed1678 and 19432ee.

📒 Files selected for processing (2)
  • server/src/db/clickhouse/clickhouse.ts
  • server/src/services/replay/sessionReplayIngestService.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • server/src/db/clickhouse/clickhouse.ts
  • server/src/services/replay/sessionReplayIngestService.ts

📝 Walkthrough

Walkthrough

Session replay now uses stable batch IDs, ordered sequences, retry-safe delivery, idempotent ingestion, and deterministic storage/query ordering. Tracking identify requests wait for earlier sends. Import concurrency uses database locks, and ingestion queues gain retries, capacity handling, and graceful draining.

Changes

Session replay batching and persistence

Layer / File(s) Summary
Replay batching and ordered delivery
server/src/analytics-script/sessionReplay.ts, server/public/script-full.js, server/public/script.js, server/src/analytics-script/types.ts, server/src/types/sessionReplay.ts, server/src/analytics-script/sessionReplay.test.ts
Replay events receive global sequence numbers, batches receive stable IDs, and pending batches are sent sequentially with retry-preserved payloads.
Replay ingestion and durable storage
server/src/api/sessionReplay/*, server/src/services/replay/*, server/src/services/storage/r2StorageService.ts, server/src/db/clickhouse/clickhouse.ts
Replay validation, ClickHouse storage, R2 keys, and duplicate detection now use batch IDs and bounded event metadata.
Replay reconstruction and ordering
server/src/services/replay/sessionReplayQueryService.ts
Query reconstruction sorts by timestamp and sequence while removing internal sequence fields from responses.

Tracking request and identify ordering

Layer / File(s) Summary
Tracking request coordination
server/src/analytics-script/tracking.ts, server/public/script-full.js, server/src/analytics-script/tracking.test.ts
In-flight tracking requests are tracked, and identify waits for earlier requests before sending identify data and refreshing flags.
Identify transaction and backfill
server/src/services/tracker/identifyService.ts, server/src/services/tracker/identifyService.test.ts
Profile, alias, and trait updates run transactionally, while new-identify backfill is awaited and errors propagate.

Import concurrency control

Layer / File(s) Summary
Locked import creation
server/src/services/import/importStatusManager.ts, server/src/services/import/importStatusManager.test.ts, server/src/api/sites/createSiteImport.ts
Import creation can lock an organization, complete stale imports, and reject a second active import.
Locked batch processing and quota filtering
server/src/api/sites/batchImportEvents.ts, server/src/api/sites/batchImportEvents.test.ts, server/src/services/import/importQuotaTracker.ts, server/src/services/import/importQuotaTracker.test.ts, server/src/api/sites/deleteSiteImport.ts
Batch processing moves quota and status checks inside the organization lock, while quota queries can be restricted to relevant months and handlers stop using the removed quota manager.

Reliable ingestion queues and bounds

Layer / File(s) Summary
Reliable batch queue implementation
server/src/services/tracker/reliableBatchQueue.ts, server/src/services/tracker/reliableBatchQueue.test.ts
A reusable queue batches events, retries failures, enforces capacity, and drains pending deliveries during shutdown.
Pageview and bot queue adapters
server/src/services/tracker/pageviewQueue.ts, server/src/services/tracker/botBlocking/botEventQueue.ts, related tests
Pageview and bot ingestion use the reliable queue while preserving enrichment and ClickHouse transformations.
Ingestion bounds and shutdown
server/src/lib/clickhouseLimits.ts, server/src/services/import/mappers/*, server/src/services/tracker/trackEvent.ts, server/src/index.ts, related tests
ClickHouse dimension limits are shared across validation, queue capacity errors return 503, and ingestion queues are closed during graceful shutdown.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SessionReplayRecorder
  participant ReplayEndpoint
  participant SessionReplayIngestService
  participant ClickHouse
  participant R2Storage
  SessionReplayRecorder->>ReplayEndpoint: send stable replay batch
  ReplayEndpoint->>SessionReplayIngestService: recordEvents
  SessionReplayIngestService->>ClickHouse: check stored batch indexes
  SessionReplayIngestService->>R2Storage: store missing batch payload
  SessionReplayIngestService->>ClickHouse: insert missing replay rows
  ClickHouse-->>ReplayEndpoint: acknowledge ingestion
Loading
sequenceDiagram
  participant Tracker
  participant ReliableBatchQueue
  participant ClickHouse
  participant ServerShutdown
  Tracker->>ReliableBatchQueue: add tracking event
  ReliableBatchQueue->>ClickHouse: process event batch
  ClickHouse-->>ReliableBatchQueue: success or transient failure
  ReliableBatchQueue-->>Tracker: resolve or retry delivery
  ServerShutdown->>ReliableBatchQueue: close and drain
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main session replay and batch-processing changes in the pull request.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ingestion-fixes

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.

@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: 3

🧹 Nitpick comments (5)
server/src/services/tracker/identifyService.ts (1)

108-123: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Avoid redundant inserts into userProfiles.

When is_new_identify is true and traits are provided, the transaction performs two INSERT operations on userProfiles: one with ON CONFLICT DO NOTHING, and immediately another with ON CONFLICT DO UPDATE.

The first INSERT is unnecessary when traits are provided, as the second INSERT safely handles row creation and conflict resolution.

♻️ Proposed refactor
     await db.transaction(async tx => {
+      const hasTraits = traits && Object.keys(traits).length > 0;
+
       if (is_new_identify) {
-        // Keep profile and alias creation atomic. The conflict update also handles
-        // simultaneous identify calls for the same anonymous visitor.
-        await tx.insert(userProfiles).values({ siteId, userId: user_id }).onConflictDoNothing();
+        // Only insert here if there are no traits. If there are traits, the upsert below handles it.
+        if (!hasTraits) {
+          await tx.insert(userProfiles).values({ siteId, userId: user_id }).onConflictDoNothing();
+        }
         await tx
           .insert(userAliases)
           .values({ siteId, anonymousId, userId: user_id })
           .onConflictDoUpdate({
             target: [userAliases.siteId, userAliases.anonymousId],
             set: { userId: user_id },
           });
       }

       // Atomic upsert: merge non-null traits and remove keys explicitly set to null.
-      if (traits && Object.keys(traits).length > 0) {
+      if (hasTraits) {
         const filteredTraits = Object.fromEntries(Object.entries(traits).filter(([_, v]) => v !== null));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/services/tracker/identifyService.ts` around lines 108 - 123,
Update the transaction logic in the is_new_identify branch to avoid the initial
userProfiles insert when traits are provided, since the traits upsert already
creates or updates the profile. Preserve profile creation for empty or absent
traits, and keep the userAliases insert behavior unchanged.
server/src/services/import/importStatusManager.ts (1)

11-25: 🩺 Stability & Availability | 🔵 Trivial | 🏗️ Heavy lift

work() never receives the locking transaction — writes bypass tx.

withOrganizationImportLock's work: () => Promise<T> signature has no way to receive tx, so every DB call inside createImport's callback (db.update(...), db.query.importStatus.findFirst, db.insert(...)) runs against the ambient pool, not the transaction holding the row lock. Serialization across workers still holds (a competing SELECT ... FOR UPDATE blocks until this transaction commits, which only happens after work() resolves), but:

  • Each of those calls needs a separate pool connection while the locking connection sits idle, increasing pool pressure under concurrency.
  • If work() throws partway (e.g., after the stale-import update but before insert), the already-executed writes remain committed even though the enclosing "transaction" appears to have failed — no real atomicity.

Consider threading tx into work (work: (tx: PgTransaction) => Promise<T>) and using it for the update/find/insert calls so they participate in the same transaction and reuse the already-checked-out connection.

♻️ Proposed direction
-export async function withOrganizationImportLock<T>(organizationId: string, work: () => Promise<T>): Promise<T> {
+export async function withOrganizationImportLock<T>(
+  organizationId: string,
+  work: (tx: Parameters<Parameters<typeof db.transaction>[0]>[0]) => Promise<T>
+): Promise<T> {
   return db.transaction(async tx => {
     const [lockedOrganization] = await tx
       .select({ id: organization.id })
       .from(organization)
       .where(eq(organization.id, organizationId))
       .for("update");

     if (!lockedOrganization) {
       throw new Error(`Organization ${organizationId} not found`);
     }

-    return work();
+    return work(tx);
   });
 }

Then update createImport's callback and call sites (e.g. updateImportProgress, completeImport, getImportById used inside the lock in batchImportEvents.ts) to accept/use tx instead of the module-level db.

Also applies to: 48-70

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/services/import/importStatusManager.ts` around lines 11 - 25,
Update withOrganizationImportLock to pass its locking transaction into work,
changing the callback contract to accept the appropriate PgTransaction type.
Propagate tx through createImport and lock-scoped call sites such as
updateImportProgress, completeImport, and getImportById, and use it for all
update, query, and insert operations instead of the module-level db so the work
remains atomic on the locking connection.
server/src/api/sites/batchImportEvents.ts (1)

103-130: 🩺 Stability & Availability | 🔵 Trivial

Keep the import lock short around Postgres work. withOrganizationImportLock keeps the row lock/transaction open for the whole callback, but this section also does ClickHouse I/O (ImportQuotaTracker.create/clickhouse.insert). With the ClickHouse client’s 5-minute request timeout, a slow request can hold the import lock and a Postgres connection for a long time. Consider moving the ClickHouse work outside the serialized section if possible.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/api/sites/batchImportEvents.ts` around lines 103 - 130, The
withOrganizationImportLock callback currently includes slow ClickHouse
operations, unnecessarily extending the Postgres lock and transaction.
Restructure the import flow so ImportQuotaTracker.create and clickhouse.insert
run outside withOrganizationImportLock, while keeping currentImport validation,
updateImportProgress, and completeImport serialized as required; preserve quota
filtering and ensure the final progress/completion update uses the results of
the ClickHouse work.
server/src/services/tracker/pageviewQueue.test.ts (1)

26-33: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add teardown to stop the real singleton's flush interval.

pageviewQueue is the real module singleton; its constructor already started a live setInterval for PAGEVIEW_FLUSH_INTERVAL_MS. beforeEach resets queue/processing/closing but nothing ever calls pageviewQueue.close(), so the background interval keeps firing (with the mocked insert/getLocation) for the rest of the file and beyond, risking interference between tests and dangling timers after the suite completes.

🧹 Proposed teardown
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
   beforeEach(() => {
     vi.clearAllMocks();
     mocks.getLocation.mockResolvedValue({});
     mocks.insert.mockResolvedValue(undefined);
     (pageviewQueue as any).queue = [];
     (pageviewQueue as any).processing = false;
     (pageviewQueue as any).closing = false;
   });
+
+  afterAll(async () => {
+    await pageviewQueue.close();
+  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/services/tracker/pageviewQueue.test.ts` around lines 26 - 33, Add
teardown for the real singleton pageviewQueue by calling pageviewQueue.close()
after the tests, ensuring its flush interval is stopped and no background timer
remains active beyond the suite.
server/src/services/tracker/trackEvent.ts (1)

445-450: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the new 503 path; consider QueueClosedError too.

This new branch has no test asserting a QueueFullError yields a 503. Also, QueueClosedError (thrown by add() while a queue drains during shutdown) isn't special-cased and will fall through to the generic 500 — a 503 would be more accurate there too.

♻️ Optional: also handle QueueClosedError
-    if (error instanceof QueueFullError) {
+    if (error instanceof QueueFullError || error instanceof QueueClosedError) {
       return reply.status(503).send({
         success: false,
         error: "Ingestion queue is at capacity",
       });
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/services/tracker/trackEvent.ts` around lines 445 - 450, Add tests
for the `QueueFullError` branch in the `trackEvent` handler, asserting it
returns status 503 with the existing capacity error response. Also update the
error handling to treat `QueueClosedError` like `QueueFullError`, returning the
same 503 response, and cover that behavior in tests.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@server/src/analytics-script/sessionReplay.ts`:
- Around line 250-295: Update SessionReplayRecorder.drainPendingBatches in
server/src/analytics-script/sessionReplay.ts:250-295 to bound pendingBatches by
dropping the oldest entries and/or add exponential backoff between failed
sendBatch retries, preserving the failed batch for retry. Rebuild
server/public/script-full.js:432-471 and server/public/script.js:1-1 from the
corrected source so their compiled drainPendingBatches implementations include
the same protection.

In `@server/src/api/sites/batchImportEvents.ts`:
- Around line 133-139: Replace the exact message comparison in the batch import
handler’s insert-error catch block with a typed or otherwise stable error
discriminator for the IMPORT_ALREADY_COMPLETED condition. Update the code that
raises this condition to use the same discriminator, and preserve the 409
response while allowing all other errors to follow the existing generic path.

In `@server/src/services/tracker/pageviewQueue.ts`:
- Around line 98-111: The ClickHouse inserts in processPageviewBatch and the
corresponding bot event batch processor need stable per-batch idempotency tokens
so ReliableBatchQueue retries cannot duplicate accepted rows. Update
server/src/services/tracker/pageviewQueue.ts lines 98-111 and
server/src/services/tracker/botBlocking/botEventQueue.ts lines 62-74 to generate
or reuse a deterministic batch ID and pass it through the ClickHouse insert
using the same deduplication mechanism established by session replay.

---

Nitpick comments:
In `@server/src/api/sites/batchImportEvents.ts`:
- Around line 103-130: The withOrganizationImportLock callback currently
includes slow ClickHouse operations, unnecessarily extending the Postgres lock
and transaction. Restructure the import flow so ImportQuotaTracker.create and
clickhouse.insert run outside withOrganizationImportLock, while keeping
currentImport validation, updateImportProgress, and completeImport serialized as
required; preserve quota filtering and ensure the final progress/completion
update uses the results of the ClickHouse work.

In `@server/src/services/import/importStatusManager.ts`:
- Around line 11-25: Update withOrganizationImportLock to pass its locking
transaction into work, changing the callback contract to accept the appropriate
PgTransaction type. Propagate tx through createImport and lock-scoped call sites
such as updateImportProgress, completeImport, and getImportById, and use it for
all update, query, and insert operations instead of the module-level db so the
work remains atomic on the locking connection.

In `@server/src/services/tracker/identifyService.ts`:
- Around line 108-123: Update the transaction logic in the is_new_identify
branch to avoid the initial userProfiles insert when traits are provided, since
the traits upsert already creates or updates the profile. Preserve profile
creation for empty or absent traits, and keep the userAliases insert behavior
unchanged.

In `@server/src/services/tracker/pageviewQueue.test.ts`:
- Around line 26-33: Add teardown for the real singleton pageviewQueue by
calling pageviewQueue.close() after the tests, ensuring its flush interval is
stopped and no background timer remains active beyond the suite.

In `@server/src/services/tracker/trackEvent.ts`:
- Around line 445-450: Add tests for the `QueueFullError` branch in the
`trackEvent` handler, asserting it returns status 503 with the existing capacity
error response. Also update the error handling to treat `QueueClosedError` like
`QueueFullError`, returning the same 503 response, and cover that behavior in
tests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 32165515-57cb-43d6-89f0-001cc47a4e00

📥 Commits

Reviewing files that changed from the base of the PR and between 5177ba4 and 27abb6a.

📒 Files selected for processing (36)
  • server/public/script-full.js
  • server/public/script.js
  • server/src/analytics-script/sessionReplay.test.ts
  • server/src/analytics-script/sessionReplay.ts
  • server/src/analytics-script/tracking.test.ts
  • server/src/analytics-script/tracking.ts
  • server/src/analytics-script/types.ts
  • server/src/api/sessionReplay/recordSessionReplay.test.ts
  • server/src/api/sessionReplay/recordSessionReplay.ts
  • server/src/api/sites/batchImportEvents.test.ts
  • server/src/api/sites/batchImportEvents.ts
  • server/src/api/sites/createSiteImport.ts
  • server/src/api/sites/deleteSiteImport.ts
  • server/src/db/clickhouse/clickhouse.ts
  • server/src/index.ts
  • server/src/lib/clickhouseLimits.ts
  • server/src/services/import/importQuotaManager.ts
  • server/src/services/import/importStatusManager.test.ts
  • server/src/services/import/importStatusManager.ts
  • server/src/services/import/mappers/simpleAnalytics.ts
  • server/src/services/import/mappers/umami.ts
  • server/src/services/replay/sessionReplayIngestService.test.ts
  • server/src/services/replay/sessionReplayIngestService.ts
  • server/src/services/replay/sessionReplayQueryService.ts
  • server/src/services/storage/r2StorageService.test.ts
  • server/src/services/storage/r2StorageService.ts
  • server/src/services/tracker/botBlocking/botEventQueue.ts
  • server/src/services/tracker/identifyService.test.ts
  • server/src/services/tracker/identifyService.ts
  • server/src/services/tracker/pageviewQueue.test.ts
  • server/src/services/tracker/pageviewQueue.ts
  • server/src/services/tracker/reliableBatchQueue.test.ts
  • server/src/services/tracker/reliableBatchQueue.ts
  • server/src/services/tracker/trackEvent.test.ts
  • server/src/services/tracker/trackEvent.ts
  • server/src/types/sessionReplay.ts
💤 Files with no reviewable changes (2)
  • server/src/services/import/importQuotaManager.ts
  • server/src/api/sites/deleteSiteImport.ts

Comment on lines 250 to 295
private async flushEvents(): Promise<void> {
if (this.eventBuffer.length === 0) {
return;
if (this.eventBuffer.length > 0) {
const batch: SessionReplayBatch = {
batchId: createReplayBatchId(),
userId: this.userId,
events: this.eventBuffer,
metadata: {
pageUrl: window.location.href,
viewportWidth: screen.width,
viewportHeight: screen.height,
language: navigator.language,
},
};
this.eventBuffer = [];
this.pendingBatches.push(batch);
}

const events = [...this.eventBuffer];
this.eventBuffer = [];

const batch: SessionReplayBatch = {
userId: this.userId,
events,
metadata: {
pageUrl: window.location.href,
viewportWidth: screen.width,
viewportHeight: screen.height,
language: navigator.language,
},
};
await this.drainPendingBatches();
}

try {
await this.sendBatch(batch);
} catch (error) {
// Re-queue the events for retry since this batch failed
this.eventBuffer.unshift(...events);
private drainPendingBatches(): Promise<void> {
if (this.sendLoop) {
const activeLoop = this.sendLoop;
return activeLoop.then(() => {
if (this.pendingBatches.length > 0 && this.sendLoop === null) {
return this.drainPendingBatches();
}
});
}

this.sendLoop = (async () => {
while (this.pendingBatches.length > 0) {
try {
await this.sendBatch(this.pendingBatches[0]);
this.pendingBatches.shift();
} catch {
// Keep the exact batch (including identity and batch id) for retry.
return;
}
}
})().finally(() => {
this.sendLoop = null;
});

return this.sendLoop;
}

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Unbounded session-replay retry queue with no backoff, present in source and both build artifacts. drainPendingBatches() retains a failing batch and retries it on every subsequent flush trigger with no cap on pendingBatches and no backoff, so a sustained outage causes unbounded memory growth and fixed-interval hammering of the endpoint. The same logic is duplicated in the compiled bundles.

  • server/src/analytics-script/sessionReplay.ts#L250-L295: add a max size cap on pendingBatches (drop oldest) and/or exponential backoff between retries in SessionReplayRecorder; this is the source of truth to fix.
  • server/public/script-full.js#L432-L471: rebuild from the fixed sessionReplay.ts/tracking.ts so this compiled drainPendingBatches picks up the cap/backoff.
  • server/public/script.js#L1-L1: rebuild from the fixed source so this minified drainPendingBatches picks up the cap/backoff.
📍 Affects 3 files
  • server/src/analytics-script/sessionReplay.ts#L250-L295 (this comment)
  • server/public/script-full.js#L432-L471
  • server/public/script.js#L1-L1
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/analytics-script/sessionReplay.ts` around lines 250 - 295, Update
SessionReplayRecorder.drainPendingBatches in
server/src/analytics-script/sessionReplay.ts:250-295 to bound pendingBatches by
dropping the oldest entries and/or add exponential backoff between failed
sendBatch retries, preserving the failed batch for retry. Rebuild
server/public/script-full.js:432-471 and server/public/script.js:1-1 from the
corrected source so their compiled drainPendingBatches implementations include
the same protection.

Comment on lines 133 to 139
} catch (insertError) {
const errorMessage = insertError instanceof Error ? insertError.message : "Unknown error";
console.error("Failed to insert events:", errorMessage);

if (isLastBatch) {
await completeImport(importId);
importQuotaManager.completeImport(siteRecord.organizationId);
if (errorMessage === "IMPORT_ALREADY_COMPLETED") {
return reply.status(409).send({ error: "Import is already complete" });
}

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Sentinel error matched by exact message string is fragile.

errorMessage === "IMPORT_ALREADY_COMPLETED" relies on exact Error#message equality to route to 409. Any wrapping/rethrowing that alters the message elsewhere in the call chain would silently fall through to the generic 500 branch. As per coding guidelines, "Prefer specific error responses with appropriate status codes over generic throws in request handlers."

🛡️ Proposed fix using a typed error
+class ImportAlreadyCompletedError extends Error {}
+
 await withOrganizationImportLock(organizationId, async () => {
   const currentImport = await getImportById(importId);
   if (!currentImport || currentImport.completedAt !== null) {
-    throw new Error("IMPORT_ALREADY_COMPLETED");
+    throw new ImportAlreadyCompletedError("Import is already complete");
   }
   ...
 });
...
-if (errorMessage === "IMPORT_ALREADY_COMPLETED") {
+if (insertError instanceof ImportAlreadyCompletedError) {
   return reply.status(409).send({ error: "Import is already complete" });
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/api/sites/batchImportEvents.ts` around lines 133 - 139, Replace
the exact message comparison in the batch import handler’s insert-error catch
block with a typed or otherwise stable error discriminator for the
IMPORT_ALREADY_COMPLETED condition. Update the code that raises this condition
to use the same discriminator, and preserve the 409 response while allowing all
other errors to follow the existing generic path.

Source: Coding guidelines

Comment thread server/src/services/tracker/pageviewQueue.ts
- Updated the session replay batch sending logic to throw an error when the server responds with an HTTP error, ensuring better error management.
- Added a test case to verify that session replay delivery is correctly rejected when an HTTP error occurs, improving the robustness of the tracking system.
- Refactored the session replay batch sending method to include response validation, enhancing overall reliability.

@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: 4

Caution

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

⚠️ Outside diff range comments (1)
server/src/db/clickhouse/clickhouse.ts (1)

213-213: 🚀 Performance & Scalability | 🔴 Critical | 🏗️ Heavy lift

Missing skip index on batch_id will cause full table scans.

During ingestion, the session_replay_events table is queried by site_id and batch_id to deduplicate and resolve the associated session_id (as seen in sessionReplayIngestService.ts). However, the table's sorting key is (site_id, session_id, sequence_number). Because batch_id is not in the index, querying it without a session_id forces ClickHouse to perform a linear scan of all sessions for that site. This will cause severe CPU and I/O overhead on every ingestion request for high-traffic sites.

Add a data skipping index (e.g., bloom_filter) for batch_id to allow ClickHouse to skip irrelevant granules.

⚡ Proposed schema fixes

Update the CREATE TABLE definition to include the index:

         viewport_width Nullable(UInt16),
         viewport_height Nullable(UInt16),
-        is_complete UInt8 DEFAULT 0
+        is_complete UInt8 DEFAULT 0,
+        INDEX batch_id_idx batch_id TYPE bloom_filter GRANULARITY 1
       )
       ENGINE = MergeTree()

Additionally, update the ALTER TABLE block around line 236 to ensure existing environments create the index:

       ALTER TABLE session_replay_events
         ADD COLUMN IF NOT EXISTS batch_id String DEFAULT '',
         ADD COLUMN IF NOT EXISTS event_data_key Nullable(String), -- R2 storage key for cloud deployments
         ADD COLUMN IF NOT EXISTS batch_index Nullable(UInt16), -- Index within the R2 batch
-        ADD COLUMN IF NOT EXISTS identified_user_id String DEFAULT ''
+        ADD COLUMN IF NOT EXISTS identified_user_id String DEFAULT '',
+        ADD INDEX IF NOT EXISTS batch_id_idx batch_id TYPE bloom_filter GRANULARITY 1
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/db/clickhouse/clickhouse.ts` at line 213, Add a Bloom filter
data-skipping index for the batch_id column in the session_replay_events CREATE
TABLE definition, and update the corresponding ALTER TABLE migration block to
create the same index for existing environments. Ensure both schema paths use
batch_id consistently.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@server/public/script.js`:
- Line 1: Update the session replay retry flow in F.drainPendingBatches so
failed batches remain queued but retries use capped exponential backoff with
jitter instead of immediately retrying on every timer tick. Track retry state
per pending batch or equivalent, increase the delay after each failure up to a
maximum cap, and reset the backoff when a batch succeeds; preserve ordered
delivery and existing batching behavior.
- Line 1: Update the session replay cleanup flow in class F, specifically
stopRecording(), clearBatchTimer(), and flushEvents(), so the batch timer
remains available until the final flush and its sendBatch retry chain settles.
Ensure failed batches remain pending and retain a retry path instead of clearing
the timer before drainPendingBatches() completes.

In `@server/src/analytics-script/tracking.test.ts`:
- Around line 328-353: Wrap the sendSessionReplayBatch assertion in a
try/finally block and move consoleSpy.mockRestore() into the finally clause,
ensuring console.error is restored whether the assertion passes or throws.

In `@server/src/db/clickhouse/clickhouse.ts`:
- Around line 96-106: Update ensureInsertDeduplication to accept only the
supported table-name string literal union, then query system.tables for the
target table’s current non_replicated_deduplication_window before issuing ALTER
TABLE. Execute the ALTER only when the setting differs from
INSERT_DEDUPLICATION_WINDOW, preserving the existing initialization and lock
options.

---

Outside diff comments:
In `@server/src/db/clickhouse/clickhouse.ts`:
- Line 213: Add a Bloom filter data-skipping index for the batch_id column in
the session_replay_events CREATE TABLE definition, and update the corresponding
ALTER TABLE migration block to create the same index for existing environments.
Ensure both schema paths use batch_id consistently.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 50d21803-1716-4b05-b6ad-68b2d23666ac

📥 Commits

Reviewing files that changed from the base of the PR and between 27abb6a and 7ed1678.

📒 Files selected for processing (19)
  • server/public/script-full.js
  • server/public/script.js
  • server/src/analytics-script/tracking.test.ts
  • server/src/analytics-script/tracking.ts
  • server/src/api/sites/batchImportEvents.test.ts
  • server/src/api/sites/batchImportEvents.ts
  • server/src/api/sites/createSiteImport.ts
  • server/src/db/clickhouse/clickhouse.ts
  • server/src/services/import/importQuotaTracker.test.ts
  • server/src/services/import/importQuotaTracker.ts
  • server/src/services/import/importStatusManager.test.ts
  • server/src/services/import/importStatusManager.ts
  • server/src/services/replay/sessionReplayIngestService.test.ts
  • server/src/services/replay/sessionReplayIngestService.ts
  • server/src/services/tracker/botBlocking/botEventQueue.ts
  • server/src/services/tracker/pageviewQueue.test.ts
  • server/src/services/tracker/pageviewQueue.ts
  • server/src/services/tracker/reliableBatchQueue.test.ts
  • server/src/services/tracker/reliableBatchQueue.ts
🚧 Files skipped from review as they are similar to previous changes (12)
  • server/src/services/tracker/pageviewQueue.test.ts
  • server/src/services/import/importStatusManager.test.ts
  • server/src/api/sites/batchImportEvents.ts
  • server/src/services/tracker/botBlocking/botEventQueue.ts
  • server/src/analytics-script/tracking.ts
  • server/src/services/import/importStatusManager.ts
  • server/src/api/sites/createSiteImport.ts
  • server/src/services/replay/sessionReplayIngestService.test.ts
  • server/public/script-full.js
  • server/src/services/replay/sessionReplayIngestService.ts
  • server/src/services/tracker/pageviewQueue.ts
  • server/src/services/tracker/reliableBatchQueue.ts

Comment thread server/public/script.js
@@ -1 +1 @@
"use strict";(()=>{var De=Object.defineProperty;var Ue=(n,e,t)=>e in n?De(n,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):n[e]=t;var R=(n,e,t)=>Ue(n,typeof e!="symbol"?e+"":e,t);function He(n){if(n.startsWith("re:")){let l=n.slice(3);if(!l)throw new Error("Empty regex pattern");return new RegExp(l)}let t="__DOUBLE_ASTERISK_TOKEN__",r="__SINGLE_ASTERISK_TOKEN__",s=n.replace(/\*\*/g,t).replace(/\*/g,r).replace(/[.+?^${}()|[\]\\]/g,"\\$&");s=s.replace(new RegExp(`/${t}/`,"g"),"/(?:.+/)?"),s=s.replace(new RegExp(t,"g"),".*"),s=s.replace(/\//g,"\\/");let a=s.replace(new RegExp(r,"g"),"[^/]+");return new RegExp("^"+a+"$")}function $(n,e){for(let t of e)try{if(He(t).test(n))return t}catch(r){console.error(`Invalid pattern: ${t}`,r)}return null}function ce(n,e){let t=null;return(...r)=>{t&&clearTimeout(t),t=setTimeout(()=>n(...r),e)}}function le(n){try{let e=window.location.hostname,t=new URL(n).hostname;return t!==e&&t!==""}catch{return!1}}function T(n,e){if(!n)return e;try{let t=JSON.parse(n);return Array.isArray(e)&&!Array.isArray(t)?e:t}catch(t){return console.error("Error parsing JSON:",t),e}}function ue(){try{if(crypto?.randomUUID)return crypto.randomUUID()}catch{}return`${Date.now().toString(36)}-${Math.random().toString(36).slice(2,12)}`}function We(n){let e=`${n}-visitor-id`;try{let t=localStorage.getItem(e);if(t)return t;let r=ue();return localStorage.setItem(e,r),r}catch{return ue()}}function Ve(n){try{return localStorage.getItem(`${n}-user-id`)||void 0}catch{return}}function $e(n){return n.hash&&n.hash.startsWith("#/")?n.hash.substring(1):n.pathname}async function ze(n,e,t,r){try{let i=new URL(window.location.href),s=await fetch(`${n}/site/${e}/feature-flags/evaluate`,{method:"POST",headers:{"Content-Type":"application/json"},credentials:"omit",body:JSON.stringify({anonymousId:r,identifiedUserId:Ve(t),hostname:i.hostname,pathname:$e(i),querystring:i.search,query:Object.fromEntries(i.searchParams.entries()),referrer:document.referrer,language:navigator.language,screenWidth:screen.width,screenHeight:screen.height})});if(!s.ok)return{};let a=await s.json();return a?.flags&&typeof a.flags=="object"?a.flags:{}}catch{return{}}}async function de(n){let e=n.getAttribute("src");if(!e)return console.error("Script src attribute is missing"),null;let t=e.split("/script.js")[0];if(!t)return console.error("Please provide a valid analytics host"),null;let r=n.getAttribute("data-site-id")||n.getAttribute("site-id");if(!r)return console.error("Please provide a valid site ID using the data-site-id attribute"),null;let i=n.getAttribute("data-namespace")||"rybbit",s=We(i),a=T(n.getAttribute("data-skip-patterns"),[]),l=T(n.getAttribute("data-mask-patterns"),[]),d=T(n.getAttribute("data-replay-mask-text-selectors"),[]),u=n.getAttribute("data-debounce")?Math.max(0,parseInt(n.getAttribute("data-debounce"))):500,g=n.getAttribute("data-replay-batch-size")?Math.max(1,parseInt(n.getAttribute("data-replay-batch-size"))):250,h=n.getAttribute("data-replay-batch-interval")?Math.max(1e3,parseInt(n.getAttribute("data-replay-batch-interval"))):5e3,C=n.getAttribute("data-replay-block-class")||void 0,m=n.getAttribute("data-replay-block-selector")||void 0,o=n.getAttribute("data-replay-ignore-class")||void 0,c=n.getAttribute("data-replay-ignore-selector")||void 0,p=n.getAttribute("data-replay-mask-text-class")||void 0,w=n.getAttribute("data-replay-mask-all-inputs"),S=w!==null?w!=="false":void 0,E=n.getAttribute("data-replay-mask-input-options"),F=E?T(E,{password:!0,email:!0}):void 0,ne=n.getAttribute("data-replay-collect-fonts"),Me=ne!==null?ne!=="false":void 0,ie=n.getAttribute("data-replay-sampling"),xe=ie?T(ie,{}):void 0,se=n.getAttribute("data-replay-slim-dom-options"),Be=se?T(se,{}):void 0,ae=n.getAttribute("data-replay-sample-rate"),Oe=ae?Math.min(100,Math.max(0,parseInt(ae,10))):void 0,Ne=n.getAttribute("data-tag")||"",f={namespace:i,analyticsHost:t,siteId:r,visitorId:s,debounceDuration:u,sessionReplayBatchSize:g,sessionReplayBatchInterval:h,sessionReplayMaskTextSelectors:d,skipPatterns:a,maskPatterns:l,autoTrackPageview:!0,autoTrackSpa:!0,trackQuerystring:!0,trackOutbound:!0,enableWebVitals:!1,trackErrors:!1,enableSessionReplay:!1,trackButtonClicks:!1,trackCopy:!1,trackFormInteractions:!1,tag:Ne,featureFlags:{},sessionReplayBlockClass:C,sessionReplayBlockSelector:m,sessionReplayIgnoreClass:o,sessionReplayIgnoreSelector:c,sessionReplayMaskTextClass:p,sessionReplayMaskAllInputs:S,sessionReplayMaskInputOptions:F,sessionReplayCollectFonts:Me,sessionReplaySampling:xe,sessionReplaySlimDOMOptions:Be,sessionReplaySampleRate:Oe},W=f;try{let V=`${t}/site/tracking-config/${r}`,oe=await fetch(V,{method:"GET",credentials:"omit"});if(oe.ok){let y=await oe.json();W={...f,autoTrackPageview:y.trackInitialPageView??f.autoTrackPageview,autoTrackSpa:y.trackSpaNavigation??f.autoTrackSpa,trackQuerystring:y.trackUrlParams??f.trackQuerystring,trackOutbound:y.trackOutbound??f.trackOutbound,enableWebVitals:y.webVitals??f.enableWebVitals,trackErrors:y.trackErrors??f.trackErrors,enableSessionReplay:y.sessionReplay??f.enableSessionReplay,trackButtonClicks:y.trackButtonClicks??f.trackButtonClicks,trackCopy:y.trackCopy??f.trackCopy,trackFormInteractions:y.trackFormInteractions??f.trackFormInteractions}}else console.warn("Failed to fetch tracking config from API, using defaults")}catch(V){console.warn("Error fetching tracking config:",V)}return W.featureFlags=await ze(t,r,i,s),W}var pe="rybbit-replay-sampled";function je(n){if(n>=100)return!0;if(n<=0)return!1;try{let e=sessionStorage.getItem(pe);if(e!==null)return e==="1";let t=Math.random()*100<n;return sessionStorage.setItem(pe,t?"1":"0"),t}catch{return Math.random()*100<n}}var L=class{constructor(e,t,r){this.isRecording=!1;this.eventBuffer=[];this.config=e,this.userId=t,this.sendBatch=r}async initialize(){if(!this.config.enableSessionReplay)return;let e=this.config.sessionReplaySampleRate;e!==void 0&&!je(e)||(window.rrweb||await this.loadRrweb(),window.rrweb&&this.startRecording())}async loadRrweb(){return new Promise((e,t)=>{let r=document.createElement("script");r.src=`${this.config.analyticsHost}/replay.js`,r.async=!1,r.onload=()=>{e()},r.onerror=()=>t(new Error("Failed to load rrweb")),document.head.appendChild(r)})}startRecording(){if(!(this.isRecording||!window.rrweb||!this.config.enableSessionReplay))try{let e={mousemove:!1,mouseInteraction:{MouseUp:!1,MouseDown:!1,Click:!0,ContextMenu:!1,DblClick:!0,Focus:!0,Blur:!0,TouchStart:!1,TouchEnd:!1},scroll:500,input:"last",media:800},t={script:!1,comment:!0,headFavicon:!0,headWhitespace:!0,headMetaDescKeywords:!0,headMetaSocial:!0,headMetaRobots:!0,headMetaHttpEquiv:!0,headMetaAuthorship:!0,headMetaVerification:!0},r={emit:i=>{this.addEvent({type:i.type,data:i.data,timestamp:i.timestamp||Date.now()})},recordCanvas:!1,checkoutEveryNms:6e4,checkoutEveryNth:500,blockClass:this.config.sessionReplayBlockClass??"rr-block",blockSelector:this.config.sessionReplayBlockSelector??null,ignoreClass:this.config.sessionReplayIgnoreClass??"rr-ignore",ignoreSelector:this.config.sessionReplayIgnoreSelector??null,maskTextClass:this.config.sessionReplayMaskTextClass??"rr-mask",maskAllInputs:this.config.sessionReplayMaskAllInputs??!0,maskInputOptions:this.config.sessionReplayMaskInputOptions??{password:!0,email:!0},collectFonts:this.config.sessionReplayCollectFonts??!0,sampling:this.config.sessionReplaySampling??e,slimDOMOptions:this.config.sessionReplaySlimDOMOptions??t};this.config.sessionReplayMaskTextSelectors&&this.config.sessionReplayMaskTextSelectors.length>0&&(r.maskTextSelector=this.config.sessionReplayMaskTextSelectors.join(", ")),this.stopRecordingFn=window.rrweb.record(r),this.isRecording=!0,this.setupBatchTimer()}catch{}}stopRecording(){this.isRecording&&(this.stopRecordingFn&&this.stopRecordingFn(),this.isRecording=!1,this.clearBatchTimer(),this.eventBuffer.length>0&&this.flushEvents())}isActive(){return this.isRecording}addEvent(e){this.eventBuffer.push(e),this.eventBuffer.length>=this.config.sessionReplayBatchSize&&this.flushEvents()}setupBatchTimer(){this.clearBatchTimer(),this.batchTimer=window.setInterval(()=>{this.eventBuffer.length>0&&this.flushEvents()},this.config.sessionReplayBatchInterval)}clearBatchTimer(){this.batchTimer&&(clearInterval(this.batchTimer),this.batchTimer=void 0)}async flushEvents(){if(this.eventBuffer.length===0)return;let e=[...this.eventBuffer];this.eventBuffer=[];let t={userId:this.userId,events:e,metadata:{pageUrl:window.location.href,viewportWidth:screen.width,viewportHeight:screen.height,language:navigator.language}};try{await this.sendBatch(t)}catch{this.eventBuffer.unshift(...e)}}updateUserId(e){e!==this.userId&&(this.eventBuffer.length>0&&this.flushEvents(),this.userId=e)}onPageChange(){this.isRecording&&this.flushEvents()}cleanup(){this.stopRecording()}};var b={automationApi:1,webdriver:1,zeroOuterDimensions:2,missingChrome:4,swiftShader:8,emptyPlugins:16,defaultViewport800x600:32,defaultViewport1024x768:64,impossibleDimensions:128,outerDimensionsWeird:256,pluginApiAbsence:512},ge=null,Ge=10;function he(){return me().score}function fe(){return me().mask}function me(){return ge??(ge=Ke()),ge}function Ke(){let n=0,e=0;function t(r,i){(e&r)===0&&(e|=r,n+=i)}try{let r=navigator.userAgent,i=/Chrome\//.test(r)&&!/\bwv\b|; wv\)/.test(r),s=/Windows NT|Macintosh|X11|Linux x86_64/.test(r)&&!/Mobile|Android|iPhone|iPad/.test(r),a=Number(window.screen?.width),l=Number(window.screen?.height),d=Number(window.outerWidth),u=Number(window.outerHeight),g=Number(window.innerWidth),h=Number(window.innerHeight),m=["__webdriver_evaluate","__selenium_evaluate","__webdriver_script_function","__webdriver_script_func","__webdriver_script_fn","__fxdriver_evaluate","__driver_unwrapped","__webdriver_unwrapped","__driver_evaluate","__selenium_unwrapped","__fxdriver_unwrapped","_phantom","callPhantom","__nightmare","domAutomation","domAutomationController"].some(c=>c in window||c in document);(navigator.webdriver===!0||m)&&t(b.automationApi,3),(u===0||d===0)&&t(b.zeroOuterDimensions,2),(!Number.isFinite(a)||!Number.isFinite(l)||a<=0||l<=0||a>1e5||l>1e5)&&t(b.impossibleDimensions,3),s&&a===800&&l===600&&t(b.defaultViewport800x600,3),s&&a===1024&&l===768&&t(b.defaultViewport1024x768,3),Number.isFinite(d)&&Number.isFinite(u)&&Number.isFinite(g)&&Number.isFinite(h)&&d>0&&u>0&&g>0&&h>0&&(d+8<g||u+8<h)&&t(b.outerDimensionsWeird,2);let o=!1;!window.chrome&&i&&(t(b.missingChrome,1),o=!0);try{let c=document.createElement("canvas"),p=c.getContext("webgl")||c.getContext("experimental-webgl");if(p)try{let w=[],S=p.getParameter(p.RENDERER);typeof S=="string"&&w.push(S);try{let E=p.getExtension("WEBGL_debug_renderer_info");if(E){let F=p.getParameter(E.UNMASKED_RENDERER_WEBGL);typeof F=="string"&&w.push(F)}}catch{}w.join(" ").toLowerCase().includes("swiftshader")&&t(b.swiftShader,1)}finally{Je(c,p)}}catch{}(!navigator.plugins||navigator.plugins.length===0)&&i&&(t(b.emptyPlugins,1),o=!0),o&&t(b.pluginApiAbsence,0)}catch{}return{score:Math.min(n,Ge),mask:e}}function Je(n,e){try{e.getExtension("WEBGL_lose_context")?.loseContext?.()}catch{}n.width=0,n.height=0}var M=class{constructor(e){this.customUserId=null;this.errorDedupeCache=new Map;this.errorDedupeLastCleanup=0;this.exposedFeatureFlags=new Set;this.config=e,this.loadUserId(),e.enableSessionReplay&&this.initializeSessionReplay()}serializeFeatureFlagValue(e){if(e==null)return"";if(typeof e=="string")return e;if(typeof e=="number"||typeof e=="boolean")return String(e);try{return JSON.stringify(e)}catch{return""}}getFeatureFlagEventPayload(){let e={};for(let[t,r]of Object.entries(this.config.featureFlags||{}))e[t]=this.serializeFeatureFlagValue(r.value);return e}getCurrentUrlContext(){let e=new URL(window.location.href),t=e.hash&&e.hash.startsWith("#/")?e.hash.substring(1):e.pathname;return{hostname:e.hostname,pathname:t,querystring:e.search,query:Object.fromEntries(e.searchParams.entries()),referrer:document.referrer,language:navigator.language,screenWidth:screen.width,screenHeight:screen.height}}async refreshFeatureFlags(){try{let e=await fetch(`${this.config.analyticsHost}/site/${this.config.siteId}/feature-flags/evaluate`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({anonymousId:this.config.visitorId,identifiedUserId:this.customUserId||void 0,...this.getCurrentUrlContext()}),mode:"cors",credentials:"omit",keepalive:!0});if(!e.ok)return;let t=await e.json();this.config.featureFlags=t?.flags&&typeof t.flags=="object"?t.flags:{}}catch{}}loadUserId(){try{let e=localStorage.getItem(`${this.config.namespace}-user-id`);e&&(this.customUserId=e)}catch{}}async initializeSessionReplay(){try{this.sessionReplayRecorder=new L(this.config,this.customUserId||"",e=>this.sendSessionReplayBatch(e)),await this.sessionReplayRecorder.initialize()}catch(e){console.error("Failed to initialize session replay:",e)}}async sendSessionReplayBatch(e){try{await fetch(`${this.config.analyticsHost}/session-replay/record/${this.config.siteId}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e),mode:"cors",keepalive:!1})}catch(t){throw console.error("Failed to send session replay batch:",t),t}}createBasePayload(){let e=new URL(window.location.href),t=e.pathname;if(e.hash&&e.hash.startsWith("#/")&&(t=e.hash.substring(1)),$(t,this.config.skipPatterns))return null;let r=$(t,this.config.maskPatterns);r&&(t=r);let i={site_id:this.config.siteId,hostname:e.hostname,pathname:t,querystring:this.config.trackQuerystring?e.search:"",screenWidth:screen.width,screenHeight:screen.height,language:navigator.language,page_title:document.title,referrer:document.referrer,_bs:he(),_bsm:fe()};this.customUserId&&(i.user_id=this.customUserId),this.config.tag&&(i.tag=this.config.tag);let s=this.getFeatureFlagEventPayload();return Object.keys(s).length>0&&(i.feature_flags=s),i}async sendTrackingData(e){try{await fetch(`${this.config.analyticsHost}/track`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e),mode:"cors",keepalive:!0})}catch(t){console.error("Failed to send tracking data:",t)}}track(e,t="",r={}){if(e==="custom_event"&&(!t||typeof t!="string")){console.error("Event name is required and must be a string for custom events");return}let i=this.createBasePayload();if(!i)return;let a={...i,type:e,event_name:t,properties:["custom_event","outbound","error","button_click","copy","form_submit","input_change"].includes(e)?JSON.stringify(r):void 0};this.sendTrackingData(a)}trackPageview(){this.track("pageview")}trackEvent(e,t={}){this.track("custom_event",e,t)}getFeatureFlag(e,t){let r=this.config.featureFlags?.[e];if(!r)return t;let i=`${e}:${r.version}:${this.serializeFeatureFlagValue(r.value)}`;return this.exposedFeatureFlags.has(i)||(this.exposedFeatureFlags.add(i),this.trackEvent("feature_flag_exposure",{key:e,value:this.serializeFeatureFlagValue(r.value),version:r.version,reason:r.reason})),r.value}getFeatureFlags(){return Object.fromEntries(Object.entries(this.config.featureFlags||{}).map(([e,t])=>[e,t.value]))}getFeatureFlagPayload(e,t){let r=this.config.featureFlags?.[e];return!r||r.payload===void 0?t:r.payload}getFeatureFlagPayloads(){return Object.fromEntries(Object.entries(this.config.featureFlags||{}).filter(([,e])=>e.payload!==void 0).map(([e,t])=>[e,t.payload]))}trackOutbound(e,t="",r="_self"){this.track("outbound","",{url:e,text:t,target:r})}trackWebVitals(e){let t=this.createBasePayload();if(!t)return;let r={...t,type:"performance",event_name:"web-vitals",...e};this.sendTrackingData(r)}trackError(e,t={}){let r=e?.message||"";if(r.includes("ResizeObserver loop completed with undelivered notifications")||r.includes("ResizeObserver loop limit exceeded"))return;let i=window.location.origin,s=t.filename||"",a=e.stack||"";if(s)try{if(new URL(s).origin!==i)return}catch{}else if(a&&!a.includes(i))return;let d=[e.name||"Error",r,t.filename||"",t.lineno??"",t.colno??""].join("|"),u=Date.now(),g=6e4,h=this.errorDedupeCache.get(d);if(h&&u-h<g)return;this.errorDedupeCache.set(d,u);let C=10*6e4;if(u-this.errorDedupeLastCleanup>g){for(let[o,c]of this.errorDedupeCache.entries())u-c>C&&this.errorDedupeCache.delete(o);this.errorDedupeLastCleanup=u}let m={message:e.message?.substring(0,500)||"Unknown error",stack:a.substring(0,2e3)||""};if(s&&(m.fileName=s),t.lineno){let o=typeof t.lineno=="string"?parseInt(t.lineno,10):t.lineno;o&&o!==0&&(m.lineNumber=o)}if(t.colno){let o=typeof t.colno=="string"?parseInt(t.colno,10):t.colno;o&&o!==0&&(m.columnNumber=o)}for(let o in t)!["lineno","colno"].includes(o)&&t[o]!==void 0&&(m[o]=t[o]);this.track("error",e.name||"Error",m)}trackButtonClick(e){this.track("button_click","",e)}trackCopy(e){this.track("copy","",e)}trackFormSubmit(e){this.track("form_submit","",e)}trackInputChange(e){this.track("input_change","",e)}identify(e,t){if(typeof e!="string"||e.trim()===""){console.error("User ID must be a non-empty string");return}this.customUserId=e.trim();try{localStorage.setItem(`${this.config.namespace}-user-id`,this.customUserId)}catch{console.warn("Could not persist user ID to localStorage")}this.sendIdentifyEvent(this.customUserId,t,!0).then(()=>this.refreshFeatureFlags()),this.sessionReplayRecorder&&this.sessionReplayRecorder.updateUserId(this.customUserId)}setTraits(e){if(!e||typeof e!="object"){console.error("Traits must be an object");return}let t=this.customUserId;if(!t){console.warn("Cannot set traits without identifying user first. Call identify() first.");return}this.sendIdentifyEvent(t,e,!1).then(()=>this.refreshFeatureFlags())}async sendIdentifyEvent(e,t,r=!0){try{await fetch(`${this.config.analyticsHost}/identify`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({site_id:this.config.siteId,user_id:e,traits:t,is_new_identify:r}),mode:"cors",keepalive:!0})}catch(i){console.error("Failed to send identify event:",i)}}clearUserId(){this.customUserId=null;try{localStorage.removeItem(`${this.config.namespace}-user-id`)}catch{}this.sessionReplayRecorder&&this.sessionReplayRecorder.updateUserId(""),this.refreshFeatureFlags()}getUserId(){return this.customUserId}startSessionReplay(){this.sessionReplayRecorder?this.sessionReplayRecorder.startRecording():console.warn("Session replay not initialized")}stopSessionReplay(){this.sessionReplayRecorder&&this.sessionReplayRecorder.stopRecording()}isSessionReplayActive(){return this.sessionReplayRecorder?.isActive()??!1}onPageChange(){this.refreshFeatureFlags(),this.sessionReplayRecorder&&this.sessionReplayRecorder.onPageChange()}cleanup(){this.sessionReplayRecorder&&this.sessionReplayRecorder.cleanup()}};var Te=-1,I=n=>{addEventListener("pageshow",(e=>{e.persisted&&(Te=e.timeStamp,n(e))}),!0)},v=(n,e,t,r)=>{let i,s;return a=>{e.value>=0&&(a||r)&&(s=e.value-(i??0),(s||i===void 0)&&(i=e.value,e.delta=s,e.rating=((l,d)=>l>d[1]?"poor":l>d[0]?"needs-improvement":"good")(e.value,t),n(e)))}},Y=n=>{requestAnimationFrame((()=>requestAnimationFrame((()=>n()))))},Z=()=>{let n=performance.getEntriesByType("navigation")[0];if(n&&n.responseStart>0&&n.responseStart<performance.now())return n},_=()=>Z()?.activationStart??0,k=(n,e=-1)=>{let t=Z(),r="navigate";return Te>=0?r="back-forward-cache":t&&(document.prerendering||_()>0?r="prerender":document.wasDiscarded?r="restore":t.type&&(r=t.type.replace(/_/g,"-"))),{name:n,value:e,rating:"good",delta:0,entries:[],id:`v5-${Date.now()}-${Math.floor(8999999999999*Math.random())+1e12}`,navigationType:r}},z=new WeakMap;function ee(n,e){return z.get(n)||z.set(n,new e),z.get(n)}var G=class{constructor(){R(this,"t");R(this,"i",0);R(this,"o",[])}h(e){if(e.hadRecentInput)return;let t=this.o[0],r=this.o.at(-1);this.i&&t&&r&&e.startTime-r.startTime<1e3&&e.startTime-t.startTime<5e3?(this.i+=e.value,this.o.push(e)):(this.i=e.value,this.o=[e]),this.t?.(e)}},A=(n,e,t={})=>{try{if(PerformanceObserver.supportedEntryTypes.includes(n)){let r=new PerformanceObserver((i=>{Promise.resolve().then((()=>{e(i.getEntries())}))}));return r.observe({type:n,buffered:!0,...t}),r}}catch{}},te=n=>{let e=!1;return()=>{e||(n(),e=!0)}},P=-1,Ce=new Set,ye=()=>document.visibilityState!=="hidden"||document.prerendering?1/0:0,K=n=>{if(document.visibilityState==="hidden"){if(n.type==="visibilitychange")for(let e of Ce)e();isFinite(P)||(P=n.type==="visibilitychange"?n.timeStamp:0,removeEventListener("prerenderingchange",K,!0))}},B=()=>{if(P<0){let n=_();P=(document.prerendering?void 0:globalThis.performance.getEntriesByType("visibility-state").filter((t=>t.name==="hidden"&&t.startTime>n))[0]?.startTime)??ye(),addEventListener("visibilitychange",K,!0),addEventListener("prerenderingchange",K,!0),I((()=>{setTimeout((()=>{P=ye()}))}))}return{get firstHiddenTime(){return P},onHidden(n){Ce.add(n)}}},O=n=>{document.prerendering?addEventListener("prerenderingchange",(()=>n()),!0):n()},be=[1800,3e3],re=(n,e={})=>{O((()=>{let t=B(),r,i=k("FCP"),s=A("paint",(a=>{for(let l of a)l.name==="first-contentful-paint"&&(s.disconnect(),l.startTime<t.firstHiddenTime&&(i.value=Math.max(l.startTime-_(),0),i.entries.push(l),r(!0)))}));s&&(r=v(n,i,be,e.reportAllChanges),I((a=>{i=k("FCP"),r=v(n,i,be,e.reportAllChanges),Y((()=>{i.value=performance.now()-a.timeStamp,r(!0)}))})))}))},ve=[.1,.25],Pe=(n,e={})=>{let t=B();re(te((()=>{let r,i=k("CLS",0),s=ee(e,G),a=d=>{for(let u of d)s.h(u);s.i>i.value&&(i.value=s.i,i.entries=s.o,r())},l=A("layout-shift",a);l&&(r=v(n,i,ve,e.reportAllChanges),t.onHidden((()=>{a(l.takeRecords()),r(!0)})),I((()=>{s.i=0,i=k("CLS",0),r=v(n,i,ve,e.reportAllChanges),Y((()=>r()))})),setTimeout(r))})))},Ie=0,j=1/0,x=0,qe=n=>{for(let e of n)e.interactionId&&(j=Math.min(j,e.interactionId),x=Math.max(x,e.interactionId),Ie=x?(x-j)/7+1:0)},J,ke=()=>J?Ie:performance.interactionCount??0,Xe=()=>{"interactionCount"in performance||J||(J=A("event",qe,{type:"event",buffered:!0,durationThreshold:0}))},we=0,q=class{constructor(){R(this,"u",[]);R(this,"l",new Map);R(this,"m");R(this,"p")}v(){we=ke(),this.u.length=0,this.l.clear()}L(){let e=Math.min(this.u.length-1,Math.floor((ke()-we)/50));return this.u[e]}h(e){if(this.m?.(e),!e.interactionId&&e.entryType!=="first-input")return;let t=this.u.at(-1),r=this.l.get(e.interactionId);if(r||this.u.length<10||e.duration>t.P){if(r?e.duration>r.P?(r.entries=[e],r.P=e.duration):e.duration===r.P&&e.startTime===r.entries[0].startTime&&r.entries.push(e):(r={id:e.interactionId,entries:[e],P:e.duration},this.l.set(r.id,r),this.u.push(r)),this.u.sort(((i,s)=>s.P-i.P)),this.u.length>10){let i=this.u.splice(10);for(let s of i)this.l.delete(s.id)}this.p?.(r)}}},_e=n=>{let e=globalThis.requestIdleCallback||setTimeout;document.visibilityState==="hidden"?n():(n=te(n),addEventListener("visibilitychange",n,{once:!0,capture:!0}),e((()=>{n(),removeEventListener("visibilitychange",n,{capture:!0})})))},Re=[200,500],Ae=(n,e={})=>{if(!globalThis.PerformanceEventTiming||!("interactionId"in PerformanceEventTiming.prototype))return;let t=B();O((()=>{Xe();let r,i=k("INP"),s=ee(e,q),a=d=>{_e((()=>{for(let g of d)s.h(g);let u=s.L();u&&u.P!==i.value&&(i.value=u.P,i.entries=u.entries,r())}))},l=A("event",a,{durationThreshold:e.durationThreshold??40});r=v(n,i,Re,e.reportAllChanges),l&&(l.observe({type:"first-input",buffered:!0}),t.onHidden((()=>{a(l.takeRecords()),r(!0)})),I((()=>{s.v(),i=k("INP"),r=v(n,i,Re,e.reportAllChanges)})))}))},X=class{constructor(){R(this,"m")}h(e){this.m?.(e)}},Se=[2500,4e3],Fe=(n,e={})=>{O((()=>{let t=B(),r,i=k("LCP"),s=ee(e,X),a=d=>{e.reportAllChanges||(d=d.slice(-1));for(let u of d)s.h(u),u.startTime<t.firstHiddenTime&&(i.value=Math.max(u.startTime-_(),0),i.entries=[u],r())},l=A("largest-contentful-paint",a);if(l){r=v(n,i,Se,e.reportAllChanges);let d=te((()=>{a(l.takeRecords()),l.disconnect(),r(!0)})),u=g=>{g.isTrusted&&(_e(d),removeEventListener(g.type,u,{capture:!0}))};for(let g of["keydown","click","visibilitychange"])addEventListener(g,u,{capture:!0});I((g=>{i=k("LCP"),r=v(n,i,Se,e.reportAllChanges),Y((()=>{i.value=performance.now()-g.timeStamp,r(!0)}))}))}}))},Ee=[800,1800],Q=n=>{document.prerendering?O((()=>Q(n))):document.readyState!=="complete"?addEventListener("load",(()=>Q(n)),!0):setTimeout(n)},Le=(n,e={})=>{let t=k("TTFB"),r=v(n,t,Ee,e.reportAllChanges);Q((()=>{let i=Z();i&&(t.value=Math.max(i.responseStart-_(),0),t.entries=[i],r(!0),I((()=>{t=k("TTFB",0),r=v(n,t,Ee,e.reportAllChanges),r(!0)})))}))};var N=class{constructor(e){this.data={lcp:null,cls:null,inp:null,fcp:null,ttfb:null};this.sent=!1;this.timeout=null;this.onReadyCallback=null;this.onReadyCallback=e}initialize(){try{Fe(this.collectMetric.bind(this)),Pe(this.collectMetric.bind(this)),Ae(this.collectMetric.bind(this)),re(this.collectMetric.bind(this)),Le(this.collectMetric.bind(this)),this.timeout=setTimeout(()=>{this.sent||this.sendData()},2e4),window.addEventListener("beforeunload",()=>{this.sent||this.sendData()})}catch(e){console.warn("Error initializing web vitals tracking:",e)}}collectMetric(e){if(this.sent)return;let t=e.name.toLowerCase();this.data[t]=e.value,Object.values(this.data).every(i=>i!==null)&&this.sendData()}sendData(){this.sent||(this.sent=!0,this.timeout&&(clearTimeout(this.timeout),this.timeout=null),this.onReadyCallback&&this.onReadyCallback(this.data))}getData(){return{...this.data}}};var D=class{constructor(e,t){this.tracker=e,this.config=t}initialize(){document.addEventListener("click",this.handleClick.bind(this),!0)}handleClick(e){let t=e.target;this.config.trackButtonClicks&&this.isButton(t)&&this.trackButtonClick(t)}isButton(e){if(e.tagName==="BUTTON"||e.getAttribute("role")==="button")return!0;if(e.tagName==="INPUT"){let i=e.type?.toLowerCase();if(i==="submit"||i==="button")return!0}let t=e.parentElement,r=0;for(;t&&r<3;){if(t.tagName==="BUTTON"||t.getAttribute("role")==="button")return!0;t=t.parentElement,r++}return!1}trackButtonClick(e){let t=this.findButton(e);if(!t||t.hasAttribute("data-rybbit-event"))return;let r={text:this.getElementText(t),...this.extractDataAttributes(t)};this.tracker.trackButtonClick(r)}extractDataAttributes(e){let t={};for(let r of e.attributes)if(r.name.startsWith("data-rybbit-prop-")){let i=r.name.replace("data-rybbit-prop-","");t[i]=r.value}return t}findButton(e){if(e.tagName==="BUTTON"||e.getAttribute("role")==="button")return e;if(e.tagName==="INPUT"){let i=e.type?.toLowerCase();if(i==="submit"||i==="button")return e}let t=e.parentElement,r=0;for(;t&&r<3;){if(t.tagName==="BUTTON"||t.getAttribute("role")==="button")return t;t=t.parentElement,r++}return null}getElementText(e){let t=e.textContent?.trim().substring(0,100);if(t)return t;let r=e.getAttribute("aria-label")?.trim().substring(0,100);if(r)return r;if(e.tagName==="INPUT"){let s=e.value?.trim().substring(0,100);if(s)return s}let i=e.getAttribute("title")?.trim().substring(0,100);if(i)return i}cleanup(){document.removeEventListener("click",this.handleClick.bind(this),!0)}};var U=class{constructor(e){this.tracker=e}initialize(){document.addEventListener("copy",this.handleCopy.bind(this))}handleCopy(){let e=window.getSelection();if(!e||e.isCollapsed)return;let t=e.toString(),r=t.length;if(r===0)return;let i=e.anchorNode,s=i instanceof HTMLElement?i:i?.parentElement;if(!s)return;let a={text:t.substring(0,500),...r>500&&{textLength:r},sourceElement:s.tagName.toLowerCase()};this.tracker.trackCopy(a)}cleanup(){document.removeEventListener("copy",this.handleCopy.bind(this))}};var H=class{constructor(e,t){this.tracker=e,this.config=t,this.boundHandleSubmit=this.handleSubmit.bind(this),this.boundHandleChange=this.handleChange.bind(this)}initialize(){document.addEventListener("submit",this.boundHandleSubmit,!0),document.addEventListener("change",this.boundHandleChange,!0)}cleanup(){document.removeEventListener("submit",this.boundHandleSubmit,!0),document.removeEventListener("change",this.boundHandleChange,!0)}handleSubmit(e){let t=e.target;if(t.tagName!=="FORM")return;let r={formId:t.id||"",formName:t.name||"",formAction:t.action||"",method:(t.method||"get").toUpperCase(),fieldCount:t.elements.length,ariaLabel:t.getAttribute("aria-label")||void 0,...this.extractDataAttributes(t)};this.tracker.trackFormSubmit(r)}handleChange(e){let t=e.target,r=t.tagName.toUpperCase();if(!["INPUT","SELECT","TEXTAREA"].includes(r)||t.disabled||t.readOnly)return;if(r==="INPUT"){let a=t.type?.toLowerCase();if(a==="hidden"||a==="password")return}let i=t.name||t.id||t.getAttribute("aria-label")||t.placeholder||"",s={element:r.toLowerCase(),inputType:r==="INPUT"?t.type?.toLowerCase():void 0,inputName:i,formId:t.form?.id||void 0,formName:t.form?.name||void 0,...this.extractDataAttributes(t)};this.tracker.trackInputChange(s)}extractDataAttributes(e){let t={};for(let r of e.attributes)if(r.name.startsWith("data-rybbit-prop-")){let i=r.name.replace("data-rybbit-prop-","");t[i]=r.value}return t}};(async function(){let n=document.currentScript;if(!n){console.error("Could not find current script tag");return}let e=n.getAttribute("data-namespace")||"rybbit",t=`disable-${e}`;if(window.__RYBBIT_OPTOUT__||localStorage.getItem(t)!==null){window[e]={pageview:()=>{},event:()=>{},error:()=>{},trackOutbound:()=>{},identify:()=>{},setTraits:()=>{},clearUserId:()=>{},getUserId:()=>null,flag:(o,c)=>c,flagPayload:(o,c)=>c,flags:()=>({}),flagPayloads:()=>({}),onReady:()=>{},startSessionReplay:()=>{},stopSessionReplay:()=>{},isSessionReplayActive:()=>!1};return}let r=[],i=o=>(...c)=>{r.push([o,c])};window[e]={pageview:i("pageview"),event:i("event"),error:i("error"),trackOutbound:i("trackOutbound"),identify:i("identify"),setTraits:i("setTraits"),clearUserId:i("clearUserId"),getUserId:()=>null,flag:(o,c)=>c,flagPayload:(o,c)=>c,flags:()=>({}),flagPayloads:()=>({}),onReady:i("onReady"),startSessionReplay:i("startSessionReplay"),stopSessionReplay:i("stopSessionReplay"),isSessionReplayActive:()=>!1};let s=await de(n);if(!s)return;let a=new M(s);s.enableWebVitals&&new N(c=>{a.trackWebVitals(c)}).initialize();let l=null,d=null,u=null;s.trackButtonClicks&&(l=new D(a,s),l.initialize()),s.trackCopy&&(d=new U(a),d.initialize()),s.trackFormInteractions&&(u=new H(a,s),u.initialize()),s.trackErrors&&(window.addEventListener("error",o=>{a.trackError(o.error||new Error(o.message),{filename:o.filename,lineno:o.lineno,colno:o.colno})}),window.addEventListener("unhandledrejection",o=>{let c=o.reason instanceof Error?o.reason:new Error(String(o.reason));a.trackError(c,{type:"unhandledrejection"})}));let g=()=>a.trackPageview(),h=s.debounceDuration>0?ce(g,s.debounceDuration):g;function C(){if(document.addEventListener("click",function(o){let c=o.target;for(;c&&c!==document.documentElement;){if(c.hasAttribute("data-rybbit-event")){let p=c.getAttribute("data-rybbit-event");if(p){let w={};for(let S of c.attributes)if(S.name.startsWith("data-rybbit-prop-")){let E=S.name.replace("data-rybbit-prop-","");w[E]=S.value}a.trackEvent(p,w)}break}c=c.parentElement}if(s.trackOutbound){let p=o.target.closest("a");p?.href&&le(p.href)&&a.trackOutbound(p.href,p.innerText||p.textContent||"",p.target||"_self")}}),s.autoTrackSpa){let o=history.pushState,c=history.replaceState;history.pushState=function(...p){o.apply(this,p),h(),a.onPageChange()},history.replaceState=function(...p){c.apply(this,p),h(),a.onPageChange()},window.addEventListener("popstate",()=>{h(),a.onPageChange()}),window.addEventListener("hashchange",()=>{h(),a.onPageChange()})}}window[s.namespace]={pageview:()=>a.trackPageview(),event:(o,c={})=>a.trackEvent(o,c),error:(o,c={})=>a.trackError(o,c),trackOutbound:(o,c="",p="_self")=>a.trackOutbound(o,c,p),identify:(o,c)=>a.identify(o,c),setTraits:o=>a.setTraits(o),clearUserId:()=>a.clearUserId(),getUserId:()=>a.getUserId(),flag:(o,c)=>a.getFeatureFlag(o,c),flagPayload:(o,c)=>a.getFeatureFlagPayload(o,c),flags:()=>a.getFeatureFlags(),flagPayloads:()=>a.getFeatureFlagPayloads(),onReady:o=>o(window[s.namespace]),startSessionReplay:()=>a.startSessionReplay(),stopSessionReplay:()=>a.stopSessionReplay(),isSessionReplayActive:()=>a.isSessionReplayActive()};let m=window[s.namespace];for(let[o,c]of r)m[o](...c);C(),window.addEventListener("beforeunload",()=>{l?.cleanup(),d?.cleanup(),a.cleanup()}),s.autoTrackPageview&&a.trackPageview()})();})();
"use strict";(()=>{var De=Object.defineProperty;var Ue=(r,e,t)=>e in r?De(r,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):r[e]=t;var R=(r,e,t)=>Ue(r,typeof e!="symbol"?e+"":e,t);function He(r){if(r.startsWith("re:")){let l=r.slice(3);if(!l)throw new Error("Empty regex pattern");return new RegExp(l)}let t="__DOUBLE_ASTERISK_TOKEN__",n="__SINGLE_ASTERISK_TOKEN__",s=r.replace(/\*\*/g,t).replace(/\*/g,n).replace(/[.+?^${}()|[\]\\]/g,"\\$&");s=s.replace(new RegExp(`/${t}/`,"g"),"/(?:.+/)?"),s=s.replace(new RegExp(t,"g"),".*"),s=s.replace(/\//g,"\\/");let a=s.replace(new RegExp(n,"g"),"[^/]+");return new RegExp("^"+a+"$")}function j(r,e){for(let t of e)try{if(He(t).test(r))return t}catch(n){console.error(`Invalid pattern: ${t}`,n)}return null}function ce(r,e){let t=null;return(...n)=>{t&&clearTimeout(t),t=setTimeout(()=>r(...n),e)}}function le(r){try{let e=window.location.hostname,t=new URL(r).hostname;return t!==e&&t!==""}catch{return!1}}function E(r,e){if(!r)return e;try{let t=JSON.parse(r);return Array.isArray(e)&&!Array.isArray(t)?e:t}catch(t){return console.error("Error parsing JSON:",t),e}}function ue(){try{if(crypto?.randomUUID)return crypto.randomUUID()}catch{}return`${Date.now().toString(36)}-${Math.random().toString(36).slice(2,12)}`}function We(r){let e=`${r}-visitor-id`;try{let t=localStorage.getItem(e);if(t)return t;let n=ue();return localStorage.setItem(e,n),n}catch{return ue()}}function $e(r){try{return localStorage.getItem(`${r}-user-id`)||void 0}catch{return}}function je(r){return r.hash&&r.hash.startsWith("#/")?r.hash.substring(1):r.pathname}async function Ve(r,e,t,n){try{let i=new URL(window.location.href),s=await fetch(`${r}/site/${e}/feature-flags/evaluate`,{method:"POST",headers:{"Content-Type":"application/json"},credentials:"omit",body:JSON.stringify({anonymousId:n,identifiedUserId:$e(t),hostname:i.hostname,pathname:je(i),querystring:i.search,query:Object.fromEntries(i.searchParams.entries()),referrer:document.referrer,language:navigator.language,screenWidth:screen.width,screenHeight:screen.height})});if(!s.ok)return{};let a=await s.json();return a?.flags&&typeof a.flags=="object"?a.flags:{}}catch{return{}}}async function de(r){let e=r.getAttribute("src");if(!e)return console.error("Script src attribute is missing"),null;let t=e.split("/script.js")[0];if(!t)return console.error("Please provide a valid analytics host"),null;let n=r.getAttribute("data-site-id")||r.getAttribute("site-id");if(!n)return console.error("Please provide a valid site ID using the data-site-id attribute"),null;let i=r.getAttribute("data-namespace")||"rybbit",s=We(i),a=E(r.getAttribute("data-skip-patterns"),[]),l=E(r.getAttribute("data-mask-patterns"),[]),d=E(r.getAttribute("data-replay-mask-text-selectors"),[]),u=r.getAttribute("data-debounce")?Math.max(0,parseInt(r.getAttribute("data-debounce"))):500,h=r.getAttribute("data-replay-batch-size")?Math.max(1,parseInt(r.getAttribute("data-replay-batch-size"))):250,g=r.getAttribute("data-replay-batch-interval")?Math.max(1e3,parseInt(r.getAttribute("data-replay-batch-interval"))):5e3,C=r.getAttribute("data-replay-block-class")||void 0,m=r.getAttribute("data-replay-block-selector")||void 0,o=r.getAttribute("data-replay-ignore-class")||void 0,c=r.getAttribute("data-replay-ignore-selector")||void 0,p=r.getAttribute("data-replay-mask-text-class")||void 0,w=r.getAttribute("data-replay-mask-all-inputs"),S=w!==null?w!=="false":void 0,T=r.getAttribute("data-replay-mask-input-options"),A=T?E(T,{password:!0,email:!0}):void 0,re=r.getAttribute("data-replay-collect-fonts"),xe=re!==null?re!=="false":void 0,ie=r.getAttribute("data-replay-sampling"),Me=ie?E(ie,{}):void 0,se=r.getAttribute("data-replay-slim-dom-options"),Be=se?E(se,{}):void 0,ae=r.getAttribute("data-replay-sample-rate"),Oe=ae?Math.min(100,Math.max(0,parseInt(ae,10))):void 0,Ne=r.getAttribute("data-tag")||"",f={namespace:i,analyticsHost:t,siteId:n,visitorId:s,debounceDuration:u,sessionReplayBatchSize:h,sessionReplayBatchInterval:g,sessionReplayMaskTextSelectors:d,skipPatterns:a,maskPatterns:l,autoTrackPageview:!0,autoTrackSpa:!0,trackQuerystring:!0,trackOutbound:!0,enableWebVitals:!1,trackErrors:!1,enableSessionReplay:!1,trackButtonClicks:!1,trackCopy:!1,trackFormInteractions:!1,tag:Ne,featureFlags:{},sessionReplayBlockClass:C,sessionReplayBlockSelector:m,sessionReplayIgnoreClass:o,sessionReplayIgnoreSelector:c,sessionReplayMaskTextClass:p,sessionReplayMaskAllInputs:S,sessionReplayMaskInputOptions:A,sessionReplayCollectFonts:xe,sessionReplaySampling:Me,sessionReplaySlimDOMOptions:Be,sessionReplaySampleRate:Oe},W=f;try{let $=`${t}/site/tracking-config/${n}`,oe=await fetch($,{method:"GET",credentials:"omit"});if(oe.ok){let y=await oe.json();W={...f,autoTrackPageview:y.trackInitialPageView??f.autoTrackPageview,autoTrackSpa:y.trackSpaNavigation??f.autoTrackSpa,trackQuerystring:y.trackUrlParams??f.trackQuerystring,trackOutbound:y.trackOutbound??f.trackOutbound,enableWebVitals:y.webVitals??f.enableWebVitals,trackErrors:y.trackErrors??f.trackErrors,enableSessionReplay:y.sessionReplay??f.enableSessionReplay,trackButtonClicks:y.trackButtonClicks??f.trackButtonClicks,trackCopy:y.trackCopy??f.trackCopy,trackFormInteractions:y.trackFormInteractions??f.trackFormInteractions}}else console.warn("Failed to fetch tracking config from API, using defaults")}catch($){console.warn("Error fetching tracking config:",$)}return W.featureFlags=await Ve(t,n,i,s),W}var pe="rybbit-replay-sampled";function ze(){let r=new Uint8Array(16);if(globalThis.crypto?.getRandomValues)globalThis.crypto.getRandomValues(r);else for(let t=0;t<r.length;t++)r[t]=Math.floor(Math.random()*256);r[6]=r[6]&15|64,r[8]=r[8]&63|128;let e=[...r].map(t=>t.toString(16).padStart(2,"0"));return`${e.slice(0,4).join("")}-${e.slice(4,6).join("")}-${e.slice(6,8).join("")}-${e.slice(8,10).join("")}-${e.slice(10).join("")}`}function qe(r){if(r>=100)return!0;if(r<=0)return!1;try{let e=sessionStorage.getItem(pe);if(e!==null)return e==="1";let t=Math.random()*100<r;return sessionStorage.setItem(pe,t?"1":"0"),t}catch{return Math.random()*100<r}}var F=class{constructor(e,t,n){this.isRecording=!1;this.eventBuffer=[];this.pendingBatches=[];this.nextSequence=0;this.sendLoop=null;this.config=e,this.userId=t,this.sendBatch=n}async initialize(){if(!this.config.enableSessionReplay)return;let e=this.config.sessionReplaySampleRate;e!==void 0&&!qe(e)||(window.rrweb||await this.loadRrweb(),window.rrweb&&this.startRecording())}async loadRrweb(){return new Promise((e,t)=>{let n=document.createElement("script");n.src=`${this.config.analyticsHost}/replay.js`,n.async=!1,n.onload=()=>{e()},n.onerror=()=>t(new Error("Failed to load rrweb")),document.head.appendChild(n)})}startRecording(){if(!(this.isRecording||!window.rrweb||!this.config.enableSessionReplay))try{let e={mousemove:!1,mouseInteraction:{MouseUp:!1,MouseDown:!1,Click:!0,ContextMenu:!1,DblClick:!0,Focus:!0,Blur:!0,TouchStart:!1,TouchEnd:!1},scroll:500,input:"last",media:800},t={script:!1,comment:!0,headFavicon:!0,headWhitespace:!0,headMetaDescKeywords:!0,headMetaSocial:!0,headMetaRobots:!0,headMetaHttpEquiv:!0,headMetaAuthorship:!0,headMetaVerification:!0},n={emit:i=>{this.addEvent({type:i.type,data:i.data,timestamp:i.timestamp||Date.now()})},recordCanvas:!1,checkoutEveryNms:6e4,checkoutEveryNth:500,blockClass:this.config.sessionReplayBlockClass??"rr-block",blockSelector:this.config.sessionReplayBlockSelector??null,ignoreClass:this.config.sessionReplayIgnoreClass??"rr-ignore",ignoreSelector:this.config.sessionReplayIgnoreSelector??null,maskTextClass:this.config.sessionReplayMaskTextClass??"rr-mask",maskAllInputs:this.config.sessionReplayMaskAllInputs??!0,maskInputOptions:this.config.sessionReplayMaskInputOptions??{password:!0,email:!0},collectFonts:this.config.sessionReplayCollectFonts??!0,sampling:this.config.sessionReplaySampling??e,slimDOMOptions:this.config.sessionReplaySlimDOMOptions??t};this.config.sessionReplayMaskTextSelectors&&this.config.sessionReplayMaskTextSelectors.length>0&&(n.maskTextSelector=this.config.sessionReplayMaskTextSelectors.join(", ")),this.stopRecordingFn=window.rrweb.record(n),this.isRecording=!0,this.setupBatchTimer()}catch{}}stopRecording(){this.isRecording&&(this.stopRecordingFn&&this.stopRecordingFn(),this.isRecording=!1,this.clearBatchTimer(),(this.eventBuffer.length>0||this.pendingBatches.length>0)&&this.flushEvents())}isActive(){return this.isRecording}addEvent(e){this.eventBuffer.push({...e,sequence:this.nextSequence++}),this.eventBuffer.length>=this.config.sessionReplayBatchSize&&this.flushEvents()}setupBatchTimer(){this.clearBatchTimer(),this.batchTimer=window.setInterval(()=>{(this.eventBuffer.length>0||this.pendingBatches.length>0)&&this.flushEvents()},this.config.sessionReplayBatchInterval)}clearBatchTimer(){this.batchTimer&&(clearInterval(this.batchTimer),this.batchTimer=void 0)}async flushEvents(){if(this.eventBuffer.length>0){let e={batchId:ze(),userId:this.userId,events:this.eventBuffer,metadata:{pageUrl:window.location.href,viewportWidth:screen.width,viewportHeight:screen.height,language:navigator.language}};this.eventBuffer=[],this.pendingBatches.push(e)}await this.drainPendingBatches()}drainPendingBatches(){return this.sendLoop?this.sendLoop.then(()=>{if(this.pendingBatches.length>0&&this.sendLoop===null)return this.drainPendingBatches()}):(this.sendLoop=(async()=>{for(;this.pendingBatches.length>0;)try{await this.sendBatch(this.pendingBatches[0]),this.pendingBatches.shift()}catch{return}})().finally(()=>{this.sendLoop=null}),this.sendLoop)}updateUserId(e){e!==this.userId&&(this.eventBuffer.length>0&&this.flushEvents(),this.userId=e)}onPageChange(){this.isRecording&&this.flushEvents()}cleanup(){this.stopRecording()}};var b={automationApi:1,webdriver:1,zeroOuterDimensions:2,missingChrome:4,swiftShader:8,emptyPlugins:16,defaultViewport800x600:32,defaultViewport1024x768:64,impossibleDimensions:128,outerDimensionsWeird:256,pluginApiAbsence:512},he=null,Ge=10;function ge(){return me().score}function fe(){return me().mask}function me(){return he??(he=Ke()),he}function Ke(){let r=0,e=0;function t(n,i){(e&n)===0&&(e|=n,r+=i)}try{let n=navigator.userAgent,i=/Chrome\//.test(n)&&!/\bwv\b|; wv\)/.test(n),s=/Windows NT|Macintosh|X11|Linux x86_64/.test(n)&&!/Mobile|Android|iPhone|iPad/.test(n),a=Number(window.screen?.width),l=Number(window.screen?.height),d=Number(window.outerWidth),u=Number(window.outerHeight),h=Number(window.innerWidth),g=Number(window.innerHeight),m=["__webdriver_evaluate","__selenium_evaluate","__webdriver_script_function","__webdriver_script_func","__webdriver_script_fn","__fxdriver_evaluate","__driver_unwrapped","__webdriver_unwrapped","__driver_evaluate","__selenium_unwrapped","__fxdriver_unwrapped","_phantom","callPhantom","__nightmare","domAutomation","domAutomationController"].some(c=>c in window||c in document);(navigator.webdriver===!0||m)&&t(b.automationApi,3),(u===0||d===0)&&t(b.zeroOuterDimensions,2),(!Number.isFinite(a)||!Number.isFinite(l)||a<=0||l<=0||a>1e5||l>1e5)&&t(b.impossibleDimensions,3),s&&a===800&&l===600&&t(b.defaultViewport800x600,3),s&&a===1024&&l===768&&t(b.defaultViewport1024x768,3),Number.isFinite(d)&&Number.isFinite(u)&&Number.isFinite(h)&&Number.isFinite(g)&&d>0&&u>0&&h>0&&g>0&&(d+8<h||u+8<g)&&t(b.outerDimensionsWeird,2);let o=!1;!window.chrome&&i&&(t(b.missingChrome,1),o=!0);try{let c=document.createElement("canvas"),p=c.getContext("webgl")||c.getContext("experimental-webgl");if(p)try{let w=[],S=p.getParameter(p.RENDERER);typeof S=="string"&&w.push(S);try{let T=p.getExtension("WEBGL_debug_renderer_info");if(T){let A=p.getParameter(T.UNMASKED_RENDERER_WEBGL);typeof A=="string"&&w.push(A)}}catch{}w.join(" ").toLowerCase().includes("swiftshader")&&t(b.swiftShader,1)}finally{Je(c,p)}}catch{}(!navigator.plugins||navigator.plugins.length===0)&&i&&(t(b.emptyPlugins,1),o=!0),o&&t(b.pluginApiAbsence,0)}catch{}return{score:Math.min(r,Ge),mask:e}}function Je(r,e){try{e.getExtension("WEBGL_lose_context")?.loseContext?.()}catch{}r.width=0,r.height=0}var x=class{constructor(e){this.customUserId=null;this.pendingTrackingRequests=new Set;this.errorDedupeCache=new Map;this.errorDedupeLastCleanup=0;this.exposedFeatureFlags=new Set;this.config=e,this.loadUserId(),e.enableSessionReplay&&this.initializeSessionReplay()}serializeFeatureFlagValue(e){if(e==null)return"";if(typeof e=="string")return e;if(typeof e=="number"||typeof e=="boolean")return String(e);try{return JSON.stringify(e)}catch{return""}}getFeatureFlagEventPayload(){let e={};for(let[t,n]of Object.entries(this.config.featureFlags||{}))e[t]=this.serializeFeatureFlagValue(n.value);return e}getCurrentUrlContext(){let e=new URL(window.location.href),t=e.hash&&e.hash.startsWith("#/")?e.hash.substring(1):e.pathname;return{hostname:e.hostname,pathname:t,querystring:e.search,query:Object.fromEntries(e.searchParams.entries()),referrer:document.referrer,language:navigator.language,screenWidth:screen.width,screenHeight:screen.height}}async refreshFeatureFlags(){try{let e=await fetch(`${this.config.analyticsHost}/site/${this.config.siteId}/feature-flags/evaluate`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({anonymousId:this.config.visitorId,identifiedUserId:this.customUserId||void 0,...this.getCurrentUrlContext()}),mode:"cors",credentials:"omit",keepalive:!0});if(!e.ok)return;let t=await e.json();this.config.featureFlags=t?.flags&&typeof t.flags=="object"?t.flags:{}}catch{}}loadUserId(){try{let e=localStorage.getItem(`${this.config.namespace}-user-id`);e&&(this.customUserId=e)}catch{}}async initializeSessionReplay(){try{this.sessionReplayRecorder=new F(this.config,this.customUserId||"",e=>this.sendSessionReplayBatch(e)),await this.sessionReplayRecorder.initialize()}catch(e){console.error("Failed to initialize session replay:",e)}}async sendSessionReplayBatch(e){try{let t=await fetch(`${this.config.analyticsHost}/session-replay/record/${this.config.siteId}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e),mode:"cors",keepalive:!1});if(!t.ok)throw new Error(`Session replay delivery failed: ${t.status} ${t.statusText}`.trim())}catch(t){throw console.error("Failed to send session replay batch:",t),t}}createBasePayload(){let e=new URL(window.location.href),t=e.pathname;if(e.hash&&e.hash.startsWith("#/")&&(t=e.hash.substring(1)),j(t,this.config.skipPatterns))return null;let n=j(t,this.config.maskPatterns);n&&(t=n);let i={site_id:this.config.siteId,hostname:e.hostname,pathname:t,querystring:this.config.trackQuerystring?e.search:"",screenWidth:screen.width,screenHeight:screen.height,language:navigator.language,page_title:document.title,referrer:document.referrer,_bs:ge(),_bsm:fe()};this.customUserId&&(i.user_id=this.customUserId),this.config.tag&&(i.tag=this.config.tag);let s=this.getFeatureFlagEventPayload();return Object.keys(s).length>0&&(i.feature_flags=s),i}sendTrackingData(e){let t=(async()=>{try{await fetch(`${this.config.analyticsHost}/track`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e),mode:"cors",keepalive:!0})}catch(n){console.error("Failed to send tracking data:",n)}})();return this.pendingTrackingRequests.add(t),t.finally(()=>this.pendingTrackingRequests.delete(t)),t}track(e,t="",n={}){if(e==="custom_event"&&(!t||typeof t!="string")){console.error("Event name is required and must be a string for custom events");return}let i=this.createBasePayload();if(!i)return;let a={...i,type:e,event_name:t,properties:["custom_event","outbound","error","button_click","copy","form_submit","input_change"].includes(e)?JSON.stringify(n):void 0};this.sendTrackingData(a)}trackPageview(){this.track("pageview")}trackEvent(e,t={}){this.track("custom_event",e,t)}getFeatureFlag(e,t){let n=this.config.featureFlags?.[e];if(!n)return t;let i=`${e}:${n.version}:${this.serializeFeatureFlagValue(n.value)}`;return this.exposedFeatureFlags.has(i)||(this.exposedFeatureFlags.add(i),this.trackEvent("feature_flag_exposure",{key:e,value:this.serializeFeatureFlagValue(n.value),version:n.version,reason:n.reason})),n.value}getFeatureFlags(){return Object.fromEntries(Object.entries(this.config.featureFlags||{}).map(([e,t])=>[e,t.value]))}getFeatureFlagPayload(e,t){let n=this.config.featureFlags?.[e];return!n||n.payload===void 0?t:n.payload}getFeatureFlagPayloads(){return Object.fromEntries(Object.entries(this.config.featureFlags||{}).filter(([,e])=>e.payload!==void 0).map(([e,t])=>[e,t.payload]))}trackOutbound(e,t="",n="_self"){this.track("outbound","",{url:e,text:t,target:n})}trackWebVitals(e){let t=this.createBasePayload();if(!t)return;let n={...t,type:"performance",event_name:"web-vitals",...e};this.sendTrackingData(n)}trackError(e,t={}){let n=e?.message||"";if(n.includes("ResizeObserver loop completed with undelivered notifications")||n.includes("ResizeObserver loop limit exceeded"))return;let i=window.location.origin,s=t.filename||"",a=e.stack||"";if(s)try{if(new URL(s).origin!==i)return}catch{}else if(a&&!a.includes(i))return;let d=[e.name||"Error",n,t.filename||"",t.lineno??"",t.colno??""].join("|"),u=Date.now(),h=6e4,g=this.errorDedupeCache.get(d);if(g&&u-g<h)return;this.errorDedupeCache.set(d,u);let C=10*6e4;if(u-this.errorDedupeLastCleanup>h){for(let[o,c]of this.errorDedupeCache.entries())u-c>C&&this.errorDedupeCache.delete(o);this.errorDedupeLastCleanup=u}let m={message:e.message?.substring(0,500)||"Unknown error",stack:a.substring(0,2e3)||""};if(s&&(m.fileName=s),t.lineno){let o=typeof t.lineno=="string"?parseInt(t.lineno,10):t.lineno;o&&o!==0&&(m.lineNumber=o)}if(t.colno){let o=typeof t.colno=="string"?parseInt(t.colno,10):t.colno;o&&o!==0&&(m.columnNumber=o)}for(let o in t)!["lineno","colno"].includes(o)&&t[o]!==void 0&&(m[o]=t[o]);this.track("error",e.name||"Error",m)}trackButtonClick(e){this.track("button_click","",e)}trackCopy(e){this.track("copy","",e)}trackFormSubmit(e){this.track("form_submit","",e)}trackInputChange(e){this.track("input_change","",e)}identify(e,t){if(typeof e!="string"||e.trim()===""){console.error("User ID must be a non-empty string");return}this.customUserId=e.trim();try{localStorage.setItem(`${this.config.namespace}-user-id`,this.customUserId)}catch{console.warn("Could not persist user ID to localStorage")}let n=this.customUserId,i=[...this.pendingTrackingRequests];Promise.allSettled(i).then(()=>this.sendIdentifyEvent(n,t,!0)).then(()=>this.refreshFeatureFlags()),this.sessionReplayRecorder&&this.sessionReplayRecorder.updateUserId(this.customUserId)}setTraits(e){if(!e||typeof e!="object"){console.error("Traits must be an object");return}let t=this.customUserId;if(!t){console.warn("Cannot set traits without identifying user first. Call identify() first.");return}this.sendIdentifyEvent(t,e,!1).then(()=>this.refreshFeatureFlags())}async sendIdentifyEvent(e,t,n=!0){try{await fetch(`${this.config.analyticsHost}/identify`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({site_id:this.config.siteId,user_id:e,traits:t,is_new_identify:n}),mode:"cors",keepalive:!0})}catch(i){console.error("Failed to send identify event:",i)}}clearUserId(){this.customUserId=null;try{localStorage.removeItem(`${this.config.namespace}-user-id`)}catch{}this.sessionReplayRecorder&&this.sessionReplayRecorder.updateUserId(""),this.refreshFeatureFlags()}getUserId(){return this.customUserId}startSessionReplay(){this.sessionReplayRecorder?this.sessionReplayRecorder.startRecording():console.warn("Session replay not initialized")}stopSessionReplay(){this.sessionReplayRecorder&&this.sessionReplayRecorder.stopRecording()}isSessionReplayActive(){return this.sessionReplayRecorder?.isActive()??!1}onPageChange(){this.refreshFeatureFlags(),this.sessionReplayRecorder&&this.sessionReplayRecorder.onPageChange()}cleanup(){this.sessionReplayRecorder&&this.sessionReplayRecorder.cleanup()}};var Ee=-1,I=r=>{addEventListener("pageshow",(e=>{e.persisted&&(Ee=e.timeStamp,r(e))}),!0)},v=(r,e,t,n)=>{let i,s;return a=>{e.value>=0&&(a||n)&&(s=e.value-(i??0),(s||i===void 0)&&(i=e.value,e.delta=s,e.rating=((l,d)=>l>d[1]?"poor":l>d[0]?"needs-improvement":"good")(e.value,t),r(e)))}},Y=r=>{requestAnimationFrame((()=>requestAnimationFrame((()=>r()))))},Z=()=>{let r=performance.getEntriesByType("navigation")[0];if(r&&r.responseStart>0&&r.responseStart<performance.now())return r},_=()=>Z()?.activationStart??0,k=(r,e=-1)=>{let t=Z(),n="navigate";return Ee>=0?n="back-forward-cache":t&&(document.prerendering||_()>0?n="prerender":document.wasDiscarded?n="restore":t.type&&(n=t.type.replace(/_/g,"-"))),{name:r,value:e,rating:"good",delta:0,entries:[],id:`v5-${Date.now()}-${Math.floor(8999999999999*Math.random())+1e12}`,navigationType:n}},V=new WeakMap;function ee(r,e){return V.get(r)||V.set(r,new e),V.get(r)}var q=class{constructor(){R(this,"t");R(this,"i",0);R(this,"o",[])}h(e){if(e.hadRecentInput)return;let t=this.o[0],n=this.o.at(-1);this.i&&t&&n&&e.startTime-n.startTime<1e3&&e.startTime-t.startTime<5e3?(this.i+=e.value,this.o.push(e)):(this.i=e.value,this.o=[e]),this.t?.(e)}},L=(r,e,t={})=>{try{if(PerformanceObserver.supportedEntryTypes.includes(r)){let n=new PerformanceObserver((i=>{Promise.resolve().then((()=>{e(i.getEntries())}))}));return n.observe({type:r,buffered:!0,...t}),n}}catch{}},te=r=>{let e=!1;return()=>{e||(r(),e=!0)}},P=-1,Ce=new Set,ye=()=>document.visibilityState!=="hidden"||document.prerendering?1/0:0,G=r=>{if(document.visibilityState==="hidden"){if(r.type==="visibilitychange")for(let e of Ce)e();isFinite(P)||(P=r.type==="visibilitychange"?r.timeStamp:0,removeEventListener("prerenderingchange",G,!0))}},B=()=>{if(P<0){let r=_();P=(document.prerendering?void 0:globalThis.performance.getEntriesByType("visibility-state").filter((t=>t.name==="hidden"&&t.startTime>r))[0]?.startTime)??ye(),addEventListener("visibilitychange",G,!0),addEventListener("prerenderingchange",G,!0),I((()=>{setTimeout((()=>{P=ye()}))}))}return{get firstHiddenTime(){return P},onHidden(r){Ce.add(r)}}},O=r=>{document.prerendering?addEventListener("prerenderingchange",(()=>r()),!0):r()},be=[1800,3e3],ne=(r,e={})=>{O((()=>{let t=B(),n,i=k("FCP"),s=L("paint",(a=>{for(let l of a)l.name==="first-contentful-paint"&&(s.disconnect(),l.startTime<t.firstHiddenTime&&(i.value=Math.max(l.startTime-_(),0),i.entries.push(l),n(!0)))}));s&&(n=v(r,i,be,e.reportAllChanges),I((a=>{i=k("FCP"),n=v(r,i,be,e.reportAllChanges),Y((()=>{i.value=performance.now()-a.timeStamp,n(!0)}))})))}))},ve=[.1,.25],Pe=(r,e={})=>{let t=B();ne(te((()=>{let n,i=k("CLS",0),s=ee(e,q),a=d=>{for(let u of d)s.h(u);s.i>i.value&&(i.value=s.i,i.entries=s.o,n())},l=L("layout-shift",a);l&&(n=v(r,i,ve,e.reportAllChanges),t.onHidden((()=>{a(l.takeRecords()),n(!0)})),I((()=>{s.i=0,i=k("CLS",0),n=v(r,i,ve,e.reportAllChanges),Y((()=>n()))})),setTimeout(n))})))},Ie=0,z=1/0,M=0,Xe=r=>{for(let e of r)e.interactionId&&(z=Math.min(z,e.interactionId),M=Math.max(M,e.interactionId),Ie=M?(M-z)/7+1:0)},K,ke=()=>K?Ie:performance.interactionCount??0,Qe=()=>{"interactionCount"in performance||K||(K=L("event",Xe,{type:"event",buffered:!0,durationThreshold:0}))},we=0,J=class{constructor(){R(this,"u",[]);R(this,"l",new Map);R(this,"m");R(this,"p")}v(){we=ke(),this.u.length=0,this.l.clear()}L(){let e=Math.min(this.u.length-1,Math.floor((ke()-we)/50));return this.u[e]}h(e){if(this.m?.(e),!e.interactionId&&e.entryType!=="first-input")return;let t=this.u.at(-1),n=this.l.get(e.interactionId);if(n||this.u.length<10||e.duration>t.P){if(n?e.duration>n.P?(n.entries=[e],n.P=e.duration):e.duration===n.P&&e.startTime===n.entries[0].startTime&&n.entries.push(e):(n={id:e.interactionId,entries:[e],P:e.duration},this.l.set(n.id,n),this.u.push(n)),this.u.sort(((i,s)=>s.P-i.P)),this.u.length>10){let i=this.u.splice(10);for(let s of i)this.l.delete(s.id)}this.p?.(n)}}},_e=r=>{let e=globalThis.requestIdleCallback||setTimeout;document.visibilityState==="hidden"?r():(r=te(r),addEventListener("visibilitychange",r,{once:!0,capture:!0}),e((()=>{r(),removeEventListener("visibilitychange",r,{capture:!0})})))},Re=[200,500],Le=(r,e={})=>{if(!globalThis.PerformanceEventTiming||!("interactionId"in PerformanceEventTiming.prototype))return;let t=B();O((()=>{Qe();let n,i=k("INP"),s=ee(e,J),a=d=>{_e((()=>{for(let h of d)s.h(h);let u=s.L();u&&u.P!==i.value&&(i.value=u.P,i.entries=u.entries,n())}))},l=L("event",a,{durationThreshold:e.durationThreshold??40});n=v(r,i,Re,e.reportAllChanges),l&&(l.observe({type:"first-input",buffered:!0}),t.onHidden((()=>{a(l.takeRecords()),n(!0)})),I((()=>{s.v(),i=k("INP"),n=v(r,i,Re,e.reportAllChanges)})))}))},X=class{constructor(){R(this,"m")}h(e){this.m?.(e)}},Se=[2500,4e3],Ae=(r,e={})=>{O((()=>{let t=B(),n,i=k("LCP"),s=ee(e,X),a=d=>{e.reportAllChanges||(d=d.slice(-1));for(let u of d)s.h(u),u.startTime<t.firstHiddenTime&&(i.value=Math.max(u.startTime-_(),0),i.entries=[u],n())},l=L("largest-contentful-paint",a);if(l){n=v(r,i,Se,e.reportAllChanges);let d=te((()=>{a(l.takeRecords()),l.disconnect(),n(!0)})),u=h=>{h.isTrusted&&(_e(d),removeEventListener(h.type,u,{capture:!0}))};for(let h of["keydown","click","visibilitychange"])addEventListener(h,u,{capture:!0});I((h=>{i=k("LCP"),n=v(r,i,Se,e.reportAllChanges),Y((()=>{i.value=performance.now()-h.timeStamp,n(!0)}))}))}}))},Te=[800,1800],Q=r=>{document.prerendering?O((()=>Q(r))):document.readyState!=="complete"?addEventListener("load",(()=>Q(r)),!0):setTimeout(r)},Fe=(r,e={})=>{let t=k("TTFB"),n=v(r,t,Te,e.reportAllChanges);Q((()=>{let i=Z();i&&(t.value=Math.max(i.responseStart-_(),0),t.entries=[i],n(!0),I((()=>{t=k("TTFB",0),n=v(r,t,Te,e.reportAllChanges),n(!0)})))}))};var N=class{constructor(e){this.data={lcp:null,cls:null,inp:null,fcp:null,ttfb:null};this.sent=!1;this.timeout=null;this.onReadyCallback=null;this.onReadyCallback=e}initialize(){try{Ae(this.collectMetric.bind(this)),Pe(this.collectMetric.bind(this)),Le(this.collectMetric.bind(this)),ne(this.collectMetric.bind(this)),Fe(this.collectMetric.bind(this)),this.timeout=setTimeout(()=>{this.sent||this.sendData()},2e4),window.addEventListener("beforeunload",()=>{this.sent||this.sendData()})}catch(e){console.warn("Error initializing web vitals tracking:",e)}}collectMetric(e){if(this.sent)return;let t=e.name.toLowerCase();this.data[t]=e.value,Object.values(this.data).every(i=>i!==null)&&this.sendData()}sendData(){this.sent||(this.sent=!0,this.timeout&&(clearTimeout(this.timeout),this.timeout=null),this.onReadyCallback&&this.onReadyCallback(this.data))}getData(){return{...this.data}}};var D=class{constructor(e,t){this.tracker=e,this.config=t}initialize(){document.addEventListener("click",this.handleClick.bind(this),!0)}handleClick(e){let t=e.target;this.config.trackButtonClicks&&this.isButton(t)&&this.trackButtonClick(t)}isButton(e){if(e.tagName==="BUTTON"||e.getAttribute("role")==="button")return!0;if(e.tagName==="INPUT"){let i=e.type?.toLowerCase();if(i==="submit"||i==="button")return!0}let t=e.parentElement,n=0;for(;t&&n<3;){if(t.tagName==="BUTTON"||t.getAttribute("role")==="button")return!0;t=t.parentElement,n++}return!1}trackButtonClick(e){let t=this.findButton(e);if(!t||t.hasAttribute("data-rybbit-event"))return;let n={text:this.getElementText(t),...this.extractDataAttributes(t)};this.tracker.trackButtonClick(n)}extractDataAttributes(e){let t={};for(let n of e.attributes)if(n.name.startsWith("data-rybbit-prop-")){let i=n.name.replace("data-rybbit-prop-","");t[i]=n.value}return t}findButton(e){if(e.tagName==="BUTTON"||e.getAttribute("role")==="button")return e;if(e.tagName==="INPUT"){let i=e.type?.toLowerCase();if(i==="submit"||i==="button")return e}let t=e.parentElement,n=0;for(;t&&n<3;){if(t.tagName==="BUTTON"||t.getAttribute("role")==="button")return t;t=t.parentElement,n++}return null}getElementText(e){let t=e.textContent?.trim().substring(0,100);if(t)return t;let n=e.getAttribute("aria-label")?.trim().substring(0,100);if(n)return n;if(e.tagName==="INPUT"){let s=e.value?.trim().substring(0,100);if(s)return s}let i=e.getAttribute("title")?.trim().substring(0,100);if(i)return i}cleanup(){document.removeEventListener("click",this.handleClick.bind(this),!0)}};var U=class{constructor(e){this.tracker=e}initialize(){document.addEventListener("copy",this.handleCopy.bind(this))}handleCopy(){let e=window.getSelection();if(!e||e.isCollapsed)return;let t=e.toString(),n=t.length;if(n===0)return;let i=e.anchorNode,s=i instanceof HTMLElement?i:i?.parentElement;if(!s)return;let a={text:t.substring(0,500),...n>500&&{textLength:n},sourceElement:s.tagName.toLowerCase()};this.tracker.trackCopy(a)}cleanup(){document.removeEventListener("copy",this.handleCopy.bind(this))}};var H=class{constructor(e,t){this.tracker=e,this.config=t,this.boundHandleSubmit=this.handleSubmit.bind(this),this.boundHandleChange=this.handleChange.bind(this)}initialize(){document.addEventListener("submit",this.boundHandleSubmit,!0),document.addEventListener("change",this.boundHandleChange,!0)}cleanup(){document.removeEventListener("submit",this.boundHandleSubmit,!0),document.removeEventListener("change",this.boundHandleChange,!0)}handleSubmit(e){let t=e.target;if(t.tagName!=="FORM")return;let n={formId:t.id||"",formName:t.name||"",formAction:t.action||"",method:(t.method||"get").toUpperCase(),fieldCount:t.elements.length,ariaLabel:t.getAttribute("aria-label")||void 0,...this.extractDataAttributes(t)};this.tracker.trackFormSubmit(n)}handleChange(e){let t=e.target,n=t.tagName.toUpperCase();if(!["INPUT","SELECT","TEXTAREA"].includes(n)||t.disabled||t.readOnly)return;if(n==="INPUT"){let a=t.type?.toLowerCase();if(a==="hidden"||a==="password")return}let i=t.name||t.id||t.getAttribute("aria-label")||t.placeholder||"",s={element:n.toLowerCase(),inputType:n==="INPUT"?t.type?.toLowerCase():void 0,inputName:i,formId:t.form?.id||void 0,formName:t.form?.name||void 0,...this.extractDataAttributes(t)};this.tracker.trackInputChange(s)}extractDataAttributes(e){let t={};for(let n of e.attributes)if(n.name.startsWith("data-rybbit-prop-")){let i=n.name.replace("data-rybbit-prop-","");t[i]=n.value}return t}};(async function(){let r=document.currentScript;if(!r){console.error("Could not find current script tag");return}let e=r.getAttribute("data-namespace")||"rybbit",t=`disable-${e}`;if(window.__RYBBIT_OPTOUT__||localStorage.getItem(t)!==null){window[e]={pageview:()=>{},event:()=>{},error:()=>{},trackOutbound:()=>{},identify:()=>{},setTraits:()=>{},clearUserId:()=>{},getUserId:()=>null,flag:(o,c)=>c,flagPayload:(o,c)=>c,flags:()=>({}),flagPayloads:()=>({}),onReady:()=>{},startSessionReplay:()=>{},stopSessionReplay:()=>{},isSessionReplayActive:()=>!1};return}let n=[],i=o=>(...c)=>{n.push([o,c])};window[e]={pageview:i("pageview"),event:i("event"),error:i("error"),trackOutbound:i("trackOutbound"),identify:i("identify"),setTraits:i("setTraits"),clearUserId:i("clearUserId"),getUserId:()=>null,flag:(o,c)=>c,flagPayload:(o,c)=>c,flags:()=>({}),flagPayloads:()=>({}),onReady:i("onReady"),startSessionReplay:i("startSessionReplay"),stopSessionReplay:i("stopSessionReplay"),isSessionReplayActive:()=>!1};let s=await de(r);if(!s)return;let a=new x(s);s.enableWebVitals&&new N(c=>{a.trackWebVitals(c)}).initialize();let l=null,d=null,u=null;s.trackButtonClicks&&(l=new D(a,s),l.initialize()),s.trackCopy&&(d=new U(a),d.initialize()),s.trackFormInteractions&&(u=new H(a,s),u.initialize()),s.trackErrors&&(window.addEventListener("error",o=>{a.trackError(o.error||new Error(o.message),{filename:o.filename,lineno:o.lineno,colno:o.colno})}),window.addEventListener("unhandledrejection",o=>{let c=o.reason instanceof Error?o.reason:new Error(String(o.reason));a.trackError(c,{type:"unhandledrejection"})}));let h=()=>a.trackPageview(),g=s.debounceDuration>0?ce(h,s.debounceDuration):h;function C(){if(document.addEventListener("click",function(o){let c=o.target;for(;c&&c!==document.documentElement;){if(c.hasAttribute("data-rybbit-event")){let p=c.getAttribute("data-rybbit-event");if(p){let w={};for(let S of c.attributes)if(S.name.startsWith("data-rybbit-prop-")){let T=S.name.replace("data-rybbit-prop-","");w[T]=S.value}a.trackEvent(p,w)}break}c=c.parentElement}if(s.trackOutbound){let p=o.target.closest("a");p?.href&&le(p.href)&&a.trackOutbound(p.href,p.innerText||p.textContent||"",p.target||"_self")}}),s.autoTrackSpa){let o=history.pushState,c=history.replaceState;history.pushState=function(...p){o.apply(this,p),g(),a.onPageChange()},history.replaceState=function(...p){c.apply(this,p),g(),a.onPageChange()},window.addEventListener("popstate",()=>{g(),a.onPageChange()}),window.addEventListener("hashchange",()=>{g(),a.onPageChange()})}}window[s.namespace]={pageview:()=>a.trackPageview(),event:(o,c={})=>a.trackEvent(o,c),error:(o,c={})=>a.trackError(o,c),trackOutbound:(o,c="",p="_self")=>a.trackOutbound(o,c,p),identify:(o,c)=>a.identify(o,c),setTraits:o=>a.setTraits(o),clearUserId:()=>a.clearUserId(),getUserId:()=>a.getUserId(),flag:(o,c)=>a.getFeatureFlag(o,c),flagPayload:(o,c)=>a.getFeatureFlagPayload(o,c),flags:()=>a.getFeatureFlags(),flagPayloads:()=>a.getFeatureFlagPayloads(),onReady:o=>o(window[s.namespace]),startSessionReplay:()=>a.startSessionReplay(),stopSessionReplay:()=>a.stopSessionReplay(),isSessionReplayActive:()=>a.isSessionReplayActive()};let m=window[s.namespace];for(let[o,c]of n)m[o](...c);C(),window.addEventListener("beforeunload",()=>{l?.cleanup(),d?.cleanup(),a.cleanup()}),s.autoTrackPageview&&a.trackPageview()})();})();

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Add backoff to failed-batch retries.

A failed batch remains queued, and each timer tick retries it indefinitely at a fixed interval. During a prolonged outage, every active session can continuously generate delivery traffic. Use capped exponential backoff with jitter while retaining the batch for retry.

🧰 Tools
🪛 ast-grep (0.44.1)

[warning] Avoid using the initial state variable in setState
Context: setTimeout(()=>r(...n),e)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(setstate-same-var)


[warning] Avoid using the initial state variable in setState
Context: setTimeout(n)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(setstate-same-var)


[warning] Avoid using the initial state variable in setState
Context: setTimeout(r)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(setstate-same-var)


[warning] Avoid SQL injections
Context: !["INPUT","SELECT","TEXTAREA"].includes(n)||t.disabled||t.readOnly
Note: [CWE-89] Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection'). Security best practice.

(variable-sql-statement-injection)


[warning] Avoid SQL injections
Context: !["INPUT","SELECT","TEXTAREA"].includes(n)||t.disabled
Note: [CWE-89] Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection'). Security best practice.

(variable-sql-statement-injection)


[warning] Detects non-literal values in regular expressions
Context: new RegExp(l)
Note: [CWE-1333] Inefficient Regular Expression Complexity (ReDoS via non-literal RegExp).

(detect-non-literal-regexp)


[warning] Detects non-literal values in regular expressions
Context: new RegExp(/${t}/,"g")
Note: [CWE-1333] Inefficient Regular Expression Complexity (ReDoS via non-literal RegExp).

(detect-non-literal-regexp)


[warning] Detects non-literal values in regular expressions
Context: new RegExp(t,"g")
Note: [CWE-1333] Inefficient Regular Expression Complexity (ReDoS via non-literal RegExp).

(detect-non-literal-regexp)


[warning] Detects non-literal values in regular expressions
Context: new RegExp(n,"g")
Note: [CWE-1333] Inefficient Regular Expression Complexity (ReDoS via non-literal RegExp).

(detect-non-literal-regexp)


[warning] Detects non-literal values in regular expressions
Context: new RegExp("^"+a+"$")
Note: [CWE-1333] Inefficient Regular Expression Complexity (ReDoS via non-literal RegExp).

(detect-non-literal-regexp)


[warning] Do not use variable for regular expressions
Context: new RegExp(l)
Note: [CWE-1333] Inefficient Regular Expression Complexity. Security best practice.

(regexp-non-literal)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/public/script.js` at line 1, Update the session replay retry flow in
F.drainPendingBatches so failed batches remain queued but retries use capped
exponential backoff with jitter instead of immediately retrying on every timer
tick. Track retry state per pending batch or equivalent, increase the delay
after each failure up to a maximum cap, and reset the backoff when a batch
succeeds; preserve ordered delivery and existing batching behavior.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

python3 - <<'PY'
from pathlib import Path
text = Path('server/public/script.js').read_text()
# Pull the recorder methods and shutdown wiring around them.
for needle in ['stopRecording(){', 'drainPendingBatches(){', 'cleanup(){this.stopRecording()}', 'beforeunload', 'sendSessionReplayBatch(e)']:
    idx = text.find(needle)
    print(f"\n=== {needle} @ {idx} ===")
    if idx != -1:
        start = max(0, idx - 400)
        end = min(len(text), idx + 1200)
        print(text[start:end])
PY

Repository: rybbit-io/rybbit

Length of output: 8352


🏁 Script executed:

node - <<'JS'
class Recorder {
  constructor() {
    this.isRecording = true;
    this.eventBuffer = ['e1'];
    this.pendingBatches = [];
    this.sendLoop = null;
    this.batchTimer = 1;
    this.calls = 0;
  }
  clearBatchTimer() { this.batchTimer = undefined; }
  async sendBatch(batch) {
    this.calls++;
    throw new Error('fail');
  }
  async flushEvents() {
    if (this.eventBuffer.length > 0) {
      this.pendingBatches.push({ events: this.eventBuffer });
      this.eventBuffer = [];
    }
    await this.drainPendingBatches();
  }
  drainPendingBatches() {
    return this.sendLoop
      ? this.sendLoop.then(() => {
          if (this.pendingBatches.length > 0 && this.sendLoop === null)
            return this.drainPendingBatches();
        })
      : (this.sendLoop = (async () => {
          for (; this.pendingBatches.length > 0;)
            try {
              await this.sendBatch(this.pendingBatches[0]);
              this.pendingBatches.shift();
            } catch {
              return;
            }
        })().finally(() => { this.sendLoop = null; }),
        this.sendLoop);
  }
  stopRecording() {
    this.isRecording && (
      this.isRecording = false,
      this.clearBatchTimer(),
      (this.eventBuffer.length > 0 || this.pendingBatches.length > 0) && this.flushEvents()
    );
  }
}

(async () => {
  const r = new Recorder();
  await r.stopRecording();
  await new Promise(r => setTimeout(r, 50));
  console.log({ calls: r.calls, pending: r.pendingBatches.length, sendLoop: r.sendLoop, batchTimer: r.batchTimer });
})();
JS

Repository: rybbit-io/rybbit

Length of output: 218


Don’t clear the replay timer before the final flush settles. If sendBatch fails during stopRecording(), the batch stays in pendingBatches but batchTimer is already gone, so there’s no retry path and the last replay events can be dropped.

🧰 Tools
🪛 ast-grep (0.44.1)

[warning] Avoid using the initial state variable in setState
Context: setTimeout(()=>r(...n),e)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(setstate-same-var)


[warning] Avoid using the initial state variable in setState
Context: setTimeout(n)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(setstate-same-var)


[warning] Avoid using the initial state variable in setState
Context: setTimeout(r)
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(setstate-same-var)


[warning] Avoid SQL injections
Context: !["INPUT","SELECT","TEXTAREA"].includes(n)||t.disabled||t.readOnly
Note: [CWE-89] Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection'). Security best practice.

(variable-sql-statement-injection)


[warning] Avoid SQL injections
Context: !["INPUT","SELECT","TEXTAREA"].includes(n)||t.disabled
Note: [CWE-89] Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection'). Security best practice.

(variable-sql-statement-injection)


[warning] Detects non-literal values in regular expressions
Context: new RegExp(l)
Note: [CWE-1333] Inefficient Regular Expression Complexity (ReDoS via non-literal RegExp).

(detect-non-literal-regexp)


[warning] Detects non-literal values in regular expressions
Context: new RegExp(/${t}/,"g")
Note: [CWE-1333] Inefficient Regular Expression Complexity (ReDoS via non-literal RegExp).

(detect-non-literal-regexp)


[warning] Detects non-literal values in regular expressions
Context: new RegExp(t,"g")
Note: [CWE-1333] Inefficient Regular Expression Complexity (ReDoS via non-literal RegExp).

(detect-non-literal-regexp)


[warning] Detects non-literal values in regular expressions
Context: new RegExp(n,"g")
Note: [CWE-1333] Inefficient Regular Expression Complexity (ReDoS via non-literal RegExp).

(detect-non-literal-regexp)


[warning] Detects non-literal values in regular expressions
Context: new RegExp("^"+a+"$")
Note: [CWE-1333] Inefficient Regular Expression Complexity (ReDoS via non-literal RegExp).

(detect-non-literal-regexp)


[warning] Do not use variable for regular expressions
Context: new RegExp(l)
Note: [CWE-1333] Inefficient Regular Expression Complexity. Security best practice.

(regexp-non-literal)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/public/script.js` at line 1, Update the session replay cleanup flow in
class F, specifically stopRecording(), clearBatchTimer(), and flushEvents(), so
the batch timer remains available until the final flush and its sendBatch retry
chain settles. Ensure failed batches remain pending and retain a retry path
instead of clearing the timer before drainPendingBatches() completes.

Comment on lines +328 to +353
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.mocked(global.fetch).mockResolvedValue({
ok: false,
status: 503,
statusText: "Service Unavailable",
} as Response);

const sendSessionReplayBatch = (
tracker as unknown as {
sendSessionReplayBatch: (batch: {
batchId: string;
userId: string;
events: Array<{ type: number; data: object; timestamp: number }>;
}) => Promise<void>;
}
).sendSessionReplayBatch.bind(tracker);

await expect(
sendSessionReplayBatch({
batchId: "76c8fb17-e7b5-41f7-b4f9-a21a4efca1d4",
userId: "employee-alice",
events: [{ type: 2, data: {}, timestamp: 1_700_000_000_000 }],
})
).rejects.toThrow("503 Service Unavailable");

consoleSpy.mockRestore();

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.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Restore the console spy in a finally block.

If the assertion fails unexpectedly, Line 353 is skipped and console.error remains mocked for subsequent tests. Guarantee cleanup with try/finally.

Proposed fix
      const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
+     try {
        await expect(
          sendSessionReplayBatch({
            batchId: "76c8fb17-e7b5-41f7-b4f9-a21a4efca1d4",
            userId: "employee-alice",
            events: [{ type: 2, data: {}, timestamp: 1_700_000_000_000 }],
          })
        ).rejects.toThrow("503 Service Unavailable");
-
-       consoleSpy.mockRestore();
+     } finally {
+       consoleSpy.mockRestore();
+     }
📝 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
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.mocked(global.fetch).mockResolvedValue({
ok: false,
status: 503,
statusText: "Service Unavailable",
} as Response);
const sendSessionReplayBatch = (
tracker as unknown as {
sendSessionReplayBatch: (batch: {
batchId: string;
userId: string;
events: Array<{ type: number; data: object; timestamp: number }>;
}) => Promise<void>;
}
).sendSessionReplayBatch.bind(tracker);
await expect(
sendSessionReplayBatch({
batchId: "76c8fb17-e7b5-41f7-b4f9-a21a4efca1d4",
userId: "employee-alice",
events: [{ type: 2, data: {}, timestamp: 1_700_000_000_000 }],
})
).rejects.toThrow("503 Service Unavailable");
consoleSpy.mockRestore();
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.mocked(global.fetch).mockResolvedValue({
ok: false,
status: 503,
statusText: "Service Unavailable",
} as Response);
const sendSessionReplayBatch = (
tracker as unknown as {
sendSessionReplayBatch: (batch: {
batchId: string;
userId: string;
events: Array<{ type: number; data: object; timestamp: number }>;
}) => Promise<void>;
}
).sendSessionReplayBatch.bind(tracker);
try {
await expect(
sendSessionReplayBatch({
batchId: "76c8fb17-e7b5-41f7-b4f9-a21a4efca1d4",
userId: "employee-alice",
events: [{ type: 2, data: {}, timestamp: 1_700_000_000_000 }],
})
).rejects.toThrow("503 Service Unavailable");
} finally {
consoleSpy.mockRestore();
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/analytics-script/tracking.test.ts` around lines 328 - 353, Wrap
the sendSessionReplayBatch assertion in a try/finally block and move
consoleSpy.mockRestore() into the finally clause, ensuring console.error is
restored whether the assertion passes or throws.

Comment on lines +96 to +106
async function ensureInsertDeduplication(table: string) {
await execClickhouseInitStep(
`enable insert deduplication for ${table}`,
`
ALTER TABLE ${table}
MODIFY SETTING non_replicated_deduplication_window = ${INSERT_DEDUPLICATION_WINDOW}
`,
{ lockAcquireTimeoutSeconds: 15 }
);
}

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.

🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift

Prevent unconditional ALTER TABLE lock contention on startup.

The ensureInsertDeduplication function unconditionally executes an ALTER TABLE statement on every application startup. As noted in the existing comments for ensureEventsColumns (line 161), even a no-op ALTER TABLE requires an exclusive metadata lock. In a production environment with active inserts or long-running queries, this will cause active queries to queue up and block, potentially leading to lock_acquire_timeout crashes and crash-looping instances during rolling deployments.

Query system.tables to verify if the setting is already applied before executing the ALTER. Additionally, strictly type the table argument as a string literal union to firmly align with the guideline to avoid interpolating untrusted input into SQL.

🔒️ Proposed fix to skip unnecessary ALTERS
-async function ensureInsertDeduplication(table: string) {
+async function ensureInsertDeduplication(table: 'events' | 'bot_events' | 'session_replay_events') {
+  const result = await clickhouse.query({
+    query: `SELECT engine_full FROM system.tables WHERE database = currentDatabase() AND name = {table:String}`,
+    query_params: { table },
+    format: "JSONEachRow",
+  });
+  const rows = await result.json() as {engine_full: string}[];
+  if (rows[0]?.engine_full.includes("non_replicated_deduplication_window")) {
+    return;
+  }
+
   await execClickhouseInitStep(
     `enable insert deduplication for ${table}`,
     `
       ALTER TABLE ${table}
         MODIFY SETTING non_replicated_deduplication_window = ${INSERT_DEDUPLICATION_WINDOW}
     `,
     { lockAcquireTimeoutSeconds: 15 }
   );
 }
📝 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
async function ensureInsertDeduplication(table: string) {
await execClickhouseInitStep(
`enable insert deduplication for ${table}`,
`
ALTER TABLE ${table}
MODIFY SETTING non_replicated_deduplication_window = ${INSERT_DEDUPLICATION_WINDOW}
`,
{ lockAcquireTimeoutSeconds: 15 }
);
}
async function ensureInsertDeduplication(table: 'events' | 'bot_events' | 'session_replay_events') {
const result = await clickhouse.query({
query: `SELECT engine_full FROM system.tables WHERE database = currentDatabase() AND name = {table:String}`,
query_params: { table },
format: "JSONEachRow",
});
const rows = await result.json() as {engine_full: string}[];
if (rows[0]?.engine_full.includes("non_replicated_deduplication_window")) {
return;
}
await execClickhouseInitStep(
`enable insert deduplication for ${table}`,
`
ALTER TABLE ${table}
MODIFY SETTING non_replicated_deduplication_window = ${INSERT_DEDUPLICATION_WINDOW}
`,
{ lockAcquireTimeoutSeconds: 15 }
);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/db/clickhouse/clickhouse.ts` around lines 96 - 106, Update
ensureInsertDeduplication to accept only the supported table-name string literal
union, then query system.tables for the target table’s current
non_replicated_deduplication_window before issuing ALTER TABLE. Execute the
ALTER only when the setting differs from INSERT_DEDUPLICATION_WINDOW, preserving
the existing initialization and lock options.

Source: Coding guidelines

The replay dedup query filters by (site_id, batch_id), which the sort key
(site_id, session_id, sequence_number) cannot serve, so every ingested
batch scanned all of a site's replay rows. Add a bloom filter skip index
on batch_id (healed into existing installations on boot) and bound the
lookup to the last two days so partition pruning covers parts that
predate the index.

Verified against ClickHouse: the lookup reads 4/62 granules instead of
62/62 on a 500k-row site.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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