Skip to content

Commit 0fb5c4c

Browse files
committed
Add continuous benchmark project
1 parent cdfbeb3 commit 0fb5c4c

21 files changed

+660
-2
lines changed

.gitlab-ci.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,21 @@ bump-chrome-version-scheduled-failure:
587587
dependencies:
588588
- bump-chrome-version-scheduled
589589

590+
########################################################################################################################
591+
# Performance benchmark
592+
########################################################################################################################
593+
594+
performance-benchmark:
595+
stage: task
596+
extends: .base-configuration
597+
only:
598+
variables:
599+
- $TARGET_TASK_NAME == "performance-benchmark-scheduled"
600+
script:
601+
- yarn
602+
- yarn playwright install
603+
- yarn test:performance
604+
590605
########################################################################################################################
591606
# Check expired telemetry
592607
########################################################################################################################

benchmark/configuration.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export const TEST_APP_URL = 'https://df1fyr56hap9d.cloudfront.net/'
2+
export const SDK_BUNDLE_URL = 'https://df1fyr56hap9d.cloudfront.net/datadog-rum.js'
3+
export const APPLICATION_ID = '9fa62a5b-8a7e-429d-8466-8b111a4d4693'
4+
export const CLIENT_TOKEN = 'pubab31fd1ab9d01d2b385a8aa3dd403b1d'
5+
export const DATADOG_SITE = 'datadoghq.com'

benchmark/createBenchmarkTest.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { test } from '@playwright/test'
2+
import type { Page } from '@playwright/test'
3+
import type { RumInitConfiguration } from '@datadog/browser-rum-core'
4+
import type { BrowserWindow, Metrics } from './profiling.type'
5+
import { startProfiling } from './profilers'
6+
import { reportToConsole } from './reporters/reportToConsole'
7+
import { reportToDatadog } from './reporters/reportToDatadog'
8+
import { isContinuousIntegration } from './environment'
9+
import { CLIENT_TOKEN, APPLICATION_ID, DATADOG_SITE, TEST_APP_URL, SDK_BUNDLE_URL } from './configuration'
10+
11+
const SCENARIO_CONFIGURATIONS = ['none', 'rum', 'rum_replay', 'rum_profiling', 'none_with_headers'] as const
12+
13+
type ScenarioConfiguration = (typeof SCENARIO_CONFIGURATIONS)[number]
14+
type TestRunner = (page: Page, takeMeasurements: () => Promise<void>, appUrl: string) => Promise<void> | void
15+
16+
export function createBenchmarkTest(scenarioName: string) {
17+
return {
18+
run(runner: TestRunner) {
19+
const metrics: Record<string, Metrics> = {}
20+
let sdkVersion: string
21+
22+
SCENARIO_CONFIGURATIONS.forEach((scenarioConfiguration) => {
23+
test(`${scenarioName} benchmark ${scenarioConfiguration}`, async ({ page }) => {
24+
await page.setExtraHTTPHeaders({ 'Accept-Language': 'en-US' })
25+
26+
const { stopProfiling, takeMeasurements } = await startProfiling(page)
27+
28+
if (shouldInjectSDK(scenarioConfiguration)) {
29+
await injectSDK(page, scenarioConfiguration, scenarioName)
30+
}
31+
32+
await runner(page, takeMeasurements, buildAppUrl(scenarioConfiguration))
33+
34+
await flushEvents(page)
35+
metrics[scenarioConfiguration] = await stopProfiling()
36+
if (!sdkVersion) {
37+
sdkVersion = await getSDKVersion(page)
38+
}
39+
})
40+
})
41+
42+
test.afterAll(async () => {
43+
reportToConsole(metrics, sdkVersion)
44+
if (isContinuousIntegration) {
45+
await reportToDatadog(metrics, scenarioName, sdkVersion)
46+
}
47+
})
48+
},
49+
}
50+
}
51+
52+
interface PageInitScriptParameters {
53+
configuration: Partial<RumInitConfiguration>
54+
sdkBundleUrl: string
55+
scenarioConfiguration: ScenarioConfiguration
56+
scenarioName: string
57+
}
58+
59+
async function injectSDK(page: Page, scenarioConfiguration: ScenarioConfiguration, scenarioName: string) {
60+
const configuration: Partial<RumInitConfiguration> = {
61+
clientToken: CLIENT_TOKEN,
62+
applicationId: APPLICATION_ID,
63+
site: DATADOG_SITE,
64+
profilingSampleRate: scenarioConfiguration === 'rum_profiling' ? 100 : 0,
65+
sessionReplaySampleRate: scenarioConfiguration === 'rum_replay' ? 100 : 0,
66+
}
67+
68+
await page.addInitScript(
69+
({ sdkBundleUrl, scenarioConfiguration, scenarioName, configuration }: PageInitScriptParameters) => {
70+
function loadSDK() {
71+
const browserWindow = window as BrowserWindow
72+
;(function (h: any, o: Document, u: string, n: string, d: string) {
73+
h = h[d] = h[d] || {
74+
q: [],
75+
onReady(c: () => void) {
76+
// eslint-disable-next-line
77+
h.q.push(c)
78+
},
79+
}
80+
const s = o.createElement(u) as HTMLScriptElement
81+
s.async = true
82+
s.src = n
83+
o.head.appendChild(s)
84+
})(window, document, 'script', sdkBundleUrl, 'DD_RUM')
85+
browserWindow.DD_RUM?.onReady(function () {
86+
browserWindow.DD_RUM!.setGlobalContextProperty('scenario', {
87+
configuration: scenarioConfiguration,
88+
name: scenarioName,
89+
})
90+
browserWindow.DD_RUM!.init(configuration as RumInitConfiguration)
91+
})
92+
}
93+
94+
// Init scripts run before DOM is ready; wait until "interactive" to append the SDK <script> tag.
95+
document.addEventListener('readystatechange', () => {
96+
if (document.readyState === 'interactive') {
97+
loadSDK()
98+
}
99+
})
100+
},
101+
{ configuration, sdkBundleUrl: SDK_BUNDLE_URL, scenarioConfiguration, scenarioName }
102+
)
103+
}
104+
105+
async function getSDKVersion(page: Page) {
106+
return await page.evaluate(() => (window as BrowserWindow).DD_RUM?.version || '')
107+
}
108+
109+
function shouldInjectSDK(scenarioConfiguration: ScenarioConfiguration): boolean {
110+
return !['none', 'none_with_headers'].includes(scenarioConfiguration)
111+
}
112+
113+
function buildAppUrl(scenarioConfiguration: ScenarioConfiguration): string {
114+
const url = new URL(TEST_APP_URL)
115+
if (scenarioConfiguration === 'rum_profiling' || scenarioConfiguration === 'none_with_headers') {
116+
url.searchParams.set('profiling', 'true')
117+
}
118+
return url.toString()
119+
}
120+
121+
/**
122+
* Flushes the events of the SDK and Google Web Vitals
123+
* by simulating a `visibilitychange` event with the state set to "hidden".
124+
*/
125+
async function flushEvents(page: Page) {
126+
await page.evaluate(() => {
127+
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' })
128+
const hiddenEvent = new Event('visibilitychange', { bubbles: true })
129+
;(hiddenEvent as unknown as { __ddIsTrusted: boolean }).__ddIsTrusted = true
130+
document.dispatchEvent(hiddenEvent)
131+
})
132+
}

