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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 37 additions & 22 deletions src/Gleap.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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();
});
}

/**
Expand Down
46 changes: 45 additions & 1 deletion src/GleapCaptureScreenshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <img> 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(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1" preserveAspectRatio="none">' +
'<rect width="1" height="1" fill="rgb(128,128,128)" fill-opacity="0.16"/></svg>'
);
// 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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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,
});
Expand Down
51 changes: 49 additions & 2 deletions src/GleapNetworkIntercepter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
13 changes: 13 additions & 0 deletions src/GleapSession.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
{
Expand Down Expand Up @@ -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');
Expand All @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
63 changes: 63 additions & 0 deletions src/GleapSession.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading