531 lines
16 KiB
TypeScript
531 lines
16 KiB
TypeScript
import App from './app/index.js'
|
|
|
|
export { default as App } from './app/index.js'
|
|
|
|
import { UserAnonymousID, CustomEvent, CustomIssue } from './app/messages.gen.js'
|
|
import * as _Messages from './app/messages.gen.js'
|
|
|
|
export const Messages = _Messages
|
|
export { SanitizeLevel } from './app/sanitizer.js'
|
|
|
|
import Connection from './modules/connection.js'
|
|
import Console from './modules/console.js'
|
|
import Exception, {
|
|
getExceptionMessageFromEvent,
|
|
getExceptionMessage,
|
|
} from './modules/exception.js'
|
|
import Img from './modules/img.js'
|
|
import Input from './modules/input.js'
|
|
import Mouse from './modules/mouse.js'
|
|
import Timing from './modules/timing.js'
|
|
import Performance from './modules/performance.js'
|
|
import Scroll from './modules/scroll.js'
|
|
import Viewport from './modules/viewport.js'
|
|
import CSSRules from './modules/cssrules.js'
|
|
import Focus from './modules/focus.js'
|
|
import Fonts from './modules/fonts.js'
|
|
import Network from './modules/network.js'
|
|
import ConstructedStyleSheets from './modules/constructedStyleSheets.js'
|
|
import Selection from './modules/selection.js'
|
|
import Tabs from './modules/tabs.js'
|
|
|
|
import { IN_BROWSER, deprecationWarn, DOCS_HOST, inIframe } from './utils.js'
|
|
import FeatureFlags, { IFeatureFlag } from './modules/featureFlags.js'
|
|
import type { Options as AppOptions } from './app/index.js'
|
|
import type { Options as ConsoleOptions } from './modules/console.js'
|
|
import type { Options as ExceptionOptions } from './modules/exception.js'
|
|
import type { Options as InputOptions } from './modules/input.js'
|
|
import type { Options as PerformanceOptions } from './modules/performance.js'
|
|
import type { Options as TimingOptions } from './modules/timing.js'
|
|
import type { Options as NetworkOptions } from './modules/network.js'
|
|
import type { MouseHandlerOptions } from './modules/mouse.js'
|
|
import type { SessionInfo } from './app/session.js'
|
|
|
|
import type { StartOptions } from './app/index.js'
|
|
//TODO: unique options init
|
|
import type { StartPromiseReturn } from './app/index.js'
|
|
|
|
export type Options = Partial<
|
|
AppOptions & ConsoleOptions & ExceptionOptions & InputOptions & PerformanceOptions & TimingOptions
|
|
> & {
|
|
projectID?: number // For the back compatibility only (deprecated)
|
|
projectKey: string
|
|
sessionToken?: string
|
|
respectDoNotTrack?: boolean
|
|
autoResetOnWindowOpen?: boolean
|
|
resetTabOnWindowOpen?: boolean
|
|
network?: Partial<NetworkOptions>
|
|
mouse?: Partial<MouseHandlerOptions>
|
|
flags?: {
|
|
onFlagsLoad?: (flags: IFeatureFlag[]) => void
|
|
}
|
|
// dev only
|
|
__DISABLE_SECURE_MODE?: boolean
|
|
}
|
|
|
|
const DOCS_SETUP = '/installation/javascript-sdk'
|
|
|
|
function processOptions(obj: any): obj is Options {
|
|
if (obj == null) {
|
|
console.error(
|
|
`OpenReplay: invalid options argument type. Please, check documentation on ${DOCS_HOST}${DOCS_SETUP}`,
|
|
)
|
|
return false
|
|
}
|
|
if (typeof obj.projectKey !== 'string') {
|
|
if (typeof obj.projectKey !== 'number') {
|
|
if (typeof obj.projectID !== 'number') {
|
|
// Back compatability
|
|
console.error(
|
|
`OpenReplay: projectKey is missing or wrong type (string is expected). Please, check ${DOCS_HOST}${DOCS_SETUP} for more information.`,
|
|
)
|
|
return false
|
|
} else {
|
|
obj.projectKey = obj.projectID.toString()
|
|
deprecationWarn('`projectID` option', '`projectKey` option', DOCS_SETUP)
|
|
}
|
|
} else {
|
|
console.warn('OpenReplay: projectKey is expected to have a string type.')
|
|
obj.projectKey = obj.projectKey.toString()
|
|
}
|
|
}
|
|
if (obj.sessionToken != null) {
|
|
deprecationWarn('`sessionToken` option', '`sessionHash` start() option', '/')
|
|
}
|
|
return true
|
|
}
|
|
|
|
const canAccessTop = () => {
|
|
try {
|
|
return Boolean(window.top?.document)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
export default class API {
|
|
public featureFlags: FeatureFlags
|
|
|
|
private readonly app: App | null = null
|
|
private readonly crossdomainMode: boolean = false
|
|
|
|
constructor(public readonly options: Partial<Options>) {
|
|
this.crossdomainMode = Boolean(inIframe() && options.crossdomain?.enabled)
|
|
if (!IN_BROWSER || !processOptions(options)) {
|
|
return
|
|
}
|
|
if (
|
|
(window as any).__OPENREPLAY__ ||
|
|
(!this.crossdomainMode && inIframe() && canAccessTop() && (window.top as any).__OPENREPLAY__)
|
|
) {
|
|
console.error('OpenReplay: one tracker instance has been initialised already')
|
|
return
|
|
}
|
|
if (!options.__DISABLE_SECURE_MODE && location.protocol !== 'https:') {
|
|
console.error(
|
|
'OpenReplay: Your website must be publicly accessible and running on SSL in order for OpenReplay to properly capture and replay the user session. You can disable this check by setting `__DISABLE_SECURE_MODE` option to `true` if you are testing in localhost. Keep in mind, that asset files on a local machine are not available to the outside world. This might affect tracking if you use css files.',
|
|
)
|
|
return
|
|
}
|
|
const doNotTrack = this.checkDoNotTrack()
|
|
const failReason: string[] = []
|
|
const conditions: string[] = [
|
|
'Map',
|
|
'Set',
|
|
'MutationObserver',
|
|
'performance',
|
|
'timing',
|
|
'startsWith',
|
|
'Blob',
|
|
'Worker',
|
|
]
|
|
|
|
if (doNotTrack) {
|
|
failReason.push('doNotTrack')
|
|
} else {
|
|
for (const condition of conditions) {
|
|
if (condition === 'timing') {
|
|
if ('performance' in window && !(condition in performance)) {
|
|
failReason.push(condition)
|
|
break
|
|
}
|
|
} else if (condition === 'startsWith') {
|
|
if (!(condition in String.prototype)) {
|
|
failReason.push(condition)
|
|
break
|
|
}
|
|
} else {
|
|
if (!(condition in window)) {
|
|
failReason.push(condition)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (failReason.length > 0) {
|
|
const missingApi = failReason.join(',')
|
|
console.error(
|
|
`OpenReplay: browser doesn't support API required for tracking or doNotTrack is set to 1. Reason: ${missingApi}`,
|
|
)
|
|
this.signalStartIssue('missing_api', failReason)
|
|
return
|
|
}
|
|
|
|
const app = new App(
|
|
options.projectKey,
|
|
options.sessionToken,
|
|
options,
|
|
this.signalStartIssue,
|
|
this.crossdomainMode,
|
|
)
|
|
this.app = app
|
|
if (!this.crossdomainMode) {
|
|
// no need to send iframe viewport data since its a node for us
|
|
Viewport(app)
|
|
// calculated in main window
|
|
Connection(app)
|
|
// while we can calculate it here, trying to compute it for all parts is hard
|
|
Performance(app, options)
|
|
// no tabs in iframes yet
|
|
Tabs(app)
|
|
}
|
|
Mouse(app, options.mouse)
|
|
// inside iframe, we ignore viewport scroll
|
|
Scroll(app, this.crossdomainMode)
|
|
CSSRules(app)
|
|
ConstructedStyleSheets(app)
|
|
Console(app, options)
|
|
Exception(app, options)
|
|
Img(app)
|
|
Input(app, options)
|
|
Timing(app, options)
|
|
Focus(app)
|
|
Fonts(app)
|
|
const skipNetwork = options.network?.disabled
|
|
if (!skipNetwork) {
|
|
Network(app, options.network)
|
|
}
|
|
Selection(app)
|
|
;(window as any).__OPENREPLAY__ = this
|
|
|
|
if (options.flags && options.flags.onFlagsLoad) {
|
|
this.onFlagsLoad(options.flags.onFlagsLoad)
|
|
}
|
|
const wOpen = window.open
|
|
if (options.autoResetOnWindowOpen || options.resetTabOnWindowOpen) {
|
|
app.attachStartCallback(() => {
|
|
const tabId = app.getTabId()
|
|
const sessStorage = app.sessionStorage ?? window.sessionStorage
|
|
window.open = function (...args) {
|
|
if (options.autoResetOnWindowOpen) {
|
|
app.resetNextPageSession(true)
|
|
}
|
|
if (options.resetTabOnWindowOpen) {
|
|
sessStorage.removeItem(options.session_tabid_key || '__openreplay_tabid')
|
|
}
|
|
app.resetNextPageSession(false)
|
|
sessStorage.setItem(options.session_tabid_key || '__openreplay_tabid', tabId)
|
|
return wOpen.call(window, ...args)
|
|
}
|
|
})
|
|
app.attachStopCallback(() => {
|
|
window.open = wOpen
|
|
})
|
|
}
|
|
}
|
|
|
|
checkDoNotTrack = () => {
|
|
return (
|
|
this.options.respectDoNotTrack &&
|
|
(navigator.doNotTrack == '1' ||
|
|
// @ts-ignore
|
|
window.doNotTrack == '1')
|
|
)
|
|
}
|
|
|
|
signalStartIssue = (reason: string, missingApi: string[]) => {
|
|
const doNotTrack = this.checkDoNotTrack()
|
|
console.log(
|
|
"Tracker couldn't start due to:",
|
|
JSON.stringify({
|
|
trackerVersion: 'TRACKER_VERSION',
|
|
projectKey: this.options.projectKey,
|
|
doNotTrack,
|
|
reason: missingApi.length ? `missing api: ${missingApi.join(',')}` : reason,
|
|
}),
|
|
)
|
|
}
|
|
|
|
isFlagEnabled(flagName: string): boolean {
|
|
return this.featureFlags.isFlagEnabled(flagName)
|
|
}
|
|
|
|
onFlagsLoad(callback: (flags: IFeatureFlag[]) => void): void {
|
|
this.app?.featureFlags.onFlagsLoad(callback)
|
|
}
|
|
|
|
clearPersistFlags() {
|
|
this.app?.featureFlags.clearPersistFlags()
|
|
}
|
|
|
|
reloadFlags() {
|
|
return this.app?.featureFlags.reloadFlags()
|
|
}
|
|
|
|
getFeatureFlag(flagName: string): IFeatureFlag | undefined {
|
|
return this.app?.featureFlags.getFeatureFlag(flagName)
|
|
}
|
|
|
|
getAllFeatureFlags() {
|
|
return this.app?.featureFlags.flags
|
|
}
|
|
|
|
public restartCanvasTracking = () => {
|
|
if (this.app === null) {
|
|
return
|
|
}
|
|
this.app.restartCanvasTracking()
|
|
}
|
|
|
|
use<T>(fn: (app: App | null, options?: Partial<Options>) => T): T {
|
|
return fn(this.app, this.options)
|
|
}
|
|
|
|
isActive(): boolean {
|
|
if (this.app === null) {
|
|
return false
|
|
}
|
|
return this.app.active()
|
|
}
|
|
|
|
/**
|
|
* Creates a named hook that expects event name, data string and msg direction (up/down),
|
|
* it will skip any message bigger than 5 mb or event name bigger than 255 symbols
|
|
* msg direction is "down" (incoming) by default
|
|
*
|
|
* @returns {(msgType: string, data: string, dir: 'up' | 'down') => void}
|
|
* */
|
|
trackWs(channelName: string) {
|
|
if (this.app === null) {
|
|
return
|
|
}
|
|
return this.app.trackWs(channelName)
|
|
}
|
|
|
|
start(startOpts?: Partial<StartOptions>): Promise<StartPromiseReturn> {
|
|
if (this.browserEnvCheck()) {
|
|
if (this.app === null) {
|
|
return Promise.reject("Browser doesn't support required api, or doNotTrack is active.")
|
|
}
|
|
return this.app.start(startOpts)
|
|
} else {
|
|
return Promise.reject('Trying to start not in browser.')
|
|
}
|
|
}
|
|
|
|
browserEnvCheck() {
|
|
if (!IN_BROWSER) {
|
|
console.error(
|
|
`OpenReplay: you are trying to start Tracker on a node.js environment. If you want to use OpenReplay with SSR, please, use componentDidMount or useEffect API for placing the \`tracker.start()\` line. Check documentation on ${DOCS_HOST}${DOCS_SETUP}`,
|
|
)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* start buffering messages without starting the actual session, which gives user 30 seconds to "activate" and record
|
|
* session by calling start() on conditional trigger and we will then send buffered batch, so it won't get lost
|
|
* */
|
|
coldStart(startOpts?: Partial<StartOptions>, conditional?: boolean) {
|
|
if (this.browserEnvCheck()) {
|
|
if (this.app === null) {
|
|
return Promise.reject('Tracker not initialized')
|
|
}
|
|
void this.app.coldStart(startOpts, conditional)
|
|
} else {
|
|
return Promise.reject('Trying to start not in browser.')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Starts offline session recording. Keep in mind that only user device time will be used for timestamps.
|
|
* (no backend delay sync)
|
|
*
|
|
* @param {Object} startOpts - options for session start, same as .start()
|
|
* @param {Function} onSessionSent - callback that will be called once session is fully sent
|
|
* @returns methods to manipulate buffer:
|
|
*
|
|
* saveBuffer - to save it in localStorage
|
|
*
|
|
* getBuffer - returns current buffer
|
|
*
|
|
* setBuffer - replaces current buffer with given
|
|
* */
|
|
startOfflineRecording(startOpts: Partial<StartOptions>, onSessionSent: () => void) {
|
|
if (this.browserEnvCheck()) {
|
|
if (this.app === null) {
|
|
return Promise.reject('Tracker not initialized')
|
|
}
|
|
return this.app.offlineRecording(startOpts, onSessionSent)
|
|
} else {
|
|
return Promise.reject('Trying to start not in browser.')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Uploads the stored session buffer to backend
|
|
* @returns promise that resolves once messages are loaded, it has to be awaited
|
|
* so the session can be uploaded properly
|
|
* @resolve - if messages were loaded into service worker successfully
|
|
* @reject {string} - error message
|
|
* */
|
|
uploadOfflineRecording() {
|
|
if (this.app === null) {
|
|
return
|
|
}
|
|
return this.app.uploadOfflineRecording()
|
|
}
|
|
|
|
stop(): string | undefined {
|
|
if (this.app === null) {
|
|
return
|
|
}
|
|
this.app.stop()
|
|
return this.app.session.getSessionHash()
|
|
}
|
|
|
|
forceFlushBatch() {
|
|
if (this.app === null) {
|
|
return
|
|
}
|
|
this.app.forceFlushBatch()
|
|
}
|
|
|
|
getSessionToken(): string | null | undefined {
|
|
if (this.app === null) {
|
|
return null
|
|
}
|
|
return this.app.getSessionToken()
|
|
}
|
|
|
|
getSessionInfo(): SessionInfo | null {
|
|
if (this.app === null) {
|
|
return null
|
|
}
|
|
return this.app.session.getInfo()
|
|
}
|
|
|
|
getSessionID(): string | null | undefined {
|
|
if (this.app === null) {
|
|
return null
|
|
}
|
|
return this.app.getSessionID()
|
|
}
|
|
|
|
getTabId() {
|
|
if (this.app === null) {
|
|
return null
|
|
}
|
|
return this.app.getTabId()
|
|
}
|
|
|
|
getUxId() {
|
|
if (this.app === null) {
|
|
return null
|
|
}
|
|
return this.app.getUxtId()
|
|
}
|
|
|
|
sessionID(): string | null | undefined {
|
|
deprecationWarn("'sessionID' method", "'getSessionID' method", '/')
|
|
return this.getSessionID()
|
|
}
|
|
|
|
getSessionURL(options?: { withCurrentTime?: boolean }): string | undefined {
|
|
if (this.app === null) {
|
|
return undefined
|
|
}
|
|
return this.app.getSessionURL(options)
|
|
}
|
|
|
|
setUserID(id: string): void {
|
|
if (typeof id === 'string' && this.app !== null) {
|
|
this.app.session.setUserID(id)
|
|
}
|
|
}
|
|
|
|
userID(id: string): void {
|
|
deprecationWarn("'userID' method", "'setUserID' method", '/')
|
|
this.setUserID(id)
|
|
}
|
|
|
|
setUserAnonymousID(id: string): void {
|
|
if (typeof id === 'string' && this.app !== null) {
|
|
this.app.send(UserAnonymousID(id))
|
|
}
|
|
}
|
|
|
|
userAnonymousID(id: string): void {
|
|
deprecationWarn("'userAnonymousID' method", "'setUserAnonymousID' method", '/')
|
|
this.setUserAnonymousID(id)
|
|
}
|
|
|
|
setMetadata(key: string, value: string): void {
|
|
if (typeof key === 'string' && typeof value === 'string' && this.app !== null) {
|
|
this.app.session.setMetadata(key, value)
|
|
}
|
|
}
|
|
|
|
metadata(key: string, value: string): void {
|
|
deprecationWarn("'metadata' method", "'setMetadata' method", '/')
|
|
this.setMetadata(key, value)
|
|
}
|
|
|
|
event(key: string, payload: any = null, issue = false): void {
|
|
if (typeof key === 'string' && this.app !== null) {
|
|
if (issue) {
|
|
return this.issue(key, payload)
|
|
} else {
|
|
try {
|
|
payload = JSON.stringify(payload)
|
|
} catch (e) {
|
|
return
|
|
}
|
|
this.app.send(CustomEvent(key, payload))
|
|
}
|
|
}
|
|
}
|
|
|
|
issue(key: string, payload: any = null): void {
|
|
if (typeof key === 'string' && this.app !== null) {
|
|
try {
|
|
payload = JSON.stringify(payload)
|
|
} catch (e) {
|
|
return
|
|
}
|
|
this.app.send(CustomIssue(key, payload))
|
|
}
|
|
}
|
|
|
|
handleError = (
|
|
e: Error | ErrorEvent | PromiseRejectionEvent,
|
|
metadata: Record<string, any> = {},
|
|
) => {
|
|
if (this.app === null) {
|
|
return
|
|
}
|
|
if (e instanceof Error) {
|
|
const msg = getExceptionMessage(e, [], metadata)
|
|
this.app.send(msg)
|
|
} else if (
|
|
e instanceof ErrorEvent ||
|
|
('PromiseRejectionEvent' in window && e instanceof PromiseRejectionEvent)
|
|
) {
|
|
const msg = getExceptionMessageFromEvent(e, undefined, metadata)
|
|
if (msg != null) {
|
|
this.app.send(msg)
|
|
}
|
|
}
|
|
}
|
|
}
|