Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

⚗ performance impact summary tool #755

Merged
merged 18 commits into from
Mar 8, 2021
Merged
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
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ module.exports = {
'./test/app/tsconfig.json',
'./test/e2e/tsconfig.json',
'./developer-extension/tsconfig.json',
'./performances/tsconfig.json',
],
sourceType: 'module',
},
Expand Down
1 change: 1 addition & 0 deletions LICENSE-3rdparty.csv
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ dev,lerna,MIT,Copyright 2015-present Lerna Contributors
dev,npm-run-all,MIT,Copyright 2015 Toru Nagashima
dev,pako,MIT,(C) 2014-2017 Vitaly Puzrin and Andrey Tupitsin
dev,prettier,MIT,Copyright James Long and contributors
dev,puppeteer,Apache-2.0,Copyright 2017 Google Inc.
dev,replace-in-file,MIT,Copyright 2015-2019 Adam Reis
dev,sinon,BSD-3-Clause,Copyright 2010-2017 Christian Johansen
dev,string-replace-loader,MIT,Copyright 2015 Valentyn Barmashyn
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
"private": true,
"workspaces": [
"packages/*",
"developer-extension"
"developer-extension",
"performances"
],
"scripts": {
"postinstall": "scripts/cli update_submodule",
Expand Down
5 changes: 5 additions & 0 deletions performances/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Browser SDK performances impact estimation tool

This tool runs various scenarios in a browser and profile the impact of the Browser SDK.

Use `yarn start` to execute it.
11 changes: 11 additions & 0 deletions performances/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"private": true,
"name": "performances",
"version": "0.0.0",
"scripts": {
"start": "ts-node ./src/main.ts"
},
"dependencies": {
"puppeteer": "8.0.0"
}
}
33 changes: 33 additions & 0 deletions performances/src/format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { ProfilingResult, ProfilingResults } from './types'

const DURATION_UNITS = ['μs', 'ms', 's']

const BYTES_UNITS = ['B', 'kB', 'MB']

export function formatProfilingResults(results: ProfilingResults) {
return `\
Memory (median): ${formatNumberWithUnit(results.memory.sdk, BYTES_UNITS)} ${formatPercent(results.memory)}
CPU: ${formatNumberWithUnit(results.cpu.sdk, DURATION_UNITS)} ${formatPercent(results.cpu)}
Bandwidth:
upload: ${formatNumberWithUnit(results.upload.sdk, BYTES_UNITS)}
download: ${formatNumberWithUnit(results.download.sdk, BYTES_UNITS)}`
}

function formatNumberWithUnit(n: number, units: string[]) {
let unit: string
for (unit of units) {
if (n < 1000) {
break
}
n /= 1000
}
return `${formatNumber(n)} ${unit!}`
}

function formatPercent({ total, sdk }: ProfilingResult) {
return `(${formatNumber((sdk / total) * 100)}%)`
}

function formatNumber(n: number) {
return new Intl.NumberFormat('en-US', { maximumFractionDigits: n < 10 ? 2 : n < 100 ? 1 : 0 }).format(n)
}
146 changes: 146 additions & 0 deletions performances/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import puppeteer, { Page } from 'puppeteer'

import { formatProfilingResults } from './format'
import { startProfiling } from './profiling'
import { trackNetwork } from './trackNetwork'
import { ProfilingResults, ProfilingOptions } from './types'

main().catch((error) => {
console.error(error)
process.exitCode = 1
})

async function main() {
const options: ProfilingOptions = {
bundleUrl: 'https://www.datadoghq-browser-agent.com/datadog-rum.js',
proxyHost: 'datadog-browser-sdk-profiling-proxy',
}

const wikipediaResults = await profileScenario(options, runWikipediaScenario)
const twitterResults = await profileScenario(options, runTwitterScenario)

console.log(`
# Wikipedia

Illustrates a mostly static site scenario.

* Navigate on three Wikipedia articles
* Do a search (with dynamic autocompletion) and go to the first result

${formatProfilingResults(wikipediaResults)}


# Twitter

Illustrates a SPA scenario.

* Navigate to the top trending topics
* Click on the first trending topic
* Click on Top, Latest, People, Photos and Videos tabs
* Navigate to the Settings page
* Click on a few checkboxes

${formatProfilingResults(twitterResults)}
`)
}