benchmark/environment.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const isContinuousIntegration = Boolean(process.env.CI)

benchmark/playwright.config.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { defineConfig, devices } from '@playwright/test'
2+
import { isContinuousIntegration } from './environment'
3+
4+
const baseConfig = {
5+
testDir: './scenarios',
6+
testMatch: '**/*.scenario.ts',
7+
tsconfig: './tsconfig.json',
8+
fullyParallel: true,
9+
retries: 0,
10+
workers: 1,
11+
timeout: 90000, // 90 seconds to allow time for profile collection
12+
projects: [
13+
{
14+
name: 'chromium',
15+
use: {
16+
...devices['Desktop Chrome'],
17+
},
18+
},
19+
],
20+
}
21+
22+
// eslint-disable-next-line import/no-default-export
23+
export default defineConfig({
24+
...baseConfig,
25+
...(isContinuousIntegration && {
26+
repeatEach: 15,
27+
workers: 4,
28+
}),
29+
})

benchmark/profilers/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export * from './startProfiling'
2+
export * from './startMemoryProfiling'
3+
export * from './startCpuProfiling'
4+
export * from './startWebVitalsProfiling'
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { CDPSession } from '@playwright/test'
2+
3+
export async function startCPUProfiling(client: CDPSession) {
4+
await client.send('Profiler.enable')
5+
await client.send('Profiler.start')
6+
7+
let totalConsumption = 0
8+
9+
async function stopAndAddProfile() {
10+
const { profile } = await client.send('Profiler.stop')
11+
12+
const timeDeltaForNodeId = new Map<number, number>()
13+
14+
for (let index = 0; index < profile.samples!.length; index += 1) {
15+
const nodeId = profile.samples![index]
16+
timeDeltaForNodeId.set(nodeId, (timeDeltaForNodeId.get(nodeId) || 0) + profile.timeDeltas![index])
17+
}
18+
19+
for (const node of profile.nodes) {
20+
const consumption = timeDeltaForNodeId.get(node.id) || 0
21+
totalConsumption += consumption
22+
}
23+
}
24+
25+
return {
26+
takeCPUMeasurements: async () => {
27+
// We need to restart profiling at each "measurement" because the running profile gets reset
28+
// on each navigation.
29+
await stopAndAddProfile()
30+
await client.send('Profiler.start')
31+
},
32+
stopCPUProfiling: async () => {
33+
await stopAndAddProfile()
34+
return totalConsumption
35+
},
36+
}
37+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { CDPSession } from '@playwright/test'
2+
3+
export async function startMemoryProfiling(client: CDPSession) {
4+
await client.send('HeapProfiler.enable')
5+
await client.send('HeapProfiler.startSampling', {
6+
// Set a low sampling interval to have more precise measurement
7+
samplingInterval: 100,
8+
})
9+
10+
const measurements: Array<{ totalConsumption: number }> = []
11+
12+
return {
13+
takeMemoryMeasurements: async () => {
14+
await client.send('HeapProfiler.collectGarbage')
15+
const { profile } = await client.send('HeapProfiler.getSamplingProfile')
16+
17+
const sizeForNodeId = new Map<number, number>()
18+
19+
for (const sample of profile.samples) {
20+
sizeForNodeId.set(sample.nodeId, (sizeForNodeId.get(sample.nodeId) || 0) + sample.size)
21+
}
22+
23+
let totalConsumption = 0
24+
for (const node of iterNodes(profile.head)) {
25+
const consumption = sizeForNodeId.get(node.id) || 0
26+
totalConsumption += consumption
27+
}
28+
measurements.push({ totalConsumption })
29+
},
30+
31+
stopMemoryProfiling: async () => {
32+
await client.send('HeapProfiler.stopSampling')
33+
34+
measurements.sort((a, b) => a.totalConsumption - b.totalConsumption)
35+
const { totalConsumption } = measurements[Math.floor(measurements.length / 2)]
36+
return totalConsumption
37+
},
38+
}
39+
}
40+
41+
function* iterNodes<N extends { children?: N[] }>(root: N): Generator<N> {
42+
yield root
43+
if (root.children) {
44+
for (const child of root.children) {
45+
yield* iterNodes(child)
46+
}
47+
}
48+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { Page } from '@playwright/test'
2+
3+
export function startNetworkProfiling(page: Page) {
4+
let uploadBytes = 0
5+
let downloadBytes = 0
6+
7+
page.on('requestfinished', async (request) => {
8+
const sizes = await request.sizes()
9+
downloadBytes += sizes.responseBodySize + sizes.responseHeadersSize
10+
uploadBytes += sizes.requestBodySize + sizes.requestHeadersSize
11+
})
12+
13+
return {
14+
stopNetworkProfiling: () => ({
15+
upload: uploadBytes,
16+
download: downloadBytes,
17+
}),
18+
}
19+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { Page } from '@playwright/test'
2+
import type { Metrics } from '../profiling.type'
3+
import { startCPUProfiling } from './startCpuProfiling'
4+
import { startMemoryProfiling } from './startMemoryProfiling'
5+
import { startWebVitalsProfiling } from './startWebVitalsProfiling'
6+
import { startNetworkProfiling } from './startNetworkProfiling'
7+
8+
export async function startProfiling(page: Page) {
9+
const context = page.context()
10+
const cdpSession = await context.newCDPSession(page)
11+
12+
const { stopCPUProfiling, takeCPUMeasurements } = await startCPUProfiling(cdpSession)
13+
const { stopMemoryProfiling, takeMemoryMeasurements } = await startMemoryProfiling(cdpSession)
14+
const { stopNetworkProfiling } = startNetworkProfiling(page)
15+
const { stopWebVitalsProfiling } = await startWebVitalsProfiling(page)
16+
17+
return {
18+
takeMeasurements: async () => {
19+
await takeCPUMeasurements()
20+
await takeMemoryMeasurements()
21+
},
22+
stopProfiling: async (): Promise<Metrics> => ({
23+
memory: await stopMemoryProfiling(),
24+
cpu: await stopCPUProfiling(),
25+
...stopNetworkProfiling(),
26+
...(await stopWebVitalsProfiling()),
27+
}),
28+
}
29+
}

0 commit comments

Comments
 (0)