diff --git a/src/Gleap.js b/src/Gleap.js index fb38590c..4b6bdbb7 100755 --- a/src/Gleap.js +++ b/src/Gleap.js @@ -1049,6 +1049,15 @@ class Gleap { * Starts the bug reporting flow. */ static startFeedbackFlowWithOptions(feedbackFlow, options = {}, isSurvey = false) { + // Right after Gleap.identify() (e.g. a user switch in the same browser) the previous contact's + // session is still active: the messenger would file the report for that contact. Start once the + // identify / updateContact in flight has landed (capped at 5 s; immediate when none is). + GleapSession.getInstance().onContactSettled(() => { + Gleap.startFeedbackFlowNow(feedbackFlow, options, isSurvey); + }); + } + + static startFeedbackFlowNow(feedbackFlow, options = {}, isSurvey = false) { const { autostartDrawing, hideBackButton, format, survey } = options; const sessionInstance = GleapSession.getInstance(); if (!sessionInstance.ready) { @@ -1170,38 +1179,44 @@ class Gleap { * Starts a new conversation and attaches the bot with the given id. */ static startBot(botId, showBackButton = true) { - GleapFrameManager.getInstance().setAppMode('widget'); - GleapFrameManager.getInstance().sendMessage( - { - name: 'start-bot', - data: { - botId: botId ? botId : '', - hideBackButton: !showBackButton, + // Creates a conversation: wait for an identify still in flight (see startFeedbackFlowWithOptions). + GleapSession.getInstance().onContactSettled(() => { + GleapFrameManager.getInstance().setAppMode('widget'); + GleapFrameManager.getInstance().sendMessage( + { + name: 'start-bot', + data: { + botId: botId ? botId : '', + hideBackButton: !showBackButton, + }, }, - }, - true - ); + true + ); - GleapFrameManager.getInstance().showWidget(); + GleapFrameManager.getInstance().showWidget(); + }); } /** * Starts a new conversation and attaches the bot with the given id. */ static askAI(question, showBackButton = true) { - GleapFrameManager.getInstance().setAppMode('widget'); - GleapFrameManager.getInstance().sendMessage( - { - name: 'ask-ai', - data: { - question, - hideBackButton: !showBackButton, + // Creates a conversation: wait for an identify still in flight (see startFeedbackFlowWithOptions). + GleapSession.getInstance().onContactSettled(() => { + GleapFrameManager.getInstance().setAppMode('widget'); + GleapFrameManager.getInstance().sendMessage( + { + name: 'ask-ai', + data: { + question, + hideBackButton: !showBackButton, + }, }, - }, - true - ); + true + ); - GleapFrameManager.getInstance().showWidget(); + GleapFrameManager.getInstance().showWidget(); + }); } /** diff --git a/src/GleapCaptureScreenshot.js b/src/GleapCaptureScreenshot.js index 26d9aab9..2bc511a0 100644 --- a/src/GleapCaptureScreenshot.js +++ b/src/GleapCaptureScreenshot.js @@ -12,6 +12,17 @@ import { now, yieldToPage } from './GleapCaptureTasks'; export const MAX_CAPTURE_EDGE = 2560; const JPEG_QUALITY = 0.85; const TRANSPARENT_GIF = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; +// An the render can't download (cross-origin without CORS headers, so the canvas may not read +// it) gets a neutral tint instead of an empty hole, so the screenshot doesn't look like the app lost +// the image. Stretches to any box. The tab capture and the classic server render keep such images. +const IMAGE_PLACEHOLDER = + 'data:image/svg+xml;charset=utf-8,' + + encodeURIComponent( + '' + + '' + ); +// CSS background images stay transparent (usually decoration). +const imagePlaceholder = (imageDom) => (imageDom ? IMAGE_PLACEHOLDER : TRANSPARENT_GIF); // Gleap's own UI never appears in a capture: the widget, launcher, notifications, banners, modals, // tooltips, tours, the admin helper and the capture UI itself. @@ -1313,6 +1324,26 @@ const createImageFetcher = () => { } }; +// The render downloads every image and font again (fetch). The logger is paused meanwhile, but the +// browser still lists those downloads in Resource Timing: mark them so they never show up as the +// app's requests (or failures) in the logs. +const ignoreInNetworkLogs = (url, since) => { + try { + if (typeof url === 'string' && url.indexOf('data:') !== 0 && url.indexOf('blob:') !== 0) { + GleapNetworkIntercepter.getInstance().ignoreInternalRequest(url, null, since); + } + } catch (exp) {} +}; + +// Wraps createImageFetcher's result: every image the render asks for is marked before the download. +const createTrackedImageFetcher = (since) => { + const fetchAway = createImageFetcher(); + return (url) => { + ignoreInNetworkLogs(url, since); + return fetchAway ? fetchAway(url) : Promise.resolve(false); + }; +}; + // Network logging is paused while renders download assets; overlapping renders share one pause. let networkLogPauses = 0; let networkLogsWereStopped = false; @@ -1374,6 +1405,7 @@ export const renderDom = ({ privacyOptions, maskSelectors, isActive }) => // Its asset downloads (fonts, images) are not the app's traffic: keep them out of the network logs. const resumeNetworkLogs = pauseNetworkLogs(); + const renderStart = now(); let context = null; let sandbox = null; let settled = false; @@ -1383,6 +1415,12 @@ export const renderDom = ({ privacyOptions, maskSelectors, isActive }) => clearTimeout(deadline); clearInterval(watch); if (context) { + try { + // Fonts and CSS assets don't pass the image fetcher. + if (context.requests && typeof context.requests.forEach === 'function') { + context.requests.forEach((request, url) => ignoreInNetworkLogs(url, renderStart)); + } + } catch (exp) {} try { // Removes the sandbox as well. destroyContext(context); @@ -1460,7 +1498,13 @@ export const renderDom = ({ privacyOptions, maskSelectors, isActive }) => overflow: 'visible', }, features: { restoreScrollPosition: false }, - fetchFn: createImageFetcher(), + fetchFn: createTrackedImageFetcher(renderStart), + // Passing `fetch` replaces modern-screenshot's defaults: keep its cache setting. + fetch: { + requestInit: { cache: 'force-cache' }, + bypassingCache: false, + placeholderImage: imagePlaceholder, + }, // How long to wait for images that are still loading; the render itself gets longer below. timeout: imagesLoadingInViewport() ? 3000 : 50, }); diff --git a/src/GleapNetworkIntercepter.js b/src/GleapNetworkIntercepter.js index 351429b7..1be126da 100644 --- a/src/GleapNetworkIntercepter.js +++ b/src/GleapNetworkIntercepter.js @@ -58,6 +58,10 @@ const now = () => { return Date.now(); }; +// Internal urls remembered for Resource Timing filtering, and the clock slack for their entries. +const MAX_INTERNAL_URLS = 300; +const INTERNAL_URL_TOLERANCE_MS = 5; + const elapsedSince = (start) => Math.max(0, Math.round(now() - start)); const truncationMarker = (totalBytes) => @@ -506,6 +510,10 @@ class GleapNetworkIntercepter { initialized = false; stopped = false; loadAllResources = false; + // The SDK's own downloads (screenshot assets): XHRs that are never recorded, and urls whose + // Resource Timing entries from that moment on are left out. + internalXhrs = typeof WeakSet !== 'undefined' ? new WeakSet() : null; + internalUrls = new Map(); // GleapNetworkIntercepter singleton static instance; @@ -516,6 +524,38 @@ class GleapNetworkIntercepter { return this.instance; } + /** + * Keeps one of the SDK's own requests out of the network logs (e.g. the images a screenshot + * downloads), so a failing asset download never reads as the app's failed request. + * @param {string} url + * @param {XMLHttpRequest} [xhr] call before xhr.open(); that request is not recorded + * @param {number} [since] performance.now() of the first download; defaults to now + */ + ignoreInternalRequest(url, xhr, since) { + try { + if (xhr && this.internalXhrs) { + this.internalXhrs.add(xhr); + } + if (!url) { + return; + } + const key = resolveUrl(url); + const start = typeof since === 'number' && isFinite(since) ? since : now(); + const previous = this.internalUrls.get(key); + this.internalUrls.delete(key); + this.internalUrls.set(key, typeof previous === 'number' ? Math.min(previous, start) : start); + // Bounded: drop the oldest marks. + while (this.internalUrls.size > MAX_INTERNAL_URLS) { + this.internalUrls.delete(this.internalUrls.keys().next().value); + } + } catch (exp) {} + } + + isInternalResource(resource) { + const since = this.internalUrls.get(resource.name); + return typeof since === 'number' && resource.startTime >= since - INTERNAL_URL_TOLERANCE_MS; + } + setLoadAllResources(loadAllResources) { this.loadAllResources = loadAllResources; } @@ -603,7 +643,11 @@ class GleapNetworkIntercepter { if (!this.loadAllResources && ['xmlhttprequest', 'fetch'].indexOf(resource.initiatorType) === -1) { continue; } - if (capturedUrls['url:' + resource.name] || isBlacklistedUrl(resource.name, blacklist)) { + if ( + capturedUrls['url:' + resource.name] || + isBlacklistedUrl(resource.name, blacklist) || + this.isInternalResource(resource) + ) { continue; } const start = timeOrigin + resource.startTime; @@ -941,7 +985,10 @@ class GleapNetworkIntercepter { state.method = String(method || 'GET').toUpperCase(); state.url = resolveUrl(url); state.headers = {}; - state.capture = this.isCapturing() && !isBlacklistedUrl(state.url, this.getBlacklist()); + state.capture = + this.isCapturing() && + !(this.internalXhrs && this.internalXhrs.has(xhr)) && + !isBlacklistedUrl(state.url, this.getBlacklist()); if (state.capture && !state.listening) { state.listening = true; diff --git a/src/GleapSession.js b/src/GleapSession.js index 38207a6f..3eaae528 100644 --- a/src/GleapSession.js +++ b/src/GleapSession.js @@ -56,6 +56,9 @@ export default class GleapSession { // waits for them (onContactSettled). pendingContactUpdates = 0; contactSettledListeners = []; + // Bumped whenever an identify / updateContact result (or a logout) replaces the session. A + // /sessions response for a request sent before that describes the previous contact and is dropped. + contactVersion = 0; // GleapSession singleton static instance; @@ -245,6 +248,7 @@ export default class GleapSession { // replay state so the next session can't inherit a stale identity. this.lastIdentify = null; this.identifyInFlight = false; + this.contactVersion++; GleapFrameManager.getInstance().sendMessage( { @@ -427,6 +431,7 @@ export default class GleapSession { } const self = this; + const contactVersionAtSend = this.contactVersion; const http = new XMLHttpRequest(); http.open('POST', self.apiUrl + '/sessions'); http.setRequestHeader('Content-Type', 'application/json;charset=UTF-8'); @@ -439,6 +444,12 @@ export default class GleapSession { } catch (exp) {} http.onreadystatechange = function (e) { if (http.readyState === 4) { + // Gleap.identify() is sent right away against a cached session, so its answer (the new + // contact) can land first. This older answer must not switch back to the previous contact + // (or clear the new one). + if (self.contactVersion !== contactVersionAtSend) { + return; + } if (http.status === 200 || http.status === 201) { try { const sessionData = JSON.parse(http.responseText); @@ -552,6 +563,7 @@ export default class GleapSession { if (http.status === 200 || http.status === 201) { try { const sessionData = JSON.parse(http.responseText); + self.contactVersion++; self.validateSession(sessionData); resolve(sessionData); } catch (exp) { @@ -620,6 +632,7 @@ export default class GleapSession { if (http.status === 200 || http.status === 201) { try { const sessionData = JSON.parse(http.responseText); + self.contactVersion++; self.validateSession(sessionData); resolve(sessionData); } catch (exp) { diff --git a/src/GleapSession.test.js b/src/GleapSession.test.js index 3782443d..227ebb39 100644 --- a/src/GleapSession.test.js +++ b/src/GleapSession.test.js @@ -236,6 +236,69 @@ describe('identify replay', () => { }); }); +describe('user switch in the same browser', () => { + const OTHER_USER_SESSION = { + gleapId: 'gleap-id-b', + gleapHash: 'gleap-hash-b', + userId: 'user-2', + email: 'other@example.com', + }; + const sessionsRequest = () => MockXhr.instances.find((xhr) => xhr.url && xhr.url.endsWith('/sessions')); + + it('keeps the new contact when the older /sessions answer for the cached contact lands last', () => { + seedCachedSession(); + const session = buildSession(); + session.startSession(); + session.identifySession('user-2', { email: 'other@example.com' }); + + identifyRequests()[0].respond(200, JSON.stringify(OTHER_USER_SESSION)); + sessionsRequest().respond(200, JSON.stringify(CACHED_IDENTIFIED_SESSION)); + + expect(session.session.userId).toBe('user-2'); + expect(session.session.gleapId).toBe('gleap-id-b'); + expect(cacheStore['session-sdk-key'].userId).toBe('user-2'); + }); + + it('does not clear the new contact when the older /sessions request is rejected', () => { + seedCachedSession(); + const session = buildSession(); + session.startSession(); + session.identifySession('user-2', { email: 'other@example.com' }); + + identifyRequests()[0].respond(200, JSON.stringify(OTHER_USER_SESSION)); + sessionsRequest().respond(404); + + expect(session.session.userId).toBe('user-2'); + expect(session.ready).toBe(true); + }); + + it('still applies the /sessions answer when no identify replaced the session', () => { + seedCachedSession(); + const session = buildSession(); + session.startSession(); + sessionsRequest().respond(200, JSON.stringify({ ...CACHED_IDENTIFIED_SESSION, name: 'Bec Renamed' })); + + expect(session.session.name).toBe('Bec Renamed'); + }); + + it('holds a flow start until the identify has landed', () => { + seedCachedSession(); + const session = buildSession(); + session.startSession(); + session.identifySession('user-2', { email: 'other@example.com' }); + + const started = jest.fn(() => session.session.userId); + session.onContactSettled(started); + expect(started).not.toHaveBeenCalled(); + + identifyRequests()[0].respond(200, JSON.stringify(OTHER_USER_SESSION)); + return Promise.resolve().then(() => { + expect(started).toHaveBeenCalledTimes(1); + expect(started.mock.results[0].value).toBe('user-2'); + }); + }); +}); + describe('authenticated file sessions', () => { it('keeps partial session updates independent of file identification', () => { const session = buildSession(); diff --git a/src/ScreenCapture.js b/src/ScreenCapture.js index 9b2fe050..378fb862 100644 --- a/src/ScreenCapture.js +++ b/src/ScreenCapture.js @@ -1,6 +1,14 @@ import { isMobile, resizeImage } from './GleapHelper'; import { getFieldValueMask, getTextMask, isBlockedElement, isMaskMarker } from './GleapInputMasking'; import { isBlacklisted } from './ResourceExclusionList'; +import GleapNetworkIntercepter from './GleapNetworkIntercepter'; + +// The screenshot's own downloads are not the app's traffic: keep them out of the network logs. +const ignoreInNetworkLogs = (xhr, url) => { + try { + GleapNetworkIntercepter.getInstance().ignoreInternalRequest(url, xhr); + } catch (exp) {} +}; /** * Captures the page as HTML for the screenshot renderer. @@ -111,6 +119,7 @@ const fetchCSSResource = (url) => { xhr.onerror = function (err) { resolve(); }; + ignoreInNetworkLogs(xhr, url); xhr.open('GET', url); xhr.responseType = 'blob'; xhr.send(); @@ -154,6 +163,7 @@ const fetchItemResource = (elem) => { resolve(); }; var url = elem.src; + ignoreInNetworkLogs(xhr, url); xhr.open('GET', url); xhr.responseType = 'blob'; xhr.send();