async function profileScenario(
options: ProfilingOptions,
runScenario: (page: Page, takeMeasurements: () => Promise<void>) => Promise<void>
) {
const browser = await puppeteer.launch({
defaultViewport: { width: 1366, height: 768 },
// Twitter detects headless browsing and refuses to load
headless: false,
})
let result: ProfilingResults
try {
const page = await browser.newPage()
await page.setExtraHTTPHeaders({
'Accept-Language': 'en-US',
})
await setupSDK(page, options)
const { stopProfiling, takeMeasurements } = await startProfiling(options, page)
await runScenario(page, takeMeasurements)
result = await stopProfiling()
} finally {
await browser.close()
}
return result
BenoitZugmeyer marked this conversation as resolved.
Show resolved Hide resolved
}

async function runWikipediaScenario(page: Page, takeMeasurements: () => Promise<void>) {
await page.goto('https://en.wikipedia.org/wiki/Event_monitoring')
await takeMeasurements()
BenoitZugmeyer marked this conversation as resolved.
Show resolved Hide resolved

await page.goto('https://en.wikipedia.org/wiki/Datadog')
await takeMeasurements()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure if it would be statistically better to run takeMeasurements periodically (like every 100 ms or so) instead of triggering it manually during the scenario. WDYT?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it would matter too much. I like controling when to take measurements, when the page is idle after doing something. If we didn't:

  • measurements could be taken while the page is loading, when the SDK is not yet loaded, yielding not applicable results for our use-case
  • measurements could be taken while some JS is running, and may impact the CPU profiling.


await page.type('[type="search"]', 'median', {
// large delay to trigger the autocomplete menu at each key press
delay: 400,
})
await Promise.all([page.waitForNavigation(), page.keyboard.press('Enter')])
await takeMeasurements()

await page.goto('https://en.wikipedia.org/wiki/Ubuntu')
await takeMeasurements()

await page.goto('about:blank')
BenoitZugmeyer marked this conversation as resolved.
Show resolved Hide resolved
}

async function runTwitterScenario(page: Page, takeMeasurements: () => Promise<void>) {
const { waitForNetworkIdle } = trackNetwork(page)
await page.goto('https://twitter.com/explore')
await waitForNetworkIdle()

// Even if the network is idle, sometimes links take a bit longer to render
await page.waitForSelector('[data-testid="trend"]')
await takeMeasurements()
await page.click('[data-testid="trend"]')
await waitForNetworkIdle()
await takeMeasurements()

// Click on all tabs
const tabs = await page.$$('[role="tab"]')
for (const tab of tabs) {
await tab.click()
await waitForNetworkIdle()
await takeMeasurements()
}

await page.click('[aria-label="Settings"]')
await waitForNetworkIdle()
await takeMeasurements()

// Scroll to the bottom of the page, because some checkboxes may be hidden below fixed banners
await page.evaluate(`scrollTo(0, 100000)`)

// Click on all checkboxes except the first one
const checkboxes = await page.$$('input[type="checkbox"]')
for (const checkbox of checkboxes.slice(1)) {
await checkbox.click()
await waitForNetworkIdle()
await takeMeasurements()
}

await page.goto('about:blank')
}

async function setupSDK(page: Page, options: ProfilingOptions) {
await page.setBypassCSP(true)
await page.evaluateOnNewDocument(`
if (location.href !== 'about:blank') {
import(${JSON.stringify(options.bundleUrl)})
.then(() => {
window.DD_RUM.init({
clientToken: 'xxx',
applicationId: 'xxx',
site: 'datadoghq.com',
trackInteractions: true,
proxyHost: ${JSON.stringify(options.proxyHost)}
})
})
}
`)
}
157 changes: 157 additions & 0 deletions performances/src/profiling.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { CDPSession, Page, Protocol } from 'puppeteer'
import { ProfilingResults, ProfilingOptions } from './types'

export async function startProfiling(options: ProfilingOptions, page: Page) {
const client = await page.target().createCDPSession()
const stopCPUProfiling = await startCPUProfiling(options, client)
const { stopMemoryProfiling, takeMemoryMeasurements } = await startMemoryProfiling(options, client)
const stopNetworkProfiling = await startNetworkProfiling(options, client)

return {
takeMeasurements: takeMemoryMeasurements,
stopProfiling: async (): Promise<ProfilingResults> => ({
memory: await stopMemoryProfiling(),
cpu: await stopCPUProfiling(),
...stopNetworkProfiling(),
}),
}
}

