-
Notifications
You must be signed in to change notification settings - Fork 40
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Session Replay - Detect Non-Inlined Stylesheets (#859)
- Loading branch information
1 parent
cd6324f
commit 69a8e00
Showing
15 changed files
with
396 additions
and
39 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
84 changes: 84 additions & 0 deletions
84
src/features/session_replay/shared/stylesheet-evaluator.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,84 @@ | ||
import { originals } from '../../../common/config/config' | ||
import { isBrowserScope } from '../../../common/constants/runtime' | ||
|
||
class StylesheetEvaluator { | ||
#evaluated = new WeakSet() | ||
#fetchProms = [] | ||
/** | ||
* Flipped to true if stylesheets that cannot be natively inlined are detected by the stylesheetEvaluator class | ||
* Used at harvest time to denote that all subsequent payloads are subject to this and customers should be advised to handle crossorigin decoration | ||
* */ | ||
invalidStylesheetsDetected = false | ||
failedToFix = false | ||
|
||
/** | ||
* this works by checking (only ever once) each cssRules obj in the style sheets array. The try/catch will catch an error if the cssRules obj blocks access, triggering the module to try to "fix" the asset`. Returns the count of incomplete assets discovered. | ||
* @returns {Number} | ||
*/ | ||
evaluate () { | ||
let incompletes = 0 | ||
if (isBrowserScope) { | ||
for (let i = 0; i < Object.keys(document.styleSheets).length; i++) { | ||
const ss = document.styleSheets[i] | ||
if (!this.#evaluated.has(ss)) { | ||
this.#evaluated.add(ss) | ||
try { | ||
// eslint-disable-next-line | ||
const temp = ss.cssRules | ||
} catch (err) { | ||
incompletes++ | ||
this.#fetchProms.push(this.#fetchAndOverride(document.styleSheets[i], ss.href)) | ||
} | ||
} | ||
} | ||
} | ||
if (incompletes) this.invalidStylesheetsDetected = true | ||
return incompletes | ||
} | ||
|
||
/** | ||
* Resolves promise once all stylesheets have been fetched and overridden | ||
* @returns {Promise} | ||
*/ | ||
async fix () { | ||
await Promise.all(this.#fetchProms) | ||
this.#fetchProms = [] | ||
const failedToFix = this.failedToFix | ||
this.failedToFix = false | ||
return failedToFix | ||
} | ||
|
||
/** | ||
* Fetches stylesheet contents and overrides the target getters | ||
* @param {*} target - The stylesheet object target - ex. document.styleSheets[0] | ||
* @param {*} href - The asset href to fetch | ||
* @returns {Promise} | ||
*/ | ||
async #fetchAndOverride (target, href) { | ||
const stylesheetContents = await originals.FETCH.bind(window)(href) | ||
if (!stylesheetContents.ok) { | ||
this.failedToFix = true | ||
return | ||
} | ||
const stylesheetText = await stylesheetContents.text() | ||
try { | ||
const cssSheet = new CSSStyleSheet() | ||
await cssSheet.replace(stylesheetText) | ||
Object.defineProperty(target, 'cssRules', { | ||
get () { return cssSheet.cssRules } | ||
}) | ||
Object.defineProperty(target, 'rules', { | ||
get () { return cssSheet.rules } | ||
}) | ||
} catch (err) { | ||
// cant make new dynamic stylesheets, browser likely doesn't support `.replace()`... | ||
// this is appended in prep of forking rrweb | ||
Object.defineProperty(target, 'cssText', { | ||
get () { return stylesheetText } | ||
}) | ||
this.failedToFix = true | ||
} | ||
} | ||
} | ||
|
||
export const stylesheetEvaluator = new StylesheetEvaluator() |
95 changes: 95 additions & 0 deletions
95
src/features/session_replay/shared/stylesheet-evaluator.test.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,95 @@ | ||
import { stylesheetEvaluator } from './stylesheet-evaluator' | ||
|
||
let stylesheet | ||
|
||
describe('stylesheet-evaluator', (done) => { | ||
beforeEach(async () => { | ||
stylesheet = new CSSStyleSheet() | ||
stylesheet.href = 'https://test.com' | ||
|
||
const globalScope = await import('../../../common/config/state/originals') | ||
jest.replaceProperty(globalScope, 'originals', { | ||
FETCH: jest.fn(() => | ||
Promise.resolve({ | ||
text: () => Promise.resolve('myCssText{width:1}') | ||
}) | ||
) | ||
}) | ||
class CSSStyleSheetMock { | ||
cssRules = {} | ||
rules = {} | ||
replace (txt) { | ||
return new Promise((resolve) => { | ||
this.cssRules = { txt } | ||
this.rules = { txt } | ||
resolve() | ||
}) | ||
} | ||
} | ||
global.CSSStyleSheet = CSSStyleSheetMock | ||
}) | ||
it('should evaluate stylesheets with cssRules as false', async () => { | ||
prepStylesheet({ | ||
get () { return 'success' } | ||
}) | ||
expect(stylesheetEvaluator.evaluate()).toEqual(0) | ||
}) | ||
|
||
it('should evaluate stylesheets without cssRules as true', async () => { | ||
prepStylesheet({ | ||
get () { | ||
throw new Error() | ||
} | ||
}) | ||
expect(stylesheetEvaluator.evaluate()).toEqual(1) | ||
}) | ||
|
||
it('should evaluate stylesheets once', async () => { | ||
prepStylesheet({ | ||
get () { | ||
throw new Error() | ||
} | ||
}) | ||
expect(stylesheetEvaluator.evaluate()).toEqual(1) | ||
expect(stylesheetEvaluator.evaluate()).toEqual(0) | ||
}) | ||
|
||
it('should execute fix single', async () => { | ||
prepStylesheet({ | ||
get () { return 'success' } | ||
}) | ||
stylesheetEvaluator.evaluate() | ||
await stylesheetEvaluator.fix() | ||
expect(document.styleSheets[0].cssRules).toEqual(stylesheet.cssRules) | ||
}) | ||
|
||
it('should resolve as false if not browserScope', async () => { | ||
jest.resetModules() | ||
jest.doMock('../../../common/constants/runtime', () => ({ | ||
globalScope: {}, | ||
isBrowserScope: false | ||
})) | ||
const { stylesheetEvaluator } = await import('./stylesheet-evaluator') | ||
prepStylesheet({ | ||
get () { | ||
throw new Error() | ||
} | ||
}) | ||
expect(stylesheetEvaluator.evaluate()).toEqual(0) | ||
}) | ||
}) | ||
|
||
function prepStylesheet (cssRules) { | ||
Object.defineProperty(stylesheet, 'cssRules', cssRules) | ||
Object.defineProperty(document, 'styleSheets', { | ||
value: { | ||
0: stylesheet, | ||
[Symbol.iterator]: function * () { | ||
for (let key in this) { | ||
yield this[key] // yield [key, value] pair | ||
} | ||
} | ||
}, | ||
configurable: true | ||
}) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,56 @@ | ||
<!DOCTYPE html> | ||
<!-- | ||
Copyright 2020 New Relic Corporation. | ||
PDX-License-Identifier: Apache-2.0 | ||
--> | ||
<html> | ||
<head> | ||
<title>RUM Unit Test</title> | ||
<style> | ||
.left { | ||
position: absolute; | ||
left: 50px; | ||
top: 200px; | ||
} | ||
.right { | ||
position: absolute; | ||
right: 50px; | ||
top: 200px; | ||
} | ||
</style> | ||
<link rel="stylesheet" type="text/css" href="style.css" /> | ||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/pure/3.0.0/pure-min.css" referrerpolicy="no-referrer" /> | ||
{init} {config} {loader} | ||
<script> | ||
setTimeout(() => { | ||
const ss = document.createElement("link"); | ||
ss.rel = "stylesheet"; | ||
ss.href = "https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.2/css/bootstrap.min.css"; | ||
document.head.appendChild(ss); | ||
}, 3000); | ||
</script> | ||
</head> | ||
<body> | ||
this is a page that provides several types of elements with selectors that session_replay can interact with based on how it is configured | ||
<hr /> | ||
<hr /> | ||
<textarea id="plain"></textarea> | ||
<textarea id="ignore" class="nr-ignore"></textarea> | ||
<textarea id="block" class="nr-block"></textarea> | ||
<textarea id="mask" class="nr-mask"></textarea> | ||
<textarea id="nr-block" data-nr-block></textarea> | ||
<textarea id="other-block" data-other-block></textarea> | ||
<input type="password" id="pass-input" /> | ||
<input type="text" id="text-input" /> | ||
<hr /> | ||
<button onclick="moveImage()">Click</button> | ||
<img src="https://upload.wikimedia.org/wikipedia/commons/d/d7/House_of_Commons_Chamber_1.png" /> | ||
<a href="./rrweb-instrumented.html" target="_blank">New Tab</a> | ||
<script> | ||
function moveImage() { | ||
document.querySelector("img").classList.toggle("left"); | ||
document.querySelector("img").classList.toggle("right"); | ||
} | ||
</script> | ||
</body> | ||
</html> |
Oops, something went wrong.