Skip to content

Commit f8d98a3

Browse files
committed
Add benchmark tests
1 parent e082b85 commit f8d98a3

18 files changed

+446
-2
lines changed

.claude/commands/new-benchmark.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# CPU Benchmark using Chrome DevTools Protocol
2+
3+
Run a CPU usage benchmark test using the Chrome DevTools Protocol.
4+
5+
This command will:
6+
1. Launch a browser with CDP enabled
7+
2. Navigate to a test page with SDK loaded
8+
3. Profile CPU usage during user interactions
9+
4. Report detailed CPU metrics including:
10+
- Total CPU time
11+
- SDK-specific CPU time
12+
- Per-function CPU breakdown
13+
- Timeline visualization
14+
15+
The benchmark uses the Profiler domain of CDP to capture precise CPU measurements.

benchmark/helpers.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { test } from '@playwright/test'
2+
import type { Page } from '@playwright/test'
3+
import type { BrowserWindow, Metrics } from 'profiling.type'
4+
import { startProfiling } from './profilers'
5+
import { reportMetricsToConsole } from './reporters/console'
6+
7+
const bundleUrl = 'https://www.datadoghq-browser-agent.com/us1/v6/datadog-rum.js'
8+
9+
export const scenarioConfigurations = ['none', 'rum', 'rum_replay', 'rum_profiling'] as const
10+
11+
type ScenarioConfiguration = (typeof scenarioConfigurations)[number]
12+
13+
async function injectSDK(page: Page, scenarioConfiguration: ScenarioConfiguration) {
14+
await page.addInitScript(`
15+
function loadSDK() {
16+
(function(h,o,u,n,d) {
17+
h=h[d]=h[d]||{q:[],onReady:function(c){h.q.push(c)}}
18+
d=o.createElement(u);d.async=1;d.src=n
19+
n=o.getElementsByTagName(u)[0];n.parentNode.insertBefore(d,n)
20+
})(window,document,'script','${bundleUrl}','DD_RUM')
21+
22+
window.DD_RUM.onReady(function() {
23+
window.DD_RUM.init({
24+
clientToken: 'xxx',
25+
applicationId: 'xxx',
26+
site: 'datadoghq.com',
27+
profilingSampleRate: ${scenarioConfiguration === 'rum_replay' ? 100 : 0},
28+
sessionReplaySampleRate: ${scenarioConfiguration === 'rum_profiling' ? 100 : 0},
29+
})
30+
})
31+
}
32+
33+
document.addEventListener("readystatechange", (event) => {
34+
if (document.readyState === "interactive") {
35+
loadSDK()
36+
}
37+
});
38+
`)
39+
}
40+
41+
type TestRunner = (page: Page, takeMeasurements: () => Promise<void>) => Promise<void> | void
42+
43+
async function stopSession(page: Page) {
44+
await page.evaluate(() => {
45+
;(window as BrowserWindow).DD_RUM?.stopSession()
46+
})
47+
}
48+
49+
export function createBenchmarkTest(scenarioName: string) {
50+
return {
51+
run(runner: TestRunner) {
52+
const metrics: Record<string, Metrics> = {}
53+
scenarioConfigurations.forEach((scenarioConfiguration) => {
54+
test(`${scenarioName} ${scenarioConfiguration}`, async ({ page }) => {
55+
await page.setExtraHTTPHeaders({ 'Accept-Language': 'en-US' })
56+
57+
const { stopProfiling, takeMeasurements } = await startProfiling(page)
58+
59+
if (scenarioConfiguration !== 'none') {
60+
await injectSDK(page, scenarioConfiguration)
61+
}
62+
63+
await runner(page, takeMeasurements)
64+
65+
if (scenarioConfiguration !== 'none') {
66+
await stopSession(page) // Flush events
67+
}
68+
69+
metrics[scenarioConfiguration] = await stopProfiling()
70+
await page.close()
71+
})
72+
})
73+
74+
test.afterAll(() => {
75+
reportMetricsToConsole(metrics)
76+
})
77+
},
78+
}
79+
}