async function startCPUProfiling(options: ProfilingOptions, client: CDPSession) {
await client.send('Profiler.enable')
await client.send('Profiler.start')

return async () => {
const { profile } = await client.send('Profiler.stop')

const timeDeltaForNodeId = new Map<number, number>()

for (let index = 0; index < profile.samples!.length; index += 1) {
const nodeId = profile.samples![index]
timeDeltaForNodeId.set(nodeId, (timeDeltaForNodeId.get(nodeId) || 0) + profile.timeDeltas![index])
}

let totalConsumption = 0
let sdkConsumption = 0
for (const node of profile.nodes) {
const consumption = timeDeltaForNodeId.get(node.id) || 0
totalConsumption += consumption
if (isSdkUrl(options, node.callFrame.url)) {
sdkConsumption += consumption
}
}

return { total: totalConsumption, sdk: sdkConsumption }
}
}

async function startMemoryProfiling(options: ProfilingOptions, client: CDPSession) {
await client.send('HeapProfiler.enable')
await client.send('HeapProfiler.startSampling', {
// Set a low sampling interval to have more precise measurement
samplingInterval: 100,
})

const measurements: Array<{ sdkConsumption: number; totalConsumption: number }> = []

return {
takeMemoryMeasurements: async () => {
await client.send('HeapProfiler.collectGarbage')
const { profile } = await client.send('HeapProfiler.getSamplingProfile')

const sizeForNodeId = new Map<number, number>()
BenoitZugmeyer marked this conversation as resolved.
Show resolved Hide resolved

for (const sample of profile.samples) {
sizeForNodeId.set(sample.nodeId, (sizeForNodeId.get(sample.nodeId) || 0) + sample.size)
}

let totalConsumption = 0
let sdkConsumption = 0
for (const node of iterNodes(profile.head)) {
const consumption = sizeForNodeId.get(node.id) || 0
totalConsumption += consumption
if (isSdkUrl(options, node.callFrame.url)) {
sdkConsumption += consumption
}
}
measurements.push({ totalConsumption, sdkConsumption })
},

stopMemoryProfiling: async () => {
await client.send('HeapProfiler.stopSampling')

measurements.sort((a, b) => a.sdkConsumption - b.sdkConsumption)
const { sdkConsumption, totalConsumption } = measurements[Math.floor(measurements.length / 2)]
BenoitZugmeyer marked this conversation as resolved.
Show resolved Hide resolved
return { total: totalConsumption, sdk: sdkConsumption }
},
}
}

async function startNetworkProfiling(options: ProfilingOptions, client: CDPSession) {
await client.send('Network.enable')
let totalUpload = 0
let totalDownload = 0
let sdkUpload = 0
let sdkDownload = 0

const sdkRequestIds = new Set<string>()

const requestListener = ({ request, requestId }: Protocol.Network.RequestWillBeSentEvent) => {
const size = getRequestApproximateSize(request)
totalUpload += size
if (isSdkUrl(options, request.url)) {
sdkUpload += size
sdkRequestIds.add(requestId)
}
}

const loadingFinishedListener = ({ requestId, encodedDataLength }: Protocol.Network.LoadingFinishedEvent) => {
totalDownload += encodedDataLength
if (sdkRequestIds.has(requestId)) {
sdkDownload += encodedDataLength
}
}

client.on('Network.requestWillBeSent', requestListener)
client.on('Network.loadingFinished', loadingFinishedListener)
return () => {
client.off('Network.requestWillBeSent', requestListener)
client.off('Network.loadingFinishedListener', loadingFinishedListener)

return {
upload: { total: totalUpload, sdk: sdkUpload },
download: { total: totalDownload, sdk: sdkDownload },
}
}
}

function isSdkUrl(options: ProfilingOptions, url: string) {
return url === options.bundleUrl || url.startsWith(`https://${options.proxyHost}/`)
}

function* iterNodes<N extends { children?: N[] }>(root: N): Generator<N> {
yield root
if (root.children) {
for (const child of root.children) {
yield* iterNodes(child)
}
}
}

function getRequestApproximateSize(request: Protocol.Network.Request) {
let bodySize = 0
if (request.postDataEntries) {
for (const { bytes } of request.postDataEntries) {
if (bytes) {
bodySize += Buffer.from(bytes, 'base64').byteLength
}
}
}

let headerSize = 0
for (const [name, value] of Object.entries(request.headers)) {
headerSize += name.length + value.length
}

return bodySize + headerSize
}
Loading