@amplitude/plugin-session-replay-browser
Advanced tools
Comparing version 0.6.6 to 0.6.7
@@ -1,17 +0,2 @@ | ||
import { IDBStoreSession } from './typings/session-replay'; | ||
export declare const DEFAULT_EVENT_PROPERTY_PREFIX = "[Amplitude]"; | ||
export declare const DEFAULT_SESSION_REPLAY_PROPERTY: string; | ||
export declare const DEFAULT_SESSION_START_EVENT = "session_start"; | ||
export declare const DEFAULT_SESSION_END_EVENT = "session_end"; | ||
export declare const BLOCK_CLASS = "amp-block"; | ||
export declare const MASK_TEXT_CLASS = "amp-mask"; | ||
export declare const UNMASK_TEXT_CLASS = "amp-unmask"; | ||
export declare const SESSION_REPLAY_SERVER_URL = "https://api-secure.amplitude.com/sessions/track"; | ||
export declare const SESSION_REPLAY_EU_URL = "https://api.eu.amplitude.com/sessions/track"; | ||
export declare const STORAGE_PREFIX: string; | ||
export declare const MAX_EVENT_LIST_SIZE_IN_BYTES: number; | ||
export declare const MIN_INTERVAL = 500; | ||
export declare const MAX_INTERVAL: number; | ||
export declare const defaultSessionStore: IDBStoreSession; | ||
export declare const MAX_IDB_STORAGE_LENGTH: number; | ||
//# sourceMappingURL=constants.d.ts.map |
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.MAX_IDB_STORAGE_LENGTH = exports.defaultSessionStore = exports.MAX_INTERVAL = exports.MIN_INTERVAL = exports.MAX_EVENT_LIST_SIZE_IN_BYTES = exports.STORAGE_PREFIX = exports.SESSION_REPLAY_EU_URL = exports.SESSION_REPLAY_SERVER_URL = exports.UNMASK_TEXT_CLASS = exports.MASK_TEXT_CLASS = exports.BLOCK_CLASS = exports.DEFAULT_SESSION_END_EVENT = exports.DEFAULT_SESSION_START_EVENT = exports.DEFAULT_SESSION_REPLAY_PROPERTY = exports.DEFAULT_EVENT_PROPERTY_PREFIX = void 0; | ||
var analytics_core_1 = require("@amplitude/analytics-core"); | ||
exports.DEFAULT_EVENT_PROPERTY_PREFIX = '[Amplitude]'; | ||
exports.DEFAULT_SESSION_REPLAY_PROPERTY = "".concat(exports.DEFAULT_EVENT_PROPERTY_PREFIX, " Session Recorded"); | ||
exports.DEFAULT_SESSION_START_EVENT = void 0; | ||
exports.DEFAULT_SESSION_START_EVENT = 'session_start'; | ||
exports.DEFAULT_SESSION_END_EVENT = 'session_end'; | ||
exports.BLOCK_CLASS = 'amp-block'; | ||
exports.MASK_TEXT_CLASS = 'amp-mask'; | ||
exports.UNMASK_TEXT_CLASS = 'amp-unmask'; | ||
exports.SESSION_REPLAY_SERVER_URL = 'https://api-secure.amplitude.com/sessions/track'; | ||
exports.SESSION_REPLAY_EU_URL = 'https://api.eu.amplitude.com/sessions/track'; | ||
exports.STORAGE_PREFIX = "".concat(analytics_core_1.AMPLITUDE_PREFIX, "_replay_unsent"); | ||
var PAYLOAD_ESTIMATED_SIZE_IN_BYTES_WITHOUT_EVENTS = 500; // derived by JSON stringifying an example payload without events | ||
exports.MAX_EVENT_LIST_SIZE_IN_BYTES = 10 * 1000000 - PAYLOAD_ESTIMATED_SIZE_IN_BYTES_WITHOUT_EVENTS; | ||
exports.MIN_INTERVAL = 500; // 500 ms | ||
exports.MAX_INTERVAL = 10 * 1000; // 10 seconds | ||
exports.defaultSessionStore = { | ||
currentSequenceId: 0, | ||
sessionSequences: {}, | ||
}; | ||
exports.MAX_IDB_STORAGE_LENGTH = 1000 * 60 * 60 * 24 * 3; // 3 days | ||
//# sourceMappingURL=constants.js.map |
@@ -1,3 +0,14 @@ | ||
import { SessionReplayPlugin } from './typings/session-replay'; | ||
export declare const sessionReplayPlugin: SessionReplayPlugin; | ||
import { BrowserConfig, EnrichmentPlugin, Event } from '@amplitude/analytics-types'; | ||
import { SessionReplayOptions } from './typings/session-replay'; | ||
export declare class SessionReplayPlugin implements EnrichmentPlugin { | ||
name: string; | ||
type: "enrichment"; | ||
config: BrowserConfig; | ||
options: SessionReplayOptions; | ||
constructor(options?: SessionReplayOptions); | ||
setup(config: BrowserConfig): Promise<void>; | ||
execute(event: Event): Promise<Event>; | ||
teardown(): Promise<void>; | ||
} | ||
export declare const sessionReplayPlugin: (options?: SessionReplayOptions) => EnrichmentPlugin; | ||
//# sourceMappingURL=session-replay.d.ts.map |
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.sessionReplayPlugin = void 0; | ||
exports.sessionReplayPlugin = exports.SessionReplayPlugin = void 0; | ||
var tslib_1 = require("tslib"); | ||
var analytics_client_common_1 = require("@amplitude/analytics-client-common"); | ||
var analytics_core_1 = require("@amplitude/analytics-core"); | ||
var analytics_types_1 = require("@amplitude/analytics-types"); | ||
var IDBKeyVal = tslib_1.__importStar(require("idb-keyval")); | ||
var rrweb_1 = require("rrweb"); | ||
var sessionReplay = tslib_1.__importStar(require("@amplitude/session-replay-browser")); | ||
var constants_1 = require("./constants"); | ||
var helpers_1 = require("./helpers"); | ||
var messages_1 = require("./messages"); | ||
var session_replay_1 = require("./typings/session-replay"); | ||
var SessionReplay = /** @class */ (function () { | ||
function SessionReplay(options) { | ||
var _this = this; | ||
var SessionReplayPlugin = /** @class */ (function () { | ||
function SessionReplayPlugin(options) { | ||
this.name = '@amplitude/plugin-session-replay-browser'; | ||
this.type = 'enrichment'; | ||
this.storageKey = ''; | ||
this.retryTimeout = 1000; | ||
this.events = []; | ||
this.currentSequenceId = 0; | ||
this.scheduled = null; | ||
this.queue = []; | ||
this.stopRecordingEvents = null; | ||
this.maxPersistedEventsSize = constants_1.MAX_EVENT_LIST_SIZE_IN_BYTES; | ||
this.interval = constants_1.MIN_INTERVAL; | ||
this.timeAtLastSend = null; | ||
this.blurListener = function () { | ||
_this.stopRecordingAndSendEvents(); | ||
}; | ||
this.focusListener = function () { | ||
void _this.initialize(); | ||
}; | ||
/** | ||
* Determines whether to send the events list to the backend and start a new | ||
* empty events list, based on the size of the list as well as the last time sent | ||
* @param nextEventString | ||
* @returns boolean | ||
*/ | ||
this.shouldSplitEventsList = function (nextEventString) { | ||
var sizeOfNextEvent = new Blob([nextEventString]).size; | ||
var sizeOfEventsList = new Blob(_this.events).size; | ||
if (sizeOfEventsList + sizeOfNextEvent >= _this.maxPersistedEventsSize) { | ||
return true; | ||
} | ||
if (_this.timeAtLastSend !== null && Date.now() - _this.timeAtLastSend > _this.interval && _this.events.length) { | ||
_this.interval = Math.min(constants_1.MAX_INTERVAL, _this.interval + constants_1.MIN_INTERVAL); | ||
_this.timeAtLastSend = Date.now(); | ||
return true; | ||
} | ||
return false; | ||
}; | ||
this.options = tslib_1.__assign({}, options); | ||
} | ||
SessionReplay.prototype.setup = function (config) { | ||
SessionReplayPlugin.prototype.setup = function (config) { | ||
return tslib_1.__awaiter(this, void 0, void 0, function () { | ||
var globalScope; | ||
return tslib_1.__generator(this, function (_a) { | ||
@@ -63,4 +19,2 @@ switch (_a.label) { | ||
this.config = config; | ||
this.config.sessionId = config.sessionId; | ||
this.storageKey = "".concat(constants_1.STORAGE_PREFIX, "_").concat(this.config.apiKey.substring(0, 10)); | ||
if (typeof config.defaultTracking === 'boolean') { | ||
@@ -79,98 +33,15 @@ if (config.defaultTracking === false) { | ||
} | ||
globalScope = (0, analytics_client_common_1.getGlobalScope)(); | ||
if (globalScope) { | ||
globalScope.addEventListener('blur', this.blurListener); | ||
globalScope.addEventListener('focus', this.focusListener); | ||
} | ||
if (!(globalScope && globalScope.document && globalScope.document.hasFocus())) return [3 /*break*/, 2]; | ||
return [4 /*yield*/, this.initialize(true)]; | ||
return [4 /*yield*/, sessionReplay.init(config.apiKey, { | ||
instanceName: this.config.instanceName, | ||
deviceId: this.config.deviceId, | ||
optOut: this.config.optOut, | ||
sessionId: this.config.sessionId, | ||
loggerProvider: this.config.loggerProvider, | ||
logLevel: this.config.logLevel, | ||
flushMaxRetries: this.config.flushMaxRetries, | ||
serverZone: this.config.serverZone, | ||
sampleRate: this.options.sampleRate, | ||
}).promise]; | ||
case 1: | ||
_a.sent(); | ||
_a.label = 2; | ||
case 2: return [2 /*return*/]; | ||
} | ||
}); | ||
}); | ||
}; | ||
SessionReplay.prototype.stopRecordingAndSendEvents = function (sessionId) { | ||
try { | ||
this.stopRecordingEvents && this.stopRecordingEvents(); | ||
this.stopRecordingEvents = null; | ||
} | ||
catch (error) { | ||
var typedError = error; | ||
this.config.loggerProvider.error("Error occurred while stopping recording: ".concat(typedError.toString())); | ||
} | ||
var sessionIdToSend = sessionId || this.config.sessionId; | ||
if (this.events.length && sessionIdToSend) { | ||
this.sendEventsList({ | ||
events: this.events, | ||
sequenceId: this.currentSequenceId, | ||
sessionId: sessionIdToSend, | ||
}); | ||
} | ||
}; | ||
SessionReplay.prototype.execute = function (event) { | ||
return tslib_1.__awaiter(this, void 0, void 0, function () { | ||
var globalScope, shouldRecord; | ||
var _a; | ||
return tslib_1.__generator(this, function (_b) { | ||
globalScope = (0, analytics_client_common_1.getGlobalScope)(); | ||
if (globalScope && globalScope.document && !globalScope.document.hasFocus()) { | ||
return [2 /*return*/, Promise.resolve(event)]; | ||
} | ||
if (event.event_type === constants_1.DEFAULT_SESSION_START_EVENT && !this.stopRecordingEvents) { | ||
this.recordEvents(); | ||
} | ||
else if (event.event_type === constants_1.DEFAULT_SESSION_END_EVENT) { | ||
this.stopRecordingAndSendEvents(event.session_id); | ||
this.events = []; | ||
this.currentSequenceId = 0; | ||
} | ||
shouldRecord = this.getShouldRecord(); | ||
if (shouldRecord) { | ||
event.event_properties = tslib_1.__assign(tslib_1.__assign({}, event.event_properties), (_a = {}, _a[constants_1.DEFAULT_SESSION_REPLAY_PROPERTY] = true, _a)); | ||
} | ||
return [2 /*return*/, Promise.resolve(event)]; | ||
}); | ||
}); | ||
}; | ||
SessionReplay.prototype.initialize = function (shouldSendStoredEvents) { | ||
if (shouldSendStoredEvents === void 0) { shouldSendStoredEvents = false; } | ||
return tslib_1.__awaiter(this, void 0, void 0, function () { | ||
var storedReplaySessions, storedSequencesForSession, storedSeqId, lastSequence; | ||
return tslib_1.__generator(this, function (_a) { | ||
switch (_a.label) { | ||
case 0: | ||
this.timeAtLastSend = Date.now(); // Initialize this so we have a point of comparison when events are recorded | ||
if (!this.config.sessionId) { | ||
return [2 /*return*/]; | ||
} | ||
return [4 /*yield*/, this.getAllSessionEventsFromStore()]; | ||
case 1: | ||
storedReplaySessions = _a.sent(); | ||
// This resolves a timing issue when focus is fired multiple times in short succession, | ||
// we only want the rest of this function to run once. We can be sure that initialize has | ||
// already been called if this.stopRecordingEvents is defined | ||
if (this.stopRecordingEvents) { | ||
return [2 /*return*/]; | ||
} | ||
storedSequencesForSession = storedReplaySessions && storedReplaySessions[this.config.sessionId]; | ||
if (storedReplaySessions && storedSequencesForSession && storedSequencesForSession.sessionSequences) { | ||
storedSeqId = storedSequencesForSession.currentSequenceId; | ||
lastSequence = storedSequencesForSession.sessionSequences[storedSeqId]; | ||
if (lastSequence && lastSequence.status !== session_replay_1.RecordingStatus.RECORDING) { | ||
this.currentSequenceId = storedSeqId + 1; | ||
this.events = []; | ||
} | ||
else { | ||
// Pick up recording where it was left off in another tab or window | ||
this.currentSequenceId = storedSeqId; | ||
this.events = (lastSequence === null || lastSequence === void 0 ? void 0 : lastSequence.events) || []; | ||
} | ||
} | ||
if (shouldSendStoredEvents && storedReplaySessions) { | ||
this.sendStoredEvents(storedReplaySessions); | ||
} | ||
this.recordEvents(); | ||
return [2 /*return*/]; | ||
@@ -181,346 +52,19 @@ } | ||
}; | ||
SessionReplay.prototype.getShouldRecord = function () { | ||
if (this.config.optOut) { | ||
return false; | ||
} | ||
else if (!this.config.sessionId) { | ||
return false; | ||
} | ||
else if (this.options && this.options.sampleRate) { | ||
return (0, helpers_1.isSessionInSample)(this.config.sessionId, this.options.sampleRate); | ||
} | ||
return true; | ||
}; | ||
SessionReplay.prototype.sendStoredEvents = function (storedReplaySessions) { | ||
for (var sessionId in storedReplaySessions) { | ||
var storedSequences = storedReplaySessions[sessionId].sessionSequences; | ||
for (var storedSeqId in storedSequences) { | ||
var seq = storedSequences[storedSeqId]; | ||
var numericSeqId = parseInt(storedSeqId, 10); | ||
var numericSessionId = parseInt(sessionId, 10); | ||
if (numericSessionId === this.config.sessionId && numericSeqId === this.currentSequenceId) { | ||
continue; | ||
} | ||
if (seq.events.length && seq.status === session_replay_1.RecordingStatus.RECORDING) { | ||
this.sendEventsList({ | ||
events: seq.events, | ||
sequenceId: numericSeqId, | ||
sessionId: numericSessionId, | ||
}); | ||
} | ||
} | ||
} | ||
}; | ||
SessionReplay.prototype.recordEvents = function () { | ||
var _this = this; | ||
var shouldRecord = this.getShouldRecord(); | ||
if (!shouldRecord && this.config.sessionId) { | ||
this.config.loggerProvider.log("Opting session ".concat(this.config.sessionId, " out of recording.")); | ||
return; | ||
} | ||
this.stopRecordingEvents = (0, rrweb_1.record)({ | ||
emit: function (event) { | ||
var globalScope = (0, analytics_client_common_1.getGlobalScope)(); | ||
if (globalScope && globalScope.document && !globalScope.document.hasFocus()) { | ||
_this.stopRecordingAndSendEvents(); | ||
return; | ||
} | ||
var eventString = JSON.stringify(event); | ||
var shouldSplit = _this.shouldSplitEventsList(eventString); | ||
if (shouldSplit) { | ||
_this.sendEventsList({ | ||
events: _this.events, | ||
sequenceId: _this.currentSequenceId, | ||
sessionId: _this.config.sessionId, | ||
}); | ||
_this.events = []; | ||
_this.currentSequenceId++; | ||
} | ||
_this.events.push(eventString); | ||
void _this.storeEventsForSession(_this.events, _this.currentSequenceId, _this.config.sessionId); | ||
}, | ||
packFn: rrweb_1.pack, | ||
maskAllInputs: true, | ||
maskTextClass: constants_1.MASK_TEXT_CLASS, | ||
blockClass: constants_1.BLOCK_CLASS, | ||
maskInputFn: helpers_1.maskInputFn, | ||
recordCanvas: false, | ||
errorHandler: function (error) { | ||
var typedError = error; | ||
_this.config.loggerProvider.error('Error while recording: ', typedError.toString()); | ||
return true; | ||
}, | ||
}); | ||
}; | ||
SessionReplay.prototype.sendEventsList = function (_a) { | ||
var events = _a.events, sequenceId = _a.sequenceId, sessionId = _a.sessionId; | ||
this.addToQueue({ | ||
events: events, | ||
sequenceId: sequenceId, | ||
attempts: 0, | ||
timeout: 0, | ||
sessionId: sessionId, | ||
}); | ||
}; | ||
SessionReplay.prototype.addToQueue = function () { | ||
var _this = this; | ||
var list = []; | ||
for (var _i = 0; _i < arguments.length; _i++) { | ||
list[_i] = arguments[_i]; | ||
} | ||
var tryable = list.filter(function (context) { | ||
if (context.attempts < _this.config.flushMaxRetries) { | ||
context.attempts += 1; | ||
return true; | ||
} | ||
_this.completeRequest({ | ||
context: context, | ||
err: "".concat(messages_1.MAX_RETRIES_EXCEEDED_MESSAGE, ", batch sequence id, ").concat(context.sequenceId), | ||
}); | ||
return false; | ||
}); | ||
tryable.forEach(function (context) { | ||
_this.queue = _this.queue.concat(context); | ||
if (context.timeout === 0) { | ||
_this.schedule(0); | ||
return; | ||
} | ||
setTimeout(function () { | ||
context.timeout = 0; | ||
_this.schedule(0); | ||
}, context.timeout); | ||
}); | ||
}; | ||
SessionReplay.prototype.schedule = function (timeout) { | ||
var _this = this; | ||
if (this.scheduled) | ||
return; | ||
this.scheduled = setTimeout(function () { | ||
void _this.flush(true).then(function () { | ||
if (_this.queue.length > 0) { | ||
_this.schedule(timeout); | ||
} | ||
}); | ||
}, timeout); | ||
}; | ||
SessionReplay.prototype.flush = function (useRetry) { | ||
if (useRetry === void 0) { useRetry = false; } | ||
SessionReplayPlugin.prototype.execute = function (event) { | ||
return tslib_1.__awaiter(this, void 0, void 0, function () { | ||
var list, later; | ||
var _this = this; | ||
var sessionRecordingProperties; | ||
return tslib_1.__generator(this, function (_a) { | ||
switch (_a.label) { | ||
case 0: | ||
list = []; | ||
later = []; | ||
this.queue.forEach(function (context) { return (context.timeout === 0 ? list.push(context) : later.push(context)); }); | ||
this.queue = later; | ||
if (this.scheduled) { | ||
clearTimeout(this.scheduled); | ||
this.scheduled = null; | ||
} | ||
return [4 /*yield*/, Promise.all(list.map(function (context) { return _this.send(context, useRetry); }))]; | ||
case 1: | ||
_a.sent(); | ||
return [2 /*return*/]; | ||
if (event.event_type === constants_1.DEFAULT_SESSION_START_EVENT && event.session_id) { | ||
sessionReplay.setSessionId(event.session_id); | ||
} | ||
sessionRecordingProperties = sessionReplay.getSessionRecordingProperties(); | ||
event.event_properties = tslib_1.__assign(tslib_1.__assign({}, event.event_properties), sessionRecordingProperties); | ||
return [2 /*return*/, Promise.resolve(event)]; | ||
}); | ||
}); | ||
}; | ||
SessionReplay.prototype.getServerUrl = function () { | ||
if (this.config.serverZone === analytics_types_1.ServerZone.EU) { | ||
return constants_1.SESSION_REPLAY_EU_URL; | ||
} | ||
return constants_1.SESSION_REPLAY_SERVER_URL; | ||
}; | ||
SessionReplay.prototype.send = function (context, useRetry) { | ||
if (useRetry === void 0) { useRetry = true; } | ||
SessionReplayPlugin.prototype.teardown = function () { | ||
return tslib_1.__awaiter(this, void 0, void 0, function () { | ||
var payload, options, server_url, res, responseBody, e_1; | ||
return tslib_1.__generator(this, function (_a) { | ||
switch (_a.label) { | ||
case 0: | ||
payload = { | ||
api_key: this.config.apiKey, | ||
device_id: this.config.deviceId, | ||
session_id: context.sessionId, | ||
start_timestamp: context.sessionId, | ||
events_batch: { | ||
version: 1, | ||
events: context.events, | ||
seq_number: context.sequenceId, | ||
}, | ||
}; | ||
_a.label = 1; | ||
case 1: | ||
_a.trys.push([1, 3, , 4]); | ||
options = { | ||
headers: { | ||
'Content-Type': 'application/json', | ||
Accept: '*/*', | ||
}, | ||
body: JSON.stringify(payload), | ||
method: 'POST', | ||
}; | ||
server_url = this.getServerUrl(); | ||
return [4 /*yield*/, fetch(server_url, options)]; | ||
case 2: | ||
res = _a.sent(); | ||
if (res === null) { | ||
this.completeRequest({ context: context, err: messages_1.UNEXPECTED_ERROR_MESSAGE }); | ||
return [2 /*return*/]; | ||
} | ||
if (!useRetry) { | ||
responseBody = ''; | ||
try { | ||
responseBody = JSON.stringify(res.body, null, 2); | ||
} | ||
catch (_b) { | ||
// to avoid crash, but don't care about the error, add comment to avoid empty block lint error | ||
} | ||
this.completeRequest({ context: context, success: "".concat(res.status, ": ").concat(responseBody) }); | ||
} | ||
else { | ||
this.handleReponse(res.status, context); | ||
} | ||
return [3 /*break*/, 4]; | ||
case 3: | ||
e_1 = _a.sent(); | ||
this.completeRequest({ context: context, err: e_1 }); | ||
return [3 /*break*/, 4]; | ||
case 4: return [2 /*return*/]; | ||
} | ||
}); | ||
}); | ||
}; | ||
SessionReplay.prototype.handleReponse = function (status, context) { | ||
var parsedStatus = new analytics_core_1.BaseTransport().buildStatus(status); | ||
switch (parsedStatus) { | ||
case analytics_types_1.Status.Success: | ||
this.handleSuccessResponse(context); | ||
break; | ||
default: | ||
this.handleOtherResponse(context); | ||
} | ||
}; | ||
SessionReplay.prototype.handleSuccessResponse = function (context) { | ||
this.completeRequest({ context: context, success: (0, messages_1.getSuccessMessage)(context.sessionId) }); | ||
}; | ||
SessionReplay.prototype.handleOtherResponse = function (context) { | ||
this.addToQueue(tslib_1.__assign(tslib_1.__assign({}, context), { timeout: context.attempts * this.retryTimeout })); | ||
}; | ||
SessionReplay.prototype.getAllSessionEventsFromStore = function () { | ||
return tslib_1.__awaiter(this, void 0, void 0, function () { | ||
var storedReplaySessionContexts, e_2; | ||
return tslib_1.__generator(this, function (_a) { | ||
switch (_a.label) { | ||
case 0: | ||
_a.trys.push([0, 2, , 3]); | ||
return [4 /*yield*/, IDBKeyVal.get(this.storageKey)]; | ||
case 1: | ||
storedReplaySessionContexts = _a.sent(); | ||
return [2 /*return*/, storedReplaySessionContexts]; | ||
case 2: | ||
e_2 = _a.sent(); | ||
this.config.loggerProvider.error("".concat(messages_1.STORAGE_FAILURE, ": ").concat(e_2)); | ||
return [3 /*break*/, 3]; | ||
case 3: return [2 /*return*/, undefined]; | ||
} | ||
}); | ||
}); | ||
}; | ||
SessionReplay.prototype.storeEventsForSession = function (events, sequenceId, sessionId) { | ||
return tslib_1.__awaiter(this, void 0, void 0, function () { | ||
var e_3; | ||
return tslib_1.__generator(this, function (_a) { | ||
switch (_a.label) { | ||
case 0: | ||
_a.trys.push([0, 2, , 3]); | ||
return [4 /*yield*/, IDBKeyVal.update(this.storageKey, function (sessionMap) { | ||
var _a, _b; | ||
if (sessionMap === void 0) { sessionMap = {}; } | ||
var session = sessionMap[sessionId] || tslib_1.__assign({}, constants_1.defaultSessionStore); | ||
session.currentSequenceId = sequenceId; | ||
var currentSequence = (session.sessionSequences && session.sessionSequences[sequenceId]) || {}; | ||
currentSequence.events = events; | ||
currentSequence.status = session_replay_1.RecordingStatus.RECORDING; | ||
return tslib_1.__assign(tslib_1.__assign({}, sessionMap), (_a = {}, _a[sessionId] = tslib_1.__assign(tslib_1.__assign({}, session), { sessionSequences: tslib_1.__assign(tslib_1.__assign({}, session.sessionSequences), (_b = {}, _b[sequenceId] = currentSequence, _b)) }), _a)); | ||
})]; | ||
case 1: | ||
_a.sent(); | ||
return [3 /*break*/, 3]; | ||
case 2: | ||
e_3 = _a.sent(); | ||
this.config.loggerProvider.error("".concat(messages_1.STORAGE_FAILURE, ": ").concat(e_3)); | ||
return [3 /*break*/, 3]; | ||
case 3: return [2 /*return*/]; | ||
} | ||
}); | ||
}); | ||
}; | ||
SessionReplay.prototype.cleanUpSessionEventsStore = function (sessionId, sequenceId) { | ||
return tslib_1.__awaiter(this, void 0, void 0, function () { | ||
var e_4; | ||
return tslib_1.__generator(this, function (_a) { | ||
switch (_a.label) { | ||
case 0: | ||
_a.trys.push([0, 2, , 3]); | ||
return [4 /*yield*/, IDBKeyVal.update(this.storageKey, function (sessionMap) { | ||
if (sessionMap === void 0) { sessionMap = {}; } | ||
var session = sessionMap[sessionId]; | ||
var sequenceToUpdate = (session === null || session === void 0 ? void 0 : session.sessionSequences) && session.sessionSequences[sequenceId]; | ||
if (!sequenceToUpdate) { | ||
return sessionMap; | ||
} | ||
sequenceToUpdate.events = []; | ||
sequenceToUpdate.status = session_replay_1.RecordingStatus.SENT; | ||
// Delete sent sequences for current session | ||
Object.entries(session.sessionSequences).forEach(function (_a) { | ||
var _b = tslib_1.__read(_a, 2), storedSeqId = _b[0], sequence = _b[1]; | ||
var numericStoredSeqId = parseInt(storedSeqId, 10); | ||
if (sequence.status === session_replay_1.RecordingStatus.SENT && sequenceId !== numericStoredSeqId) { | ||
delete session.sessionSequences[numericStoredSeqId]; | ||
} | ||
}); | ||
// Delete any sessions that are older than 3 days | ||
Object.keys(sessionMap).forEach(function (sessionId) { | ||
var numericSessionId = parseInt(sessionId, 10); | ||
if (Date.now() - numericSessionId >= constants_1.MAX_IDB_STORAGE_LENGTH) { | ||
delete sessionMap[numericSessionId]; | ||
} | ||
}); | ||
return sessionMap; | ||
})]; | ||
case 1: | ||
_a.sent(); | ||
return [3 /*break*/, 3]; | ||
case 2: | ||
e_4 = _a.sent(); | ||
this.config.loggerProvider.error("".concat(messages_1.STORAGE_FAILURE, ": ").concat(e_4)); | ||
return [3 /*break*/, 3]; | ||
case 3: return [2 /*return*/]; | ||
} | ||
}); | ||
}); | ||
}; | ||
SessionReplay.prototype.completeRequest = function (_a) { | ||
var context = _a.context, err = _a.err, success = _a.success; | ||
context.sessionId && this.cleanUpSessionEventsStore(context.sessionId, context.sequenceId); | ||
if (err) { | ||
this.config.loggerProvider.error(err); | ||
} | ||
else if (success) { | ||
this.config.loggerProvider.log(success); | ||
} | ||
}; | ||
SessionReplay.prototype.teardown = function () { | ||
return tslib_1.__awaiter(this, void 0, void 0, function () { | ||
var globalScope; | ||
return tslib_1.__generator(this, function (_a) { | ||
globalScope = (0, analytics_client_common_1.getGlobalScope)(); | ||
if (globalScope) { | ||
globalScope.removeEventListener('blur', this.blurListener); | ||
globalScope.removeEventListener('focus', this.focusListener); | ||
} | ||
this.stopRecordingAndSendEvents(); | ||
sessionReplay.shutdown(); | ||
return [2 /*return*/]; | ||
@@ -530,8 +74,9 @@ }); | ||
}; | ||
return SessionReplay; | ||
return SessionReplayPlugin; | ||
}()); | ||
exports.SessionReplayPlugin = SessionReplayPlugin; | ||
var sessionReplayPlugin = function (options) { | ||
return new SessionReplay(options); | ||
return new SessionReplayPlugin(options); | ||
}; | ||
exports.sessionReplayPlugin = sessionReplayPlugin; | ||
//# sourceMappingURL=session-replay.js.map |
@@ -1,71 +0,4 @@ | ||
import { BrowserConfig, EnrichmentPlugin } from '@amplitude/analytics-types'; | ||
import { record } from 'rrweb'; | ||
export interface SessionReplayOptions { | ||
sampleRate?: number; | ||
} | ||
export type Events = string[]; | ||
export interface SessionReplayContext { | ||
events: Events; | ||
sequenceId: number; | ||
attempts: number; | ||
timeout: number; | ||
sessionId: number; | ||
} | ||
export declare enum RecordingStatus { | ||
RECORDING = "recording", | ||
SENT = "sent" | ||
} | ||
export interface IDBStoreSequence { | ||
events: Events; | ||
status: RecordingStatus; | ||
} | ||
export interface IDBStoreSession { | ||
currentSequenceId: number; | ||
sessionSequences: { | ||
[sequenceId: number]: IDBStoreSequence; | ||
}; | ||
} | ||
export interface IDBStore { | ||
[sessionId: number]: IDBStoreSession; | ||
} | ||
export interface SessionReplayEnrichmentPlugin extends EnrichmentPlugin { | ||
setup: (config: BrowserConfig) => Promise<void>; | ||
config: BrowserConfig; | ||
storageKey: string; | ||
retryTimeout: number; | ||
events: Events; | ||
currentSequenceId: number; | ||
interval: number; | ||
queue: SessionReplayContext[]; | ||
timeAtLastSend: number | null; | ||
stopRecordingEvents: ReturnType<typeof record> | null; | ||
stopRecordingAndSendEvents: (sessionId?: number) => void; | ||
maxPersistedEventsSize: number; | ||
initialize: (shouldSendStoredEvents?: boolean) => Promise<void>; | ||
sendStoredEvents: (storedReplaySessions: IDBStore) => void; | ||
getShouldRecord: () => boolean; | ||
recordEvents: () => void; | ||
shouldSplitEventsList: (nextEventString: string) => boolean; | ||
sendEventsList: ({ events, sequenceId, sessionId, }: { | ||
events: string[]; | ||
sequenceId: number; | ||
sessionId: number; | ||
}) => void; | ||
addToQueue: (...list: SessionReplayContext[]) => void; | ||
schedule: (timeout: number) => void; | ||
flush: (useRetry?: boolean) => Promise<void>; | ||
send: (context: SessionReplayContext, useRetry?: boolean) => Promise<void>; | ||
completeRequest({ context, err, success, removeEvents, }: { | ||
context: SessionReplayContext; | ||
err?: string | undefined; | ||
success?: string | undefined; | ||
removeEvents?: boolean | undefined; | ||
}): void; | ||
getAllSessionEventsFromStore: () => Promise<IDBStore | undefined>; | ||
storeEventsForSession: (events: Events, sequenceId: number, sessionId: number) => Promise<void>; | ||
cleanUpSessionEventsStore: (sessionId: number, sequenceId: number) => Promise<void>; | ||
} | ||
export interface SessionReplayPlugin { | ||
(options?: SessionReplayOptions): SessionReplayEnrichmentPlugin; | ||
} | ||
//# sourceMappingURL=session-replay.d.ts.map |
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.RecordingStatus = void 0; | ||
var RecordingStatus; | ||
(function (RecordingStatus) { | ||
RecordingStatus["RECORDING"] = "recording"; | ||
RecordingStatus["SENT"] = "sent"; | ||
})(RecordingStatus = exports.RecordingStatus || (exports.RecordingStatus = {})); | ||
//# sourceMappingURL=session-replay.js.map |
@@ -1,17 +0,2 @@ | ||
import { IDBStoreSession } from './typings/session-replay'; | ||
export declare const DEFAULT_EVENT_PROPERTY_PREFIX = "[Amplitude]"; | ||
export declare const DEFAULT_SESSION_REPLAY_PROPERTY: string; | ||
export declare const DEFAULT_SESSION_START_EVENT = "session_start"; | ||
export declare const DEFAULT_SESSION_END_EVENT = "session_end"; | ||
export declare const BLOCK_CLASS = "amp-block"; | ||
export declare const MASK_TEXT_CLASS = "amp-mask"; | ||
export declare const UNMASK_TEXT_CLASS = "amp-unmask"; | ||
export declare const SESSION_REPLAY_SERVER_URL = "https://api-secure.amplitude.com/sessions/track"; | ||
export declare const SESSION_REPLAY_EU_URL = "https://api.eu.amplitude.com/sessions/track"; | ||
export declare const STORAGE_PREFIX: string; | ||
export declare const MAX_EVENT_LIST_SIZE_IN_BYTES: number; | ||
export declare const MIN_INTERVAL = 500; | ||
export declare const MAX_INTERVAL: number; | ||
export declare const defaultSessionStore: IDBStoreSession; | ||
export declare const MAX_IDB_STORAGE_LENGTH: number; | ||
//# sourceMappingURL=constants.d.ts.map |
@@ -1,21 +0,2 @@ | ||
import { AMPLITUDE_PREFIX } from '@amplitude/analytics-core'; | ||
export var DEFAULT_EVENT_PROPERTY_PREFIX = '[Amplitude]'; | ||
export var DEFAULT_SESSION_REPLAY_PROPERTY = "".concat(DEFAULT_EVENT_PROPERTY_PREFIX, " Session Recorded"); | ||
export var DEFAULT_SESSION_START_EVENT = 'session_start'; | ||
export var DEFAULT_SESSION_END_EVENT = 'session_end'; | ||
export var BLOCK_CLASS = 'amp-block'; | ||
export var MASK_TEXT_CLASS = 'amp-mask'; | ||
export var UNMASK_TEXT_CLASS = 'amp-unmask'; | ||
export var SESSION_REPLAY_SERVER_URL = 'https://api-secure.amplitude.com/sessions/track'; | ||
export var SESSION_REPLAY_EU_URL = 'https://api.eu.amplitude.com/sessions/track'; | ||
export var STORAGE_PREFIX = "".concat(AMPLITUDE_PREFIX, "_replay_unsent"); | ||
var PAYLOAD_ESTIMATED_SIZE_IN_BYTES_WITHOUT_EVENTS = 500; // derived by JSON stringifying an example payload without events | ||
export var MAX_EVENT_LIST_SIZE_IN_BYTES = 10 * 1000000 - PAYLOAD_ESTIMATED_SIZE_IN_BYTES_WITHOUT_EVENTS; | ||
export var MIN_INTERVAL = 500; // 500 ms | ||
export var MAX_INTERVAL = 10 * 1000; // 10 seconds | ||
export var defaultSessionStore = { | ||
currentSequenceId: 0, | ||
sessionSequences: {}, | ||
}; | ||
export var MAX_IDB_STORAGE_LENGTH = 1000 * 60 * 60 * 24 * 3; // 3 days | ||
//# sourceMappingURL=constants.js.map |
@@ -1,3 +0,14 @@ | ||
import { SessionReplayPlugin } from './typings/session-replay'; | ||
export declare const sessionReplayPlugin: SessionReplayPlugin; | ||
import { BrowserConfig, EnrichmentPlugin, Event } from '@amplitude/analytics-types'; | ||
import { SessionReplayOptions } from './typings/session-replay'; | ||
export declare class SessionReplayPlugin implements EnrichmentPlugin { | ||
name: string; | ||
type: "enrichment"; | ||
config: BrowserConfig; | ||
options: SessionReplayOptions; | ||
constructor(options?: SessionReplayOptions); | ||
setup(config: BrowserConfig): Promise<void>; | ||
execute(event: Event): Promise<Event>; | ||
teardown(): Promise<void>; | ||
} | ||
export declare const sessionReplayPlugin: (options?: SessionReplayOptions) => EnrichmentPlugin; | ||
//# sourceMappingURL=session-replay.d.ts.map |
@@ -1,56 +0,12 @@ | ||
import { __assign, __awaiter, __generator, __read } from "tslib"; | ||
import { getGlobalScope } from '@amplitude/analytics-client-common'; | ||
import { BaseTransport } from '@amplitude/analytics-core'; | ||
import { ServerZone, Status } from '@amplitude/analytics-types'; | ||
import * as IDBKeyVal from 'idb-keyval'; | ||
import { pack, record } from 'rrweb'; | ||
import { BLOCK_CLASS, DEFAULT_SESSION_END_EVENT, DEFAULT_SESSION_REPLAY_PROPERTY, DEFAULT_SESSION_START_EVENT, MASK_TEXT_CLASS, MAX_EVENT_LIST_SIZE_IN_BYTES, MAX_IDB_STORAGE_LENGTH, MAX_INTERVAL, MIN_INTERVAL, SESSION_REPLAY_EU_URL as SESSION_REPLAY_EU_SERVER_URL, SESSION_REPLAY_SERVER_URL, STORAGE_PREFIX, defaultSessionStore, } from './constants'; | ||
import { isSessionInSample, maskInputFn } from './helpers'; | ||
import { MAX_RETRIES_EXCEEDED_MESSAGE, STORAGE_FAILURE, UNEXPECTED_ERROR_MESSAGE, getSuccessMessage } from './messages'; | ||
import { RecordingStatus, } from './typings/session-replay'; | ||
var SessionReplay = /** @class */ (function () { | ||
function SessionReplay(options) { | ||
var _this = this; | ||
import { __assign, __awaiter, __generator } from "tslib"; | ||
import * as sessionReplay from '@amplitude/session-replay-browser'; | ||
import { DEFAULT_SESSION_START_EVENT } from './constants'; | ||
var SessionReplayPlugin = /** @class */ (function () { | ||
function SessionReplayPlugin(options) { | ||
this.name = '@amplitude/plugin-session-replay-browser'; | ||
this.type = 'enrichment'; | ||
this.storageKey = ''; | ||
this.retryTimeout = 1000; | ||
this.events = []; | ||
this.currentSequenceId = 0; | ||
this.scheduled = null; | ||
this.queue = []; | ||
this.stopRecordingEvents = null; | ||
this.maxPersistedEventsSize = MAX_EVENT_LIST_SIZE_IN_BYTES; | ||
this.interval = MIN_INTERVAL; | ||
this.timeAtLastSend = null; | ||
this.blurListener = function () { | ||
_this.stopRecordingAndSendEvents(); | ||
}; | ||
this.focusListener = function () { | ||
void _this.initialize(); | ||
}; | ||
/** | ||
* Determines whether to send the events list to the backend and start a new | ||
* empty events list, based on the size of the list as well as the last time sent | ||
* @param nextEventString | ||
* @returns boolean | ||
*/ | ||
this.shouldSplitEventsList = function (nextEventString) { | ||
var sizeOfNextEvent = new Blob([nextEventString]).size; | ||
var sizeOfEventsList = new Blob(_this.events).size; | ||
if (sizeOfEventsList + sizeOfNextEvent >= _this.maxPersistedEventsSize) { | ||
return true; | ||
} | ||
if (_this.timeAtLastSend !== null && Date.now() - _this.timeAtLastSend > _this.interval && _this.events.length) { | ||
_this.interval = Math.min(MAX_INTERVAL, _this.interval + MIN_INTERVAL); | ||
_this.timeAtLastSend = Date.now(); | ||
return true; | ||
} | ||
return false; | ||
}; | ||
this.options = __assign({}, options); | ||
} | ||
SessionReplay.prototype.setup = function (config) { | ||
SessionReplayPlugin.prototype.setup = function (config) { | ||
return __awaiter(this, void 0, void 0, function () { | ||
var globalScope; | ||
return __generator(this, function (_a) { | ||
@@ -61,4 +17,2 @@ switch (_a.label) { | ||
this.config = config; | ||
this.config.sessionId = config.sessionId; | ||
this.storageKey = "".concat(STORAGE_PREFIX, "_").concat(this.config.apiKey.substring(0, 10)); | ||
if (typeof config.defaultTracking === 'boolean') { | ||
@@ -77,98 +31,15 @@ if (config.defaultTracking === false) { | ||
} | ||
globalScope = getGlobalScope(); | ||
if (globalScope) { | ||
globalScope.addEventListener('blur', this.blurListener); | ||
globalScope.addEventListener('focus', this.focusListener); | ||
} | ||
if (!(globalScope && globalScope.document && globalScope.document.hasFocus())) return [3 /*break*/, 2]; | ||
return [4 /*yield*/, this.initialize(true)]; | ||
return [4 /*yield*/, sessionReplay.init(config.apiKey, { | ||
instanceName: this.config.instanceName, | ||
deviceId: this.config.deviceId, | ||
optOut: this.config.optOut, | ||
sessionId: this.config.sessionId, | ||
loggerProvider: this.config.loggerProvider, | ||
logLevel: this.config.logLevel, | ||
flushMaxRetries: this.config.flushMaxRetries, | ||
serverZone: this.config.serverZone, | ||
sampleRate: this.options.sampleRate, | ||
}).promise]; | ||
case 1: | ||
_a.sent(); | ||
_a.label = 2; | ||
case 2: return [2 /*return*/]; | ||
} | ||
}); | ||
}); | ||
}; | ||
SessionReplay.prototype.stopRecordingAndSendEvents = function (sessionId) { | ||
try { | ||
this.stopRecordingEvents && this.stopRecordingEvents(); | ||
this.stopRecordingEvents = null; | ||
} | ||
catch (error) { | ||
var typedError = error; | ||
this.config.loggerProvider.error("Error occurred while stopping recording: ".concat(typedError.toString())); | ||
} | ||
var sessionIdToSend = sessionId || this.config.sessionId; | ||
if (this.events.length && sessionIdToSend) { | ||
this.sendEventsList({ | ||
events: this.events, | ||
sequenceId: this.currentSequenceId, | ||
sessionId: sessionIdToSend, | ||
}); | ||
} | ||
}; | ||
SessionReplay.prototype.execute = function (event) { | ||
return __awaiter(this, void 0, void 0, function () { | ||
var globalScope, shouldRecord; | ||
var _a; | ||
return __generator(this, function (_b) { | ||
globalScope = getGlobalScope(); | ||
if (globalScope && globalScope.document && !globalScope.document.hasFocus()) { | ||
return [2 /*return*/, Promise.resolve(event)]; | ||
} | ||
if (event.event_type === DEFAULT_SESSION_START_EVENT && !this.stopRecordingEvents) { | ||
this.recordEvents(); | ||
} | ||
else if (event.event_type === DEFAULT_SESSION_END_EVENT) { | ||
this.stopRecordingAndSendEvents(event.session_id); | ||
this.events = []; | ||
this.currentSequenceId = 0; | ||
} | ||
shouldRecord = this.getShouldRecord(); | ||
if (shouldRecord) { | ||
event.event_properties = __assign(__assign({}, event.event_properties), (_a = {}, _a[DEFAULT_SESSION_REPLAY_PROPERTY] = true, _a)); | ||
} | ||
return [2 /*return*/, Promise.resolve(event)]; | ||
}); | ||
}); | ||
}; | ||
SessionReplay.prototype.initialize = function (shouldSendStoredEvents) { | ||
if (shouldSendStoredEvents === void 0) { shouldSendStoredEvents = false; } | ||
return __awaiter(this, void 0, void 0, function () { | ||
var storedReplaySessions, storedSequencesForSession, storedSeqId, lastSequence; | ||
return __generator(this, function (_a) { | ||
switch (_a.label) { | ||
case 0: | ||
this.timeAtLastSend = Date.now(); // Initialize this so we have a point of comparison when events are recorded | ||
if (!this.config.sessionId) { | ||
return [2 /*return*/]; | ||
} | ||
return [4 /*yield*/, this.getAllSessionEventsFromStore()]; | ||
case 1: | ||
storedReplaySessions = _a.sent(); | ||
// This resolves a timing issue when focus is fired multiple times in short succession, | ||
// we only want the rest of this function to run once. We can be sure that initialize has | ||
// already been called if this.stopRecordingEvents is defined | ||
if (this.stopRecordingEvents) { | ||
return [2 /*return*/]; | ||
} | ||
storedSequencesForSession = storedReplaySessions && storedReplaySessions[this.config.sessionId]; | ||
if (storedReplaySessions && storedSequencesForSession && storedSequencesForSession.sessionSequences) { | ||
storedSeqId = storedSequencesForSession.currentSequenceId; | ||
lastSequence = storedSequencesForSession.sessionSequences[storedSeqId]; | ||
if (lastSequence && lastSequence.status !== RecordingStatus.RECORDING) { | ||
this.currentSequenceId = storedSeqId + 1; | ||
this.events = []; | ||
} | ||
else { | ||
// Pick up recording where it was left off in another tab or window | ||
this.currentSequenceId = storedSeqId; | ||
this.events = (lastSequence === null || lastSequence === void 0 ? void 0 : lastSequence.events) || []; | ||
} | ||
} | ||
if (shouldSendStoredEvents && storedReplaySessions) { | ||
this.sendStoredEvents(storedReplaySessions); | ||
} | ||
this.recordEvents(); | ||
return [2 /*return*/]; | ||
@@ -179,346 +50,19 @@ } | ||
}; | ||
SessionReplay.prototype.getShouldRecord = function () { | ||
if (this.config.optOut) { | ||
return false; | ||
} | ||
else if (!this.config.sessionId) { | ||
return false; | ||
} | ||
else if (this.options && this.options.sampleRate) { | ||
return isSessionInSample(this.config.sessionId, this.options.sampleRate); | ||
} | ||
return true; | ||
}; | ||
SessionReplay.prototype.sendStoredEvents = function (storedReplaySessions) { | ||
for (var sessionId in storedReplaySessions) { | ||
var storedSequences = storedReplaySessions[sessionId].sessionSequences; | ||
for (var storedSeqId in storedSequences) { | ||
var seq = storedSequences[storedSeqId]; | ||
var numericSeqId = parseInt(storedSeqId, 10); | ||
var numericSessionId = parseInt(sessionId, 10); | ||
if (numericSessionId === this.config.sessionId && numericSeqId === this.currentSequenceId) { | ||
continue; | ||
} | ||
if (seq.events.length && seq.status === RecordingStatus.RECORDING) { | ||
this.sendEventsList({ | ||
events: seq.events, | ||
sequenceId: numericSeqId, | ||
sessionId: numericSessionId, | ||
}); | ||
} | ||
} | ||
} | ||
}; | ||
SessionReplay.prototype.recordEvents = function () { | ||
var _this = this; | ||
var shouldRecord = this.getShouldRecord(); | ||
if (!shouldRecord && this.config.sessionId) { | ||
this.config.loggerProvider.log("Opting session ".concat(this.config.sessionId, " out of recording.")); | ||
return; | ||
} | ||
this.stopRecordingEvents = record({ | ||
emit: function (event) { | ||
var globalScope = getGlobalScope(); | ||
if (globalScope && globalScope.document && !globalScope.document.hasFocus()) { | ||
_this.stopRecordingAndSendEvents(); | ||
return; | ||
} | ||
var eventString = JSON.stringify(event); | ||
var shouldSplit = _this.shouldSplitEventsList(eventString); | ||
if (shouldSplit) { | ||
_this.sendEventsList({ | ||
events: _this.events, | ||
sequenceId: _this.currentSequenceId, | ||
sessionId: _this.config.sessionId, | ||
}); | ||
_this.events = []; | ||
_this.currentSequenceId++; | ||
} | ||
_this.events.push(eventString); | ||
void _this.storeEventsForSession(_this.events, _this.currentSequenceId, _this.config.sessionId); | ||
}, | ||
packFn: pack, | ||
maskAllInputs: true, | ||
maskTextClass: MASK_TEXT_CLASS, | ||
blockClass: BLOCK_CLASS, | ||
maskInputFn: maskInputFn, | ||
recordCanvas: false, | ||
errorHandler: function (error) { | ||
var typedError = error; | ||
_this.config.loggerProvider.error('Error while recording: ', typedError.toString()); | ||
return true; | ||
}, | ||
}); | ||
}; | ||
SessionReplay.prototype.sendEventsList = function (_a) { | ||
var events = _a.events, sequenceId = _a.sequenceId, sessionId = _a.sessionId; | ||
this.addToQueue({ | ||
events: events, | ||
sequenceId: sequenceId, | ||
attempts: 0, | ||
timeout: 0, | ||
sessionId: sessionId, | ||
}); | ||
}; | ||
SessionReplay.prototype.addToQueue = function () { | ||
var _this = this; | ||
var list = []; | ||
for (var _i = 0; _i < arguments.length; _i++) { | ||
list[_i] = arguments[_i]; | ||
} | ||
var tryable = list.filter(function (context) { | ||
if (context.attempts < _this.config.flushMaxRetries) { | ||
context.attempts += 1; | ||
return true; | ||
} | ||
_this.completeRequest({ | ||
context: context, | ||
err: "".concat(MAX_RETRIES_EXCEEDED_MESSAGE, ", batch sequence id, ").concat(context.sequenceId), | ||
}); | ||
return false; | ||
}); | ||
tryable.forEach(function (context) { | ||
_this.queue = _this.queue.concat(context); | ||
if (context.timeout === 0) { | ||
_this.schedule(0); | ||
return; | ||
} | ||
setTimeout(function () { | ||
context.timeout = 0; | ||
_this.schedule(0); | ||
}, context.timeout); | ||
}); | ||
}; | ||
SessionReplay.prototype.schedule = function (timeout) { | ||
var _this = this; | ||
if (this.scheduled) | ||
return; | ||
this.scheduled = setTimeout(function () { | ||
void _this.flush(true).then(function () { | ||
if (_this.queue.length > 0) { | ||
_this.schedule(timeout); | ||
} | ||
}); | ||
}, timeout); | ||
}; | ||
SessionReplay.prototype.flush = function (useRetry) { | ||
if (useRetry === void 0) { useRetry = false; } | ||
SessionReplayPlugin.prototype.execute = function (event) { | ||
return __awaiter(this, void 0, void 0, function () { | ||
var list, later; | ||
var _this = this; | ||
var sessionRecordingProperties; | ||
return __generator(this, function (_a) { | ||
switch (_a.label) { | ||
case 0: | ||
list = []; | ||
later = []; | ||
this.queue.forEach(function (context) { return (context.timeout === 0 ? list.push(context) : later.push(context)); }); | ||
this.queue = later; | ||
if (this.scheduled) { | ||
clearTimeout(this.scheduled); | ||
this.scheduled = null; | ||
} | ||
return [4 /*yield*/, Promise.all(list.map(function (context) { return _this.send(context, useRetry); }))]; | ||
case 1: | ||
_a.sent(); | ||
return [2 /*return*/]; | ||
if (event.event_type === DEFAULT_SESSION_START_EVENT && event.session_id) { | ||
sessionReplay.setSessionId(event.session_id); | ||
} | ||
sessionRecordingProperties = sessionReplay.getSessionRecordingProperties(); | ||
event.event_properties = __assign(__assign({}, event.event_properties), sessionRecordingProperties); | ||
return [2 /*return*/, Promise.resolve(event)]; | ||
}); | ||
}); | ||
}; | ||
SessionReplay.prototype.getServerUrl = function () { | ||
if (this.config.serverZone === ServerZone.EU) { | ||
return SESSION_REPLAY_EU_SERVER_URL; | ||
} | ||
return SESSION_REPLAY_SERVER_URL; | ||
}; | ||
SessionReplay.prototype.send = function (context, useRetry) { | ||
if (useRetry === void 0) { useRetry = true; } | ||
SessionReplayPlugin.prototype.teardown = function () { | ||
return __awaiter(this, void 0, void 0, function () { | ||
var payload, options, server_url, res, responseBody, e_1; | ||
return __generator(this, function (_a) { | ||
switch (_a.label) { | ||
case 0: | ||
payload = { | ||
api_key: this.config.apiKey, | ||
device_id: this.config.deviceId, | ||
session_id: context.sessionId, | ||
start_timestamp: context.sessionId, | ||
events_batch: { | ||
version: 1, | ||
events: context.events, | ||
seq_number: context.sequenceId, | ||
}, | ||
}; | ||
_a.label = 1; | ||
case 1: | ||
_a.trys.push([1, 3, , 4]); | ||
options = { | ||
headers: { | ||
'Content-Type': 'application/json', | ||
Accept: '*/*', | ||
}, | ||
body: JSON.stringify(payload), | ||
method: 'POST', | ||
}; | ||
server_url = this.getServerUrl(); | ||
return [4 /*yield*/, fetch(server_url, options)]; | ||
case 2: | ||
res = _a.sent(); | ||
if (res === null) { | ||
this.completeRequest({ context: context, err: UNEXPECTED_ERROR_MESSAGE }); | ||
return [2 /*return*/]; | ||
} | ||
if (!useRetry) { | ||
responseBody = ''; | ||
try { | ||
responseBody = JSON.stringify(res.body, null, 2); | ||
} | ||
catch (_b) { | ||
// to avoid crash, but don't care about the error, add comment to avoid empty block lint error | ||
} | ||
this.completeRequest({ context: context, success: "".concat(res.status, ": ").concat(responseBody) }); | ||
} | ||
else { | ||
this.handleReponse(res.status, context); | ||
} | ||
return [3 /*break*/, 4]; | ||
case 3: | ||
e_1 = _a.sent(); | ||
this.completeRequest({ context: context, err: e_1 }); | ||
return [3 /*break*/, 4]; | ||
case 4: return [2 /*return*/]; | ||
} | ||
}); | ||
}); | ||
}; | ||
SessionReplay.prototype.handleReponse = function (status, context) { | ||
var parsedStatus = new BaseTransport().buildStatus(status); | ||
switch (parsedStatus) { | ||
case Status.Success: | ||
this.handleSuccessResponse(context); | ||
break; | ||
default: | ||
this.handleOtherResponse(context); | ||
} | ||
}; | ||
SessionReplay.prototype.handleSuccessResponse = function (context) { | ||
this.completeRequest({ context: context, success: getSuccessMessage(context.sessionId) }); | ||
}; | ||
SessionReplay.prototype.handleOtherResponse = function (context) { | ||
this.addToQueue(__assign(__assign({}, context), { timeout: context.attempts * this.retryTimeout })); | ||
}; | ||
SessionReplay.prototype.getAllSessionEventsFromStore = function () { | ||
return __awaiter(this, void 0, void 0, function () { | ||
var storedReplaySessionContexts, e_2; | ||
return __generator(this, function (_a) { | ||
switch (_a.label) { | ||
case 0: | ||
_a.trys.push([0, 2, , 3]); | ||
return [4 /*yield*/, IDBKeyVal.get(this.storageKey)]; | ||
case 1: | ||
storedReplaySessionContexts = _a.sent(); | ||
return [2 /*return*/, storedReplaySessionContexts]; | ||
case 2: | ||
e_2 = _a.sent(); | ||
this.config.loggerProvider.error("".concat(STORAGE_FAILURE, ": ").concat(e_2)); | ||
return [3 /*break*/, 3]; | ||
case 3: return [2 /*return*/, undefined]; | ||
} | ||
}); | ||
}); | ||
}; | ||
SessionReplay.prototype.storeEventsForSession = function (events, sequenceId, sessionId) { | ||
return __awaiter(this, void 0, void 0, function () { | ||
var e_3; | ||
return __generator(this, function (_a) { | ||
switch (_a.label) { | ||
case 0: | ||
_a.trys.push([0, 2, , 3]); | ||
return [4 /*yield*/, IDBKeyVal.update(this.storageKey, function (sessionMap) { | ||
var _a, _b; | ||
if (sessionMap === void 0) { sessionMap = {}; } | ||
var session = sessionMap[sessionId] || __assign({}, defaultSessionStore); | ||
session.currentSequenceId = sequenceId; | ||
var currentSequence = (session.sessionSequences && session.sessionSequences[sequenceId]) || {}; | ||
currentSequence.events = events; | ||
currentSequence.status = RecordingStatus.RECORDING; | ||
return __assign(__assign({}, sessionMap), (_a = {}, _a[sessionId] = __assign(__assign({}, session), { sessionSequences: __assign(__assign({}, session.sessionSequences), (_b = {}, _b[sequenceId] = currentSequence, _b)) }), _a)); | ||
})]; | ||
case 1: | ||
_a.sent(); | ||
return [3 /*break*/, 3]; | ||
case 2: | ||
e_3 = _a.sent(); | ||
this.config.loggerProvider.error("".concat(STORAGE_FAILURE, ": ").concat(e_3)); | ||
return [3 /*break*/, 3]; | ||
case 3: return [2 /*return*/]; | ||
} | ||
}); | ||
}); | ||
}; | ||
SessionReplay.prototype.cleanUpSessionEventsStore = function (sessionId, sequenceId) { | ||
return __awaiter(this, void 0, void 0, function () { | ||
var e_4; | ||
return __generator(this, function (_a) { | ||
switch (_a.label) { | ||
case 0: | ||
_a.trys.push([0, 2, , 3]); | ||
return [4 /*yield*/, IDBKeyVal.update(this.storageKey, function (sessionMap) { | ||
if (sessionMap === void 0) { sessionMap = {}; } | ||
var session = sessionMap[sessionId]; | ||
var sequenceToUpdate = (session === null || session === void 0 ? void 0 : session.sessionSequences) && session.sessionSequences[sequenceId]; | ||
if (!sequenceToUpdate) { | ||
return sessionMap; | ||
} | ||
sequenceToUpdate.events = []; | ||
sequenceToUpdate.status = RecordingStatus.SENT; | ||
// Delete sent sequences for current session | ||
Object.entries(session.sessionSequences).forEach(function (_a) { | ||
var _b = __read(_a, 2), storedSeqId = _b[0], sequence = _b[1]; | ||
var numericStoredSeqId = parseInt(storedSeqId, 10); | ||
if (sequence.status === RecordingStatus.SENT && sequenceId !== numericStoredSeqId) { | ||
delete session.sessionSequences[numericStoredSeqId]; | ||
} | ||
}); | ||
// Delete any sessions that are older than 3 days | ||
Object.keys(sessionMap).forEach(function (sessionId) { | ||
var numericSessionId = parseInt(sessionId, 10); | ||
if (Date.now() - numericSessionId >= MAX_IDB_STORAGE_LENGTH) { | ||
delete sessionMap[numericSessionId]; | ||
} | ||
}); | ||
return sessionMap; | ||
})]; | ||
case 1: | ||
_a.sent(); | ||
return [3 /*break*/, 3]; | ||
case 2: | ||
e_4 = _a.sent(); | ||
this.config.loggerProvider.error("".concat(STORAGE_FAILURE, ": ").concat(e_4)); | ||
return [3 /*break*/, 3]; | ||
case 3: return [2 /*return*/]; | ||
} | ||
}); | ||
}); | ||
}; | ||
SessionReplay.prototype.completeRequest = function (_a) { | ||
var context = _a.context, err = _a.err, success = _a.success; | ||
context.sessionId && this.cleanUpSessionEventsStore(context.sessionId, context.sequenceId); | ||
if (err) { | ||
this.config.loggerProvider.error(err); | ||
} | ||
else if (success) { | ||
this.config.loggerProvider.log(success); | ||
} | ||
}; | ||
SessionReplay.prototype.teardown = function () { | ||
return __awaiter(this, void 0, void 0, function () { | ||
var globalScope; | ||
return __generator(this, function (_a) { | ||
globalScope = getGlobalScope(); | ||
if (globalScope) { | ||
globalScope.removeEventListener('blur', this.blurListener); | ||
globalScope.removeEventListener('focus', this.focusListener); | ||
} | ||
this.stopRecordingAndSendEvents(); | ||
sessionReplay.shutdown(); | ||
return [2 /*return*/]; | ||
@@ -528,7 +72,8 @@ }); | ||
}; | ||
return SessionReplay; | ||
return SessionReplayPlugin; | ||
}()); | ||
export { SessionReplayPlugin }; | ||
export var sessionReplayPlugin = function (options) { | ||
return new SessionReplay(options); | ||
return new SessionReplayPlugin(options); | ||
}; | ||
//# sourceMappingURL=session-replay.js.map |
@@ -1,71 +0,4 @@ | ||
import { BrowserConfig, EnrichmentPlugin } from '@amplitude/analytics-types'; | ||
import { record } from 'rrweb'; | ||
export interface SessionReplayOptions { | ||
sampleRate?: number; | ||
} | ||
export type Events = string[]; | ||
export interface SessionReplayContext { | ||
events: Events; | ||
sequenceId: number; | ||
attempts: number; | ||
timeout: number; | ||
sessionId: number; | ||
} | ||
export declare enum RecordingStatus { | ||
RECORDING = "recording", | ||
SENT = "sent" | ||
} | ||
export interface IDBStoreSequence { | ||
events: Events; | ||
status: RecordingStatus; | ||
} | ||
export interface IDBStoreSession { | ||
currentSequenceId: number; | ||
sessionSequences: { | ||
[sequenceId: number]: IDBStoreSequence; | ||
}; | ||
} | ||
export interface IDBStore { | ||
[sessionId: number]: IDBStoreSession; | ||
} | ||
export interface SessionReplayEnrichmentPlugin extends EnrichmentPlugin { | ||
setup: (config: BrowserConfig) => Promise<void>; | ||
config: BrowserConfig; | ||
storageKey: string; | ||
retryTimeout: number; | ||
events: Events; | ||
currentSequenceId: number; | ||
interval: number; | ||
queue: SessionReplayContext[]; | ||
timeAtLastSend: number | null; | ||
stopRecordingEvents: ReturnType<typeof record> | null; | ||
stopRecordingAndSendEvents: (sessionId?: number) => void; | ||
maxPersistedEventsSize: number; | ||
initialize: (shouldSendStoredEvents?: boolean) => Promise<void>; | ||
sendStoredEvents: (storedReplaySessions: IDBStore) => void; | ||
getShouldRecord: () => boolean; | ||
recordEvents: () => void; | ||
shouldSplitEventsList: (nextEventString: string) => boolean; | ||
sendEventsList: ({ events, sequenceId, sessionId, }: { | ||
events: string[]; | ||
sequenceId: number; | ||
sessionId: number; | ||
}) => void; | ||
addToQueue: (...list: SessionReplayContext[]) => void; | ||
schedule: (timeout: number) => void; | ||
flush: (useRetry?: boolean) => Promise<void>; | ||
send: (context: SessionReplayContext, useRetry?: boolean) => Promise<void>; | ||
completeRequest({ context, err, success, removeEvents, }: { | ||
context: SessionReplayContext; | ||
err?: string | undefined; | ||
success?: string | undefined; | ||
removeEvents?: boolean | undefined; | ||
}): void; | ||
getAllSessionEventsFromStore: () => Promise<IDBStore | undefined>; | ||
storeEventsForSession: (events: Events, sequenceId: number, sessionId: number) => Promise<void>; | ||
cleanUpSessionEventsStore: (sessionId: number, sequenceId: number) => Promise<void>; | ||
} | ||
export interface SessionReplayPlugin { | ||
(options?: SessionReplayOptions): SessionReplayEnrichmentPlugin; | ||
} | ||
//# sourceMappingURL=session-replay.d.ts.map |
@@ -1,6 +0,2 @@ | ||
export var RecordingStatus; | ||
(function (RecordingStatus) { | ||
RecordingStatus["RECORDING"] = "recording"; | ||
RecordingStatus["SENT"] = "sent"; | ||
})(RecordingStatus || (RecordingStatus = {})); | ||
export {}; | ||
//# sourceMappingURL=session-replay.js.map |
@@ -1,17 +0,2 @@ | ||
import { IDBStoreSession } from './typings/session-replay'; | ||
export declare const DEFAULT_EVENT_PROPERTY_PREFIX = "[Amplitude]"; | ||
export declare const DEFAULT_SESSION_REPLAY_PROPERTY: string; | ||
export declare const DEFAULT_SESSION_START_EVENT = "session_start"; | ||
export declare const DEFAULT_SESSION_END_EVENT = "session_end"; | ||
export declare const BLOCK_CLASS = "amp-block"; | ||
export declare const MASK_TEXT_CLASS = "amp-mask"; | ||
export declare const UNMASK_TEXT_CLASS = "amp-unmask"; | ||
export declare const SESSION_REPLAY_SERVER_URL = "https://api-secure.amplitude.com/sessions/track"; | ||
export declare const SESSION_REPLAY_EU_URL = "https://api.eu.amplitude.com/sessions/track"; | ||
export declare const STORAGE_PREFIX: string; | ||
export declare const MAX_EVENT_LIST_SIZE_IN_BYTES: number; | ||
export declare const MIN_INTERVAL = 500; | ||
export declare const MAX_INTERVAL: number; | ||
export declare const defaultSessionStore: IDBStoreSession; | ||
export declare const MAX_IDB_STORAGE_LENGTH: number; | ||
//# sourceMappingURL=constants.d.ts.map |
@@ -1,3 +0,14 @@ | ||
import { SessionReplayPlugin } from './typings/session-replay'; | ||
export declare const sessionReplayPlugin: SessionReplayPlugin; | ||
import { BrowserConfig, EnrichmentPlugin, Event } from '@amplitude/analytics-types'; | ||
import { SessionReplayOptions } from './typings/session-replay'; | ||
export declare class SessionReplayPlugin implements EnrichmentPlugin { | ||
name: string; | ||
type: "enrichment"; | ||
config: BrowserConfig; | ||
options: SessionReplayOptions; | ||
constructor(options?: SessionReplayOptions); | ||
setup(config: BrowserConfig): Promise<void>; | ||
execute(event: Event): Promise<Event>; | ||
teardown(): Promise<void>; | ||
} | ||
export declare const sessionReplayPlugin: (options?: SessionReplayOptions) => EnrichmentPlugin; | ||
//# sourceMappingURL=session-replay.d.ts.map |
@@ -1,71 +0,4 @@ | ||
import { BrowserConfig, EnrichmentPlugin } from '@amplitude/analytics-types'; | ||
import { record } from 'rrweb'; | ||
export interface SessionReplayOptions { | ||
sampleRate?: number; | ||
} | ||
export type Events = string[]; | ||
export interface SessionReplayContext { | ||
events: Events; | ||
sequenceId: number; | ||
attempts: number; | ||
timeout: number; | ||
sessionId: number; | ||
} | ||
export declare enum RecordingStatus { | ||
RECORDING = "recording", | ||
SENT = "sent" | ||
} | ||
export interface IDBStoreSequence { | ||
events: Events; | ||
status: RecordingStatus; | ||
} | ||
export interface IDBStoreSession { | ||
currentSequenceId: number; | ||
sessionSequences: { | ||
[sequenceId: number]: IDBStoreSequence; | ||
}; | ||
} | ||
export interface IDBStore { | ||
[sessionId: number]: IDBStoreSession; | ||
} | ||
export interface SessionReplayEnrichmentPlugin extends EnrichmentPlugin { | ||
setup: (config: BrowserConfig) => Promise<void>; | ||
config: BrowserConfig; | ||
storageKey: string; | ||
retryTimeout: number; | ||
events: Events; | ||
currentSequenceId: number; | ||
interval: number; | ||
queue: SessionReplayContext[]; | ||
timeAtLastSend: number | null; | ||
stopRecordingEvents: ReturnType<typeof record> | null; | ||
stopRecordingAndSendEvents: (sessionId?: number) => void; | ||
maxPersistedEventsSize: number; | ||
initialize: (shouldSendStoredEvents?: boolean) => Promise<void>; | ||
sendStoredEvents: (storedReplaySessions: IDBStore) => void; | ||
getShouldRecord: () => boolean; | ||
recordEvents: () => void; | ||
shouldSplitEventsList: (nextEventString: string) => boolean; | ||
sendEventsList: ({ events, sequenceId, sessionId, }: { | ||
events: string[]; | ||
sequenceId: number; | ||
sessionId: number; | ||
}) => void; | ||
addToQueue: (...list: SessionReplayContext[]) => void; | ||
schedule: (timeout: number) => void; | ||
flush: (useRetry?: boolean) => Promise<void>; | ||
send: (context: SessionReplayContext, useRetry?: boolean) => Promise<void>; | ||
completeRequest({ context, err, success, removeEvents, }: { | ||
context: SessionReplayContext; | ||
err?: string | undefined; | ||
success?: string | undefined; | ||
removeEvents?: boolean | undefined; | ||
}): void; | ||
getAllSessionEventsFromStore: () => Promise<IDBStore | undefined>; | ||
storeEventsForSession: (events: Events, sequenceId: number, sessionId: number) => Promise<void>; | ||
cleanUpSessionEventsStore: (sessionId: number, sequenceId: number) => Promise<void>; | ||
} | ||
export interface SessionReplayPlugin { | ||
(options?: SessionReplayOptions): SessionReplayEnrichmentPlugin; | ||
} | ||
//# sourceMappingURL=session-replay.d.ts.map |
{ | ||
"name": "@amplitude/plugin-session-replay-browser", | ||
"version": "0.6.6", | ||
"version": "0.6.7", | ||
"description": "", | ||
@@ -44,2 +44,3 @@ "author": "Amplitude Inc", | ||
"@amplitude/analytics-types": ">=1 <3", | ||
"@amplitude/session-replay-browser": "^0.1.1", | ||
"idb-keyval": "^6.2.1", | ||
@@ -61,3 +62,3 @@ "rrweb": "^2.0.0-alpha.11", | ||
], | ||
"gitHead": "2875252fa4a24e44dea6ca79fdfd7707aad44c43" | ||
"gitHead": "f361d34b9158827f298df4486d2f5110af1a880e" | ||
} |
@@ -56,3 +56,3 @@ <p align="center"> | ||
|-|-|-|-| | ||
|`sampleRate`|`number`|`undefined`|Use this option to control how many sessions will be selected for recording. A selected session will be recorded, while sessions that are not selected will not be recorded. <br></br>The number should be a decimal between 0 and 1, ie `0.4`, representing the fraction of sessions you would like to have randomly selected for recording. Over a large number of sessions, `0.4` would select `40%` of those sessions.| | ||
|`sampleRate`|`number`|`undefined`|Use this option to control how many sessions will be selected for replay collection. A selected session will be collected for replay, while sessions that are not selected will not. <br></br>The number should be a decimal between 0 and 1, ie `0.4`, representing the fraction of sessions you would like to have randomly selected for replay collection. Over a large number of sessions, `0.4` would select `40%` of those sessions.| | ||
@@ -66,11 +66,11 @@ ### 3. Install plugin to Amplitude SDK | ||
## Privacy | ||
By default, the session recording will mask all inputs, meaning the text in inputs will appear in a session replay as asterisks: `***`. You may require more specific masking controls based on your use case, so we offer the following controls: | ||
By default, the session replay will mask all inputs, meaning the text in inputs will appear in a session replay as asterisks: `***`. You may require more specific masking controls based on your use case, so we offer the following controls: | ||
#### 1. Unmask inputs | ||
In your application code, add the class `.amp-unmask` to any __input__ whose text you'd like to have unmasked in the recording. In the replay of a recorded session, it will be possible to read the exact text entered into an input with this class, the text will not be converted to asterisks. | ||
In your application code, add the class `.amp-unmask` to any __input__ whose text you'd like to have unmasked in the replay. In the session replay, it will be possible to read the exact text entered into an input with this class, the text will not be converted to asterisks. | ||
#### 2. Mask non-input elements | ||
In your application code, add the class `.amp-mask` to any __non-input element__ whose text you'd like to have masked from the recording. The text in the element, as well as it's children, will all be converted to asterisks. | ||
In your application code, add the class `.amp-mask` to any __non-input element__ whose text you'd like to have masked from the replay. The text in the element, as well as it's children, will all be converted to asterisks. | ||
#### 3. Block non-text elements | ||
In your application code, add the class `.amp-block` to any element you would like to have blocked from the recording. The element will appear in the replay as a placeholder with the same dimensions. | ||
In your application code, add the class `.amp-block` to any element you would like to have blocked from the collection of the replay. The element will appear in the replay as a placeholder with the same dimensions. |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
313983
7
47
1039
8
+ Added@amplitude/session-replay-browser@0.1.1(transitive)