Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion sdk/highlight-run/src/client/constants/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@
* The amount of time between sending the client-side payload to Highlight backend client.
* In milliseconds.
*/
export const SEND_FREQUENCY = 1000 * 2
export const SEND_FREQUENCY = 1000 * 15

/**
* Payload size threshold for triggering sends.
* In bytes.
*/
// TODO: This is a temporary low value for testing.

Check notice

Code scanning / devskim

A "TODO" or similar was left in source code, possibly indicating incomplete functionality Note

Suspicious comment
export const UNCOMPRESSED_PAYLOAD_SIZE_THRESHOLD = 1024 * 10 // 10KB

/**
* Maximum length of a session
Expand Down
Original file line number Diff line number Diff line change
@@ -1,34 +1,8 @@
export const PageVisibilityListener = (
callback: (isTabHidden: boolean) => void,
) => {
let hidden: string | undefined = undefined
let visibilityChangeEventName: string | undefined = undefined

if (typeof document.hidden !== 'undefined') {
// Opera 12.10 and Firefox 18 and later support
hidden = 'hidden'
visibilityChangeEventName = 'visibilitychange'
// @ts-expect-error
} else if (typeof document.msHidden !== 'undefined') {
hidden = 'msHidden'
visibilityChangeEventName = 'msvisibilitychange'
// @ts-expect-error
} else if (typeof document.webkitHidden !== 'undefined') {
hidden = 'webkitHidden'
visibilityChangeEventName = 'webkitvisibilitychange'
}

if (visibilityChangeEventName === undefined) {
return () => {}
}
if (hidden === undefined) {
return () => {}
}

const hiddenPropertyName = hidden
const listener = () => {
// @ts-expect-error
const tabState = document[hiddenPropertyName]
const tabState = document.hidden

if (tabState) {
callback(true)
Expand All @@ -37,9 +11,6 @@ export const PageVisibilityListener = (
}
}

document.addEventListener(visibilityChangeEventName, listener)

const eventNameToRemove = visibilityChangeEventName

return () => document.removeEventListener(eventNameToRemove, listener)
document.addEventListener('visibilitychange', listener)
return () => document.removeEventListener('visibilitychange', listener)
}
108 changes: 94 additions & 14 deletions sdk/highlight-run/src/sdk/record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
LAUNCHDARKLY_PATH_PREFIX,
LAUNCHDARKLY_URL,
MAX_SESSION_LENGTH,
UNCOMPRESSED_PAYLOAD_SIZE_THRESHOLD,
SEND_FREQUENCY,
SNAPSHOT_SETTINGS,
VISIBILITY_DEBOUNCE_MS,
Expand Down Expand Up @@ -86,6 +87,7 @@ import type { Hook, LDClient } from '../integrations/launchdarkly'
import { LaunchDarklyIntegration } from '../integrations/launchdarkly'
import { LDPluginEnvironmentMetadata } from '../plugins/plugin'
import { RecordOptions } from '../client/types/record'
import { strToU8 } from 'fflate'

interface HighlightWindow extends Window {
Highlight: Highlight
Expand Down Expand Up @@ -142,6 +144,8 @@ export class RecordSDK implements Record {
hasSessionUnloaded!: boolean
hasPushedData!: boolean
reloaded!: boolean
saving!: boolean
_estimatedEventsByteSize!: number
_hasPreviouslyInitialized!: boolean
_recordStop!: listenerHandler | undefined
_integrations: IntegrationClient[] = []
Expand Down Expand Up @@ -336,6 +340,8 @@ export class RecordSDK implements Record {
this.events = []
this.hasSessionUnloaded = false
this.hasPushedData = false
this.saving = false
this._estimatedEventsByteSize = 0

if (window.Intercom) {
window.Intercom('onShow', () => {
Expand Down Expand Up @@ -581,6 +587,12 @@ SessionSecureID: ${this.sessionData.sessionSecureID}`,
this.logger.log('received isCheckout emit', { event })
}
this.events.push(event)

// Check if we should send payload early based on size
if (!this.saving && this._checkForImmediateSave(event)) {
this.logger.log('Triggering save due to large payload size')
this._save()
}
}
emit.bind(this)

Expand Down Expand Up @@ -704,6 +716,7 @@ SessionSecureID: ${this.sessionData.sessionSecureID}`,
this.addCustomEvent('TabHidden', false)
} else {
this.addCustomEvent('TabHidden', true)
this._saveOnUnload()
if (this.options.disableBackgroundRecording) {
this.stop()
}
Expand Down Expand Up @@ -911,7 +924,7 @@ SessionSecureID: ${this.sessionData.sessionSecureID}`,
}
window.addEventListener('pagehide', unloadListener)
this.listeners.push(() =>
window.removeEventListener('beforeunload', unloadListener),
window.removeEventListener('pagehide', unloadListener),
)
}
}
Expand Down Expand Up @@ -987,6 +1000,11 @@ SessionSecureID: ${this.sessionData.sessionSecureID}`,

// Reset the events array and push to a backend.
async _save() {
if (this.saving) {
return
}
this.saving = true

try {
if (
this.state === 'Recording' &&
Expand All @@ -1001,6 +1019,9 @@ SessionSecureID: ${this.sessionData.sessionSecureID}`,
})
await this._reset({})
}

const eventsToSend = this._captureAndResetEventsState()

let sendFn = undefined
if (this.options?.sendMode === 'local') {
sendFn = async (payload: any) => {
Expand All @@ -1022,7 +1043,7 @@ SessionSecureID: ${this.sessionData.sessionSecureID}`,
return 0
}
}
await this._sendPayload({ sendFn })
await this._sendPayload({ sendFn, events: eventsToSend })
this.hasPushedData = true
this.sessionData.lastPushTime = Date.now()
setSessionData(this.sessionData)
Expand All @@ -1035,9 +1056,12 @@ SessionSecureID: ${this.sessionData.sessionSecureID}`,
this.pushPayloadTimerId = undefined
}
this.pushPayloadTimerId = setTimeout(() => {
this.logger.log(`Triggering save due to timeout`)
this._save()
}, SEND_FREQUENCY)
}

this.saving = false
}

/**
Expand Down Expand Up @@ -1066,13 +1090,13 @@ SessionSecureID: ${this.sessionData.sessionSecureID}`,

async _sendPayload({
sendFn,
events,
}: {
sendFn?: (
payload: PushSessionEventsMutationVariables,
) => Promise<number>
events: eventWithTime[]
}) {
const events = [...this.events]

// if it is time to take a full snapshot,
// ensure the snapshot is at the beginning of the next payload
// After snapshot thresholds have been met,
Expand Down Expand Up @@ -1108,7 +1132,9 @@ SessionSecureID: ${this.sessionData.sessionSecureID}`,
highlight_logs: highlightLogs || undefined,
}

const { compressedBase64 } = await payloadToBase64(sessionPayload)
const { compressedBase64, compressedSize } =
await payloadToBase64(sessionPayload)
this.logger.log(`Compressed payload size: ${compressedSize} bytes`)
await sendFn({
session_secure_id: this.sessionData.sessionSecureID,
payload_id: payloadId.toString(),
Expand All @@ -1133,15 +1159,6 @@ SessionSecureID: ${this.sessionData.sessionSecureID}`,
}
setSessionData(this.sessionData)

// We are creating a weak copy of the events. rrweb could have pushed more events to this.events while we send the request with the events as a payload.
// Originally, we would clear this.events but this could lead to a race condition.
// Example Scenario:
// 1. Create the events payload from this.events (with N events)
// 2. rrweb pushes to this.events (with M events)
// 3. Network request made to push payload (Only includes N events)
// 4. this.events is cleared (we lose M events)
this.events = this.events.slice(events.length)

clearHighlightLogs(highlightLogs)
}

Expand All @@ -1159,6 +1176,69 @@ SessionSecureID: ${this.sessionData.sessionSecureID}`,
this._lastSnapshotTime = new Date().getTime()
}

// Flush data if size threshold is exceeded
private _checkForImmediateSave(newEvent: eventWithTime): boolean {
if (this.state !== 'Recording' || !this.pushPayloadTimerId) {
return false
}

const newEventByteSize = strToU8(JSON.stringify(newEvent)).length
this._estimatedEventsByteSize += newEventByteSize
return (
this._estimatedEventsByteSize >= UNCOMPRESSED_PAYLOAD_SIZE_THRESHOLD
)
}

private _saveWithBeacon(
backendUrl: string,
payload: PushSessionEventsMutationVariables,
): Promise<number> {
let blob = new Blob(
[
JSON.stringify({
query: print(PushSessionEventsDocument),
variables: payload,
}),
],
{
type: 'application/json',
},
)
window.fetch(`${backendUrl}`, {
method: 'POST',
body: blob,
keepalive: true,
})
return Promise.resolve(0)
}

private _saveOnUnload(): void {
console.log('saving on unload', this.events.length)
if (this.events.length === 0) {
return
}

try {
const eventsToSend = this._captureAndResetEventsState()

this._sendPayload({
sendFn: (payload) =>
this._saveWithBeacon(this._backendUrl, payload),
events: eventsToSend,
})
} catch (error) {
this.logger.log('Failed to save session data on unload:', error)
}
}

// Atomic capture and reset events state synchronously to avoid race conditions
private _captureAndResetEventsState(): eventWithTime[] {
const eventsToSend = [...this.events]
this.events = []
this._estimatedEventsByteSize = 0
return eventsToSend
}

register(client: LDClient, metadata: LDPluginEnvironmentMetadata) {
this._integrations.push(new LaunchDarklyIntegration(client, metadata))
}
Expand Down
Loading