benchmark/playwright.config.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { defineConfig, devices } from '@playwright/test'
2+
3+
// eslint-disable-next-line import/no-default-export
4+
export default defineConfig({
5+
testDir: './scenarios',
6+
testMatch: '**/*.scenario.ts',
7+
tsconfig: './tsconfig.json',
8+
fullyParallel: true,
9+
workers: 1,
10+
retries: 0,
11+
projects: [
12+
{
13+
name: 'chromium',
14+
use: {
15+
...devices['Desktop Chrome'],
16+
},
17+
},
18+
],
19+
})

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: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { CDPSession } from '@playwright/test'
2+
import type { Protocol } from 'playwright-core/types/protocol'
3+
4+
export async function startNetworkProfiling(client: CDPSession) {
5+
await client.send('Network.enable')
6+
let downloadBytes = 0
7+
let uploadBytes = 0
8+
9+
const requestListener = ({ request }: Protocol.Network.requestWillBeSentPayload) => {
10+
const postData = request.postData
11+
if (postData) {
12+
if (request.headers['accept-encoding']?.includes('deflate')) {
13+
uploadBytes += postData.length
14+
} else {
15+
uploadBytes += new TextEncoder().encode(postData).length
16+
}
17+
}
18+
}
19+
20+
const loadingFinishedListener = ({ encodedDataLength }: Protocol.Network.loadingFinishedPayload) => {
21+
downloadBytes += encodedDataLength
22+
}
23+
24+
client.on('Network.requestWillBeSent', requestListener)
25+
client.on('Network.loadingFinished', loadingFinishedListener)
26+
27+
return {
28+
stopNetworkProfiling: () => {
29+
client.off('Network.requestWillBeSent', requestListener)
30+
client.off('Network.loadingFinished', loadingFinishedListener)
31+
32+
return {
33+
upload: uploadBytes,
34+
download: downloadBytes,
35+
}
36+
},
37+
}
38+
}
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 } = await startNetworkProfiling(cdpSession)
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+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { Page } from '@playwright/test'
2+
import type { BrowserWindow, WebVitalsMetrics } from 'profiling.type'
3+
interface Metric {
4+
value: number
5+
}
6+
7+
type RecordMetric = (callback: (metric: Metric) => void, options?: { reportAllChanges: boolean }) => void
8+
9+
interface WebVitalsModule {
10+
onLCP: RecordMetric
11+
onCLS: RecordMetric
12+
onFCP: RecordMetric
13+
onTTFB: RecordMetric
14+
onINP: RecordMetric
15+
}
16+
17+
export async function startWebVitalsProfiling(page: Page) {
18+
await page.addInitScript(() => {
19+
import('https://unpkg.com/web-vitals@5?module' as string)
20+
.then(({ onLCP, onCLS, onFCP, onTTFB, onINP }: WebVitalsModule) => {
21+
const metrics: WebVitalsMetrics = {}
22+
;(window as BrowserWindow).__webVitalsMetrics__ = metrics
23+
const recordMetric = (name: keyof WebVitalsMetrics) => (metric: Metric) => (metrics[name] = metric.value)
24+
onINP(recordMetric('INP'), { reportAllChanges: true })
25+
onLCP(recordMetric('LCP'), { reportAllChanges: true })
26+
onCLS(recordMetric('CLS'), { reportAllChanges: true })
27+
onFCP(recordMetric('FCP'))
28+
onTTFB(recordMetric('TTFB'))
29+
})
30+
.catch((e) => console.error('web-vitals load failed:', e))
31+
})
32+
33+
return {
34+
stopWebVitalsProfiling: async (): Promise<WebVitalsMetrics> => {
35+
const metrics = await page.evaluate(() => (window as BrowserWindow).__webVitalsMetrics__ || {})
36+
return metrics
37+
},
38+
}
39+
}

benchmark/profiling.type.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { Page } from '@playwright/test'
2+
import type { RumPublicApi } from '@datadog/browser-rum-core'
3+
4+
export interface BrowserWindow extends Window {
5+
DD_RUM?: RumPublicApi
6+
__webVitalsMetrics__?: WebVitalsMetrics
7+
}
8+
9+
export interface Scenario {
10+
description: string
11+
run(this: void, page: Page, takeMeasurements: () => Promise<void>): Promise<void>
12+
}
13+
14+
export interface Metrics extends WebVitalsMetrics {
15+
memory: number
16+
cpu: number
17+
upload: number
18+
download: number
19+
}
20+
21+
export interface WebVitalsMetrics {
22+
LCP?: number
23+
CLS?: number
24+
FCP?: number
25+
TTFB?: number
26+
INP?: number
27+
}

0 commit comments

Comments
 (0)