From 49741e0ff1548454ace87e4e3571aeb88892fc9a Mon Sep 17 00:00:00 2001 From: Lukas Boehler Date: Mon, 12 Oct 2026 23:28:41 +0200 Subject: [PATCH 1/2] fix(capture): Pause/Resume stays under the pointer when the pill shows "Paused" The centred recording pill widened by the "Paused" label to both sides, so Resume jumped ~27px right of where Pause was clicked; clicking the same spot again hit the timer. The pill now grows to the left (centred and dragged pills); phones keep the full-width docked pill. Found in the live E2E of capture UI v2 (10-12). Co-Authored-By: Claude Opus 5.5 --- src/GleapCaptureUI.js | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/GleapCaptureUI.js b/src/GleapCaptureUI.js index 99826450..0258e98d 100644 --- a/src/GleapCaptureUI.js +++ b/src/GleapCaptureUI.js @@ -854,7 +854,9 @@ export default class GleapCaptureUI { this.dot.classList.toggle('paused', !!paused); } if (this.pausedLabel) { + const before = this.pillRight(); this.pausedLabel.style.display = paused ? '' : 'none'; + this.keepPillRight(before); } const button = this.pauseButton; if (button) { @@ -865,6 +867,36 @@ export default class GleapCaptureUI { } } + pillRight() { + try { + return this.pill ? this.pill.getBoundingClientRect().right : null; + } catch (exp) { + return null; + } + } + + // "Paused" widens the pill. A centred (or dragged) pill would grow to both sides and move Pause / + // Resume away from the pointer that just clicked it, so the pill grows to the left instead. + // Phones dock the pill across the whole width; nothing moves there. + keepPillRight(before) { + const pill = this.pill; + const after = this.pillRight(); + if (!pill || before === null || after === null || window.innerWidth <= 600) { + return; + } + const shift = after - before; + if (Math.abs(shift) < 0.5) { + return; + } + if (this.position) { + this.position = { left: this.position.left - shift, top: this.position.top }; + this.place(pill); + } else { + const margin = (parseFloat(pill.style.marginLeft) || 0) - shift; + pill.style.marginLeft = Math.abs(margin) < 0.5 ? '' : margin + 'px'; + } + } + setMicOn(on) { const button = this.micButton; if (button) { From 194e05fd04d0b795720902d2fc1b18bd5d24c3cc Mon Sep 17 00:00:00 2001 From: Lukas Boehler Date: Tue, 13 Oct 2026 01:53:16 +0200 Subject: [PATCH 2/2] fix(capture): keep screenshot downloads out of network logs, tint undownloadable images, fix user-switch race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Network logs: the classic screenshot's own image/CSS XHRs were logged as the app's requests (a no-CORS image showed as "GET … FAILED", which Kai read). They are now marked internal and never recorded; the v2 DOM render's fetches (logger already paused) are also kept out of the Resource Timing entries. - v2 DOM screenshot: an the browser render can't download (cross-origin without CORS) gets a neutral tint instead of an empty hole. - Identity: a /sessions answer for the cached contact that lands after Gleap.identify() switched the user no longer switches back (or clears the new contact); startFeedbackFlow / startClassicForm / startBot / startConversation / askAI wait for an identify in flight (max 5 s, like showSurvey) so the conversation isn't filed for the previous contact. Co-Authored-By: Claude Opus 5.5 --- src/Gleap.js | 59 +++++++++++++++++++------------ src/GleapCaptureScreenshot.js | 46 ++++++++++++++++++++++++- src/GleapNetworkIntercepter.js | 51 +++++++++++++++++++++++++-- src/GleapSession.js | 13 +++++++ src/GleapSession.test.js | 63 ++++++++++++++++++++++++++++++++++ src/ScreenCapture.js | 10 ++++++ 6 files changed, 217 insertions(+), 25 deletions(-) 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();