Skip to content

Commit

Permalink
feat: Session Replay - Detect Non-Inlined Stylesheets (#859)
Browse files Browse the repository at this point in the history
  • Loading branch information
metal-messiah authored Jan 24, 2024
1 parent cd6324f commit 69a8e00
Show file tree
Hide file tree
Showing 15 changed files with 396 additions and 39 deletions.
3 changes: 3 additions & 0 deletions src/features/session_replay/aggregate/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { RRWEB_VERSION } from '../../../common/constants/env'
import { now } from '../../../common/timing/now'
import { MODE, SESSION_EVENTS, SESSION_EVENT_TYPES } from '../../../common/session/constants'
import { stringify } from '../../../common/util/stringify'
import { stylesheetEvaluator } from '../shared/stylesheet-evaluator'

let gzipper, u8

Expand Down Expand Up @@ -302,6 +303,8 @@ export class Aggregate extends AggregateBase {
hasError: recorderEvents.hasError || false,
isFirstChunk: agentRuntime.session.state.sessionReplaySentFirstChunk === false,
decompressedBytes: recorderEvents.payloadBytesEstimation,
invalidStylesheetsDetected: stylesheetEvaluator.invalidStylesheetsDetected,
inlinedAllStylesheets: recorderEvents.inlinedAllStylesheets,
'rrweb.version': RRWEB_VERSION,
// customer-defined data should go last so that if it exceeds the query param padding limit it will be truncated instead of important attrs
...(endUserId && { 'enduser.id': endUserId })
Expand Down
2 changes: 2 additions & 0 deletions src/features/session_replay/shared/recorder-events.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export class RecorderEvents {
this.hasMeta = false
/** Payload metadata -- Should indicate that the payload being sent contains an error. Used for query/filter purposes in UI */
this.hasError = false
/** Payload metadata -- Denotes whether all stylesheet elements were able to be inlined */
this.inlinedAllStylesheets = true
}

add (event) {
Expand Down
53 changes: 49 additions & 4 deletions src/features/session_replay/shared/recorder.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { AVG_COMPRESSION, CHECKOUT_MS, IDEAL_PAYLOAD_SIZE, QUERY_PARAM_PADDING,
import { getConfigurationValue } from '../../../common/config/config'
import { RecorderEvents } from './recorder-events'
import { MODE } from '../../../common/session/constants'
import { stylesheetEvaluator } from './stylesheet-evaluator'
import { handle } from '../../../common/event-emitter/handle'
import { SUPPORTABILITY_METRIC_CHANNEL } from '../../metrics/constants'
import { FEATURE_NAMES } from '../../../loaders/features/features'

export class Recorder {
/** Each page mutation or event will be stored (raw) in this array. This array will be cleared on each harvest */
Expand All @@ -12,16 +16,22 @@ export class Recorder {
#backloggedEvents = new RecorderEvents()
/** array of recorder events -- Will be filled only if forced harvest was triggered and harvester does not exist */
#preloaded = [new RecorderEvents()]
/** flag that if true, blocks events from being "stored". Only set to true when a full snapshot has incomplete nodes (only stylesheets ATM) */
#fixing = false

constructor (parent) {
/** True when actively recording, false when paused or stopped */
this.recording = false
/** The pointer to the current bucket holding rrweb events */
this.currentBufferTarget = this.#events
/** Hold on to the last meta node, so that it can be re-inserted if the meta and snapshot nodes are broken up due to harvesting */
this.lastMeta = false

/** The parent class that instantiated the recorder */
this.parent = parent

/** Config to inform to inline stylesheet contents (true default) */
this.shouldInlineStylesheets = getConfigurationValue(this.parent.agentIdentifier, 'session_replay.inline_stylesheet')
/** A flag that can be set to false by failing conversions to stop the fetching process */
this.shouldFix = this.shouldInlineStylesheets
/** The method to stop recording. This defaults to a noop, but is overwritten once the recording library is imported and initialized */
this.stopRecording = () => { /* no-op until set by rrweb initializer */ }
}
Expand All @@ -35,7 +45,8 @@ export class Recorder {
payloadBytesEstimation: this.#backloggedEvents.payloadBytesEstimation + this.#events.payloadBytesEstimation,
hasError: this.#backloggedEvents.hasError || this.#events.hasError,
hasMeta: this.#backloggedEvents.hasMeta || this.#events.hasMeta,
hasSnapshot: this.#backloggedEvents.hasSnapshot || this.#events.hasSnapshot
hasSnapshot: this.#backloggedEvents.hasSnapshot || this.#events.hasSnapshot,
inlinedAllStylesheets: (!!this.#backloggedEvents.events.length && this.#backloggedEvents.inlinedAllStylesheets) || this.#events.inlinedAllStylesheets
}
}

Expand All @@ -54,7 +65,7 @@ export class Recorder {
// set up rrweb configurations for maximum privacy --
// https://newrelic.atlassian.net/wiki/spaces/O11Y/pages/2792293280/2023+02+28+Browser+-+Session+Replay#Configuration-options
const stop = recorder({
emit: this.store.bind(this),
emit: this.audit.bind(this),
blockClass: block_class,
ignoreClass: ignore_class,
maskTextClass: mask_text_class,
Expand All @@ -74,8 +85,42 @@ export class Recorder {
}
}

/**
* audit - Checks if the event node payload is missing certain attributes
* will forward on to the "store" method if nothing needs async fixing
* @param {*} event - An RRWEB event node
* @param {*} isCheckout - Flag indicating if the payload was triggered as a checkout
*/
audit (event, isCheckout) {
/** only run the audit if inline_stylesheets is configured as on (default behavior) */
if (this.shouldInlineStylesheets === false || !this.shouldFix) {
this.currentBufferTarget.inlinedAllStylesheets = false
return this.store(event, isCheckout)
}
/** An count of stylesheet objects that were blocked from accessing contents via JS */
const incompletes = stylesheetEvaluator.evaluate()
/** Only stop ignoring data if already ignoring and a new valid snapshap is taking place (0 incompletes and we get a meta node for the snap) */
if (!incompletes && this.#fixing && event.type === RRWEB_EVENT_TYPES.Meta) this.#fixing = false
if (incompletes) {
handle(SUPPORTABILITY_METRIC_CHANNEL, ['SessionReplay/Payload/Missing-Inline-Css', incompletes], undefined, FEATURE_NAMES.metrics, this.parent.ee)
/** wait for the evaluator to download/replace the incompletes' src code and then take a new snap */
stylesheetEvaluator.fix().then((failedToFix) => {
if (failedToFix) {
this.currentBufferTarget.inlinedAllStylesheets = false
this.shouldFix = false
}
this.takeFullSnapshot()
})
/** Only start ignoring data if got a faulty snapshot */
if (event.type === RRWEB_EVENT_TYPES.FullSnapshot || event.type === RRWEB_EVENT_TYPES.Meta) this.#fixing = true
}
/** Only store the data if not being "fixed" (full snapshots that have broken css) */
if (!this.#fixing) this.store(event, isCheckout)
}

/** Store a payload in the buffer (this.#events). This should be the callback to the recording lib noticing a mutation */
store (event, isCheckout) {
if (!event) return
event.__serialized = stringify(event)

if (!this.parent.scheduler && this.#preloaded.length) this.currentBufferTarget = this.#preloaded[this.#preloaded.length - 1]
Expand Down
84 changes: 84 additions & 0 deletions src/features/session_replay/shared/stylesheet-evaluator.js
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 src/features/session_replay/shared/stylesheet-evaluator.test.js
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
})
}
16 changes: 8 additions & 8 deletions tests/assets/rrweb-instrumented.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,18 @@
top: 200px;
}
</style>
<link rel="stylesheet" type="text/css" href="style.css" />
<link rel="stylesheet" type="text/css" href="style.css" />
{init} {config} {loader}
<script>
window.wasPreloaded = false;
window.addEventListener("load", () => {
try{
window.addEventListener("load", () => {
try {
const replayEvents = Object.values(newrelic.initializedAgents)[0].features.session_replay.recorder.getEvents();
window.wasPreloaded = !!replayEvents.events.length && replayEvents.type === "preloaded"
} catch(err){
// do nothing because it failed to get the recorder events -- which means they dont exist
}
});
window.wasPreloaded = !!replayEvents.events.length && replayEvents.type === "preloaded";
} catch (err) {
// do nothing because it failed to get the recorder events -- which means they dont exist
}
});
</script>
</head>
<body>
Expand Down
56 changes: 56 additions & 0 deletions tests/assets/rrweb-invalid-stylesheet.html
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>
Loading

0 comments on commit 69a8e00

Please sign in to comment.