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();