Implement session replay enhancements and batch processing improvements#1048
Implement session replay enhancements and batch processing improvements#1048goldflag wants to merge 3 commits into
Conversation
- 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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (2)
📝 WalkthroughWalkthroughSession 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. ChangesSession replay batching and persistence
Tracking request and identify ordering
Import concurrency control
Reliable ingestion queues and bounds
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
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
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (5)
server/src/services/tracker/identifyService.ts (1)
108-123: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low valueAvoid redundant inserts into
userProfiles.When
is_new_identifyis true andtraitsare provided, the transaction performs twoINSERToperations onuserProfiles: one withON CONFLICT DO NOTHING, and immediately another withON CONFLICT DO UPDATE.The first
INSERTis unnecessary when traits are provided, as the secondINSERTsafely 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 bypasstx.
withOrganizationImportLock'swork: () => Promise<T>signature has no way to receivetx, so every DB call insidecreateImport'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 competingSELECT ... FOR UPDATEblocks until this transaction commits, which only happens afterwork()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
txintowork(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,getImportByIdused inside the lock inbatchImportEvents.ts) to accept/usetxinstead of the module-leveldb.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 | 🔵 TrivialKeep the import lock short around Postgres work.
withOrganizationImportLockkeeps 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 winAdd teardown to stop the real singleton's flush interval.
pageviewQueueis the real module singleton; its constructor already started a livesetIntervalforPAGEVIEW_FLUSH_INTERVAL_MS.beforeEachresetsqueue/processing/closingbut nothing ever callspageviewQueue.close(), so the background interval keeps firing (with the mockedinsert/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 winAdd coverage for the new 503 path; consider
QueueClosedErrortoo.This new branch has no test asserting a
QueueFullErroryields a 503. Also,QueueClosedError(thrown byadd()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
📒 Files selected for processing (36)
server/public/script-full.jsserver/public/script.jsserver/src/analytics-script/sessionReplay.test.tsserver/src/analytics-script/sessionReplay.tsserver/src/analytics-script/tracking.test.tsserver/src/analytics-script/tracking.tsserver/src/analytics-script/types.tsserver/src/api/sessionReplay/recordSessionReplay.test.tsserver/src/api/sessionReplay/recordSessionReplay.tsserver/src/api/sites/batchImportEvents.test.tsserver/src/api/sites/batchImportEvents.tsserver/src/api/sites/createSiteImport.tsserver/src/api/sites/deleteSiteImport.tsserver/src/db/clickhouse/clickhouse.tsserver/src/index.tsserver/src/lib/clickhouseLimits.tsserver/src/services/import/importQuotaManager.tsserver/src/services/import/importStatusManager.test.tsserver/src/services/import/importStatusManager.tsserver/src/services/import/mappers/simpleAnalytics.tsserver/src/services/import/mappers/umami.tsserver/src/services/replay/sessionReplayIngestService.test.tsserver/src/services/replay/sessionReplayIngestService.tsserver/src/services/replay/sessionReplayQueryService.tsserver/src/services/storage/r2StorageService.test.tsserver/src/services/storage/r2StorageService.tsserver/src/services/tracker/botBlocking/botEventQueue.tsserver/src/services/tracker/identifyService.test.tsserver/src/services/tracker/identifyService.tsserver/src/services/tracker/pageviewQueue.test.tsserver/src/services/tracker/pageviewQueue.tsserver/src/services/tracker/reliableBatchQueue.test.tsserver/src/services/tracker/reliableBatchQueue.tsserver/src/services/tracker/trackEvent.test.tsserver/src/services/tracker/trackEvent.tsserver/src/types/sessionReplay.ts
💤 Files with no reviewable changes (2)
- server/src/services/import/importQuotaManager.ts
- server/src/api/sites/deleteSiteImport.ts
| 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; | ||
| } |
There was a problem hiding this comment.
🩺 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 onpendingBatches(drop oldest) and/or exponential backoff between retries inSessionReplayRecorder; this is the source of truth to fix.server/public/script-full.js#L432-L471: rebuild from the fixedsessionReplay.ts/tracking.tsso this compileddrainPendingBatchespicks up the cap/backoff.server/public/script.js#L1-L1: rebuild from the fixed source so this minifieddrainPendingBatchespicks up the cap/backoff.
📍 Affects 3 files
server/src/analytics-script/sessionReplay.ts#L250-L295(this comment)server/public/script-full.js#L432-L471server/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.
| } 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" }); | ||
| } |
There was a problem hiding this comment.
🎯 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
- 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.
There was a problem hiding this comment.
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 liftMissing skip index on
batch_idwill cause full table scans.During ingestion, the
session_replay_eventstable is queried bysite_idandbatch_idto deduplicate and resolve the associatedsession_id(as seen insessionReplayIngestService.ts). However, the table's sorting key is(site_id, session_id, sequence_number). Becausebatch_idis not in the index, querying it without asession_idforces 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) forbatch_idto allow ClickHouse to skip irrelevant granules.⚡ Proposed schema fixes
Update the
CREATE TABLEdefinition 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 TABLEblock 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
📒 Files selected for processing (19)
server/public/script-full.jsserver/public/script.jsserver/src/analytics-script/tracking.test.tsserver/src/analytics-script/tracking.tsserver/src/api/sites/batchImportEvents.test.tsserver/src/api/sites/batchImportEvents.tsserver/src/api/sites/createSiteImport.tsserver/src/db/clickhouse/clickhouse.tsserver/src/services/import/importQuotaTracker.test.tsserver/src/services/import/importQuotaTracker.tsserver/src/services/import/importStatusManager.test.tsserver/src/services/import/importStatusManager.tsserver/src/services/replay/sessionReplayIngestService.test.tsserver/src/services/replay/sessionReplayIngestService.tsserver/src/services/tracker/botBlocking/botEventQueue.tsserver/src/services/tracker/pageviewQueue.test.tsserver/src/services/tracker/pageviewQueue.tsserver/src/services/tracker/reliableBatchQueue.test.tsserver/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
| @@ -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()})();})(); | |||
There was a problem hiding this comment.
🩺 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])
PYRepository: 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 });
})();
JSRepository: 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.
| 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(); |
There was a problem hiding this comment.
🩺 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.
| 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.
| 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 } | ||
| ); | ||
| } | ||
|
|
There was a problem hiding this comment.
🩺 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.
| 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>
Summary by CodeRabbit
New Features
Bug Fixes
Tests