Repository: elastic/synthetics (ts)

Issue:
Sensible defaults for network throttling
Although the more rounded solution for network throttling is defined in https://github.com/elastic/synthetics/issues/210, this is to at least provide sensible defaults for all monitors.

As a Synthetic user
I want all monitors to run with a sensible network throttling configuration
So that they don’t run at line speed and result in variable performance metrics based on how busy the network is

ACs:
* Determine a reasonable default network speed (upload speed, download speed and latency)
* Apply these to all monitors that run in Synthetics (inline, suites, `heartbeat.yml`, Fleet Integration)
* It is possible to override these defaults for specific monitors (e.g. via the script as shown in https://github.com/elastic/synthetics/issues/210#issuecomment-790502321)

Heartbeat issue here: https://github.com/elastic/beats/issues/27952

Pull request description:
feat: Add network conditions cli args with sensible defaults
Adds network conditions cli args with sensible defaults
* fix #284 
* `--upload-throughput`: default 5Mbps
* `--download-throughput`: default 3Mbps
* `--latency`: default 20ms
* `--offline`: default `false`
* `--no-throttling`: disables all throttling, including sensible defaults :

Patch:
diff --git a/src/cli.ts b/src/cli.ts
index e162057..cde54ab 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -37,6 +37,7 @@ import {
   isDepInstalled,
   isDirectory,
   totalist,
+  parseNetworkConditions,
 } from './helpers';
 import { run } from './';
 import { readConfig } from './config';
@@ -184,8 +185,10 @@ async function prepareSuites(inputs: string[]) {
     chromiumSandbox: options.sandbox,
     ignoreHTTPSErrors: options.ignoreHttpsErrors,
   });
+
   const results = await run({
     params: Object.freeze(params),
+    networkConditions: options.throttling ? parseNetworkConditions(options.throttling as string) : undefined,
     environment,
     playwrightOptions,
     ...options,
diff --git a/src/common_types.ts b/src/common_types.ts
index 072441d..42587ae 100644
--- a/src/common_types.ts
+++ b/src/common_types.ts
@@ -37,6 +37,12 @@ import { reporters } from './reporters';
 
 export type VoidCallback = () => void;
 export type Params = Record<string, any>;
+export type NetworkConditions = {
+  offline: boolean;
+  downloadThroughput: number;
+	uploadThroughput: number;
+	latency: number;
+};
 export type HooksArgs = {
   env: string;
   params: Params;
@@ -157,6 +163,7 @@ export type CliArgs = {
   debug?: boolean;
   ignoreHttpsErrors?: boolean;
   params?: Params;
+  throttling?: boolean | string;
   /**
    * @deprecated use params instead
    */
diff --git a/src/core/gatherer.ts b/src/core/gatherer.ts
index 732d605..dfec13f 100644
--- a/src/core/gatherer.ts
+++ b/src/core/gatherer.ts
@@ -23,7 +23,7 @@
  *
  */
 
-import { chromium, ChromiumBrowser } from 'playwright-chromium';
+import { chromium, ChromiumBrowser, BrowserContext } from 'playwright-chromium';
 import { PluginManager } from '../plugins';
 import { RunOptions } from './runner';
 import { log } from './logger';
@@ -37,7 +37,12 @@ export class Gatherer {
   static browser: ChromiumBrowser;
 
   static async setupDriver(options: RunOptions): Promise<Driver> {
-    const { wsEndpoint, playwrightOptions } = options;
+    const { 
+      wsEndpoint,
+      playwrightOptions,
+      networkConditions
+    } = options;
+    
     if (Gatherer.browser == null) {
       if (wsEndpoint) {
         log(`Gatherer: connecting to WS endpoint: ${wsEndpoint}`);
@@ -50,6 +55,8 @@ export class Gatherer {
       ...playwrightOptions,
       userAgent: await Gatherer.getUserAgent(),
     });
+    await Gatherer.setNetworkConditions(context, networkConditions);
+
     const page = await context.newPage();
     const client = await context.newCDPSession(page);
     return { browser: Gatherer.browser, context, page, client };
@@ -61,6 +68,16 @@ export class Gatherer {
     return userAgent + ' Elastic/Synthetics';
   }
 
+  static async setNetworkConditions(context: BrowserContext, networkConditions: RunOptions['networkConditions']) {
+    if (networkConditions) {
+      context.on('page', async (page) => {
+        const context = page.context();
+        const client = await context.newCDPSession(page);
+        await client.send('Network.emulateNetworkConditions', networkConditions);
+      });
+    }
+  }
+
   /**
    * Starts recording all events related to the v8 devtools protocol
    * https://chromedevtools.github.io/devtools-protocol/v8/
diff --git a/src/core/runner.ts b/src/core/runner.ts
index 7dd3dca..08b53b7 100644
--- a/src/core/runner.ts
+++ b/src/core/runner.ts
@@ -42,6 +42,7 @@ import {
   StatusValue,
   HooksCallback,
   Params,
+  NetworkConditions,
   PluginOutput,
   CliArgs,
   HooksArgs,
@@ -64,9 +65,11 @@ export type RunOptions = Omit<
   | 'capability'
   | 'sandbox'
   | 'headless'
+  | 'throttling'
 > & {
   environment?: string;
   playwrightOptions?: PlaywrightOptions;
+  networkConditions?: NetworkConditions;
   reporter?: CliArgs['reporter'] | Reporter;
 };
 
@@ -104,7 +107,10 @@ type HookType = 'beforeAll' | 'afterAll';
 export type SuiteHooks = Record<HookType, Array<HooksCallback>>;
 
 interface Events {
-  start: { numJourneys: number };
+  start: { 
+    numJourneys: number, 
+    networkConditions?: NetworkConditions;
+  };
   'journey:register': {
     journey: Journey;
   };
@@ -419,7 +425,7 @@ export default class Runner extends EventEmitter {
   }
 
   async init(options: RunOptions) {
-    const { reporter, outfd } = options;
+    const { reporter, outfd, networkConditions } = options;
     /**
      * Set up the corresponding reporter and fallback
      */
@@ -428,7 +434,7 @@ export default class Runner extends EventEmitter {
         ? reporter
         : reporters[reporter] || reporters['default'];
     new Reporter(this, { fd: outfd });
-    this.emit('start', { numJourneys: this.journeys.length });
+    this.emit('start', { numJourneys: this.journeys.length, networkConditions });
     /**
      * Set up the directory for caching screenshots
      */
diff --git a/src/helpers.ts b/src/helpers.ts
index d5f77ae..8b622b5 100644
--- a/src/helpers.ts
+++ b/src/helpers.ts
@@ -29,7 +29,7 @@ import { resolve, join, dirname } from 'path';
 import fs from 'fs';
 import { promisify } from 'util';
 import { performance } from 'perf_hooks';
-import { HooksArgs, HooksCallback } from './common_types';
+import { HooksArgs, HooksCallback, NetworkConditions } from './common_types';
 
 const lstatAsync = promisify(fs.lstat);
 const readdirAsync = promisify(fs.readdir);
@@ -257,3 +257,57 @@ export const CACHE_PATH = join(cwd, '.synthetics', process.pid.toString());
 export function getDurationInUs(duration: number) {
   return Math.trunc(duration * 1e6);
 }
+
+export function megabytesToBytes(megabytes: number) {
+  return megabytes * 1024 * 1024;
+}
+
+export function bytesToMegabytes(bytes: number) {
+  return bytes / 1024 / 1024;
+}
+
+export const DEFAULT_NETWORK_CONDITIONS: NetworkConditions = {
+  downloadThroughput: megabytesToBytes(5), // megabytes/second
+  uploadThroughput: megabytesToBytes(3), // megabytes/second
+  latency: 20, // milliseconds,
+  offline: false,
+}
+
+export function formatNetworkConditionsArgs(networkConditions: NetworkConditions) {
+  const d = bytesToMegabytes(networkConditions.downloadThroughput);
+  const u = bytesToMegabytes(networkConditions.uploadThroughput);
+  const l = networkConditions.latency;
+  return `${d}d/${u}u/${l}l`;
+}
+
+export const DEFAULT_NETWORK_CONDITIONS_ARG = formatNetworkConditionsArgs(DEFAULT_NETWORK_CONDITIONS);
+
+export function parseNetworkConditions(args: string): NetworkConditions {
+  const uploadToken = 'u';
+  const downloadToken = 'd';
+  const latencyToken = 'l';
+  const networkConditions = {
+    ...DEFAULT_NETWORK_CONDITIONS,
+  };
+
+  const conditions = args.split('/');
+
+  conditions.forEach(condition => {
+    const value = condition.slice(0, condition.length - 1);
+    const token = condition.slice(-1);
+
+    switch (token) {
+      case uploadToken:
+        networkConditions.uploadThroughput = megabytesToBytes(Number(value));
+        break;
+      case downloadToken:
+        networkConditions.downloadThroughput = megabytesToBytes(Number(value));
+        break;
+      case latencyToken: 
+        networkConditions.latency = Number(value);
+        break;
+    }
+  });
+
+  return networkConditions;
+}
diff --git a/src/parse_args.ts b/src/parse_args.ts
index 83618be..821c84f 100644
--- a/src/parse_args.ts
+++ b/src/parse_args.ts
@@ -26,6 +26,7 @@
 import { program, Option } from 'commander';
 import { CliArgs } from './common_types';
 import { reporters } from './reporters';
+import { DEFAULT_NETWORK_CONDITIONS_ARG } from './helpers';
 
 /* eslint-disable-next-line @typescript-eslint/no-var-requires */
 const { name, version } = require('../package.json');
@@ -104,6 +105,11 @@ program
     '--quiet-exit-code',
     'always return 0 as an exit code status, regardless of test pass / fail. Only return > 0 exit codes on internal errors where the suite could not be run'
   )
+  .addOption(
+    new Option('--throttling <d/u/l>', 'List of options to throttle network conditions for download throughput (d) in megabytes/second, upload throughput (u) in megabytes/second and latency (l) in milliseconds.')
+      .default(DEFAULT_NETWORK_CONDITIONS_ARG)
+  )
+  .option('--no-throttling', 'Turns off default network throttling.')
   .version(version)
   .description('Run synthetic tests');
 
diff --git a/src/reporters/json.ts b/src/reporters/json.ts
index 944c656..2a9a754 100644
--- a/src/reporters/json.ts
+++ b/src/reporters/json.ts
@@ -40,6 +40,7 @@ import { Journey, Step } from '../dsl';
 import snakeCaseKeys from 'snakecase-keys';
 import {
   NetworkInfo,
+  NetworkConditions,
   TraceOutput,
   StatusValue,
   PerfMetrics,
@@ -76,6 +77,7 @@ type Payload = {
   type?: OutputType;
   text?: string;
   index?: number;
+  network_conditions?: NetworkConditions
 };
 
 type OutputFields = {
@@ -352,12 +354,15 @@ export default class JSONReporter extends BaseReporter {
      * report the number of journeys that exists on a suite which
      * could be used for better sharding
      */
-    this.runner.on('start', ({ numJourneys }) => {
+    this.runner.on('start', ({ numJourneys, networkConditions }) => {
       this.writeJSON({
         type: 'synthetics/metadata',
         root_fields: {
           num_journeys: numJourneys,
         },
+        payload: networkConditions ? {
+          network_conditions: networkConditions,
+        } : undefined
       });
     });
 


Test patch:
diff --git a/__tests__/cli.test.ts b/__tests__/cli.test.ts
index 3261972..7d3eba6 100644
--- a/__tests__/cli.test.ts
+++ b/__tests__/cli.test.ts
@@ -26,6 +26,7 @@
 import { ChildProcess, spawn } from 'child_process';
 import { join } from 'path';
 import { Server } from './utils/server';
+import { megabytesToBytes, DEFAULT_NETWORK_CONDITIONS } from '../src/helpers';
 
 describe('CLI', () => {
   let server: Server;
@@ -350,6 +351,99 @@ describe('CLI', () => {
       );
     });
   });
+
+  describe('throttling', () => {
+    let cliArgs: Array<string>;
+
+    beforeAll(async () => {
+      cliArgs = [
+        join(FIXTURES_DIR, 'example.journey.ts'),
+        '--params',
+        JSON.stringify(serverParams),
+        '--reporter',
+        'json',
+      ];
+    });
+
+    it('applies --no-throttling', async () => {
+      const cli = new CLIMock()
+        .args(cliArgs.concat(['--no-throttling']))
+        .run();
+      await cli.waitFor('synthetics/metadata');
+      const journeyStartOutput = JSON.parse(cli.output());
+      expect(await cli.exitCode).toBe(0);
+      expect(journeyStartOutput.payload).toBeUndefined();
+    });
+
+    it('applies default throttling', async () => {
+      const cli = new CLIMock()
+        .args(cliArgs)
+        .run();
+      await cli.waitFor('synthetics/metadata');
+      const journeyStartOutput = JSON.parse(cli.output());
+      expect(await cli.exitCode).toBe(0);
+      expect(journeyStartOutput.payload).toHaveProperty('network_conditions', DEFAULT_NETWORK_CONDITIONS);
+    });
+
+    it('applies custom throttling', async () => {
+      const downloadThroughput = megabytesToBytes(3);
+      const uploadThroughput = megabytesToBytes(1);
+      const latency = 30;
+      const cli = new CLIMock()
+        .args(cliArgs.concat([
+          '--throttling',
+          '3d/1u/30l',
+        ]))
+        .run();
+      await cli.waitFor('synthetics/metadata');
+      const journeyStartOutput = JSON.parse(cli.output());
+      expect(await cli.exitCode).toBe(0);
+      expect(journeyStartOutput.payload).toHaveProperty('network_conditions', {
+        downloadThroughput, 
+        latency, 
+        offline: false, 
+        uploadThroughput
+      });
+    });
+
+    it('applies custom throttling order agnostic', async () => {
+      const downloadThroughput = megabytesToBytes(3);
+      const uploadThroughput = megabytesToBytes(1);
+      const latency = 30;
+      const cli = new CLIMock()
+        .args(cliArgs.concat([
+          '--throttling',
+          '1u/30l/3d',
+        ]))
+        .run();
+      await cli.waitFor('synthetics/metadata');
+      const journeyStartOutput = JSON.parse(cli.output());
+      expect(await cli.exitCode).toBe(0);
+      expect(journeyStartOutput.payload).toHaveProperty('network_conditions', {
+        ...DEFAULT_NETWORK_CONDITIONS,
+        downloadThroughput, 
+        latency, 
+        uploadThroughput
+      });
+    });
+
+    it('uses default throttling when specific params are not provided', async () => {
+      const downloadThroughput = megabytesToBytes(2);
+      const cli = new CLIMock()
+        .args(cliArgs.concat([
+          '--throttling',
+          '2d',
+        ]))
+        .run();
+      await cli.waitFor('synthetics/metadata');
+      const journeyStartOutput = JSON.parse(cli.output());
+      expect(await cli.exitCode).toBe(0);
+      expect(journeyStartOutput.payload).toHaveProperty('network_conditions', {
+        ...DEFAULT_NETWORK_CONDITIONS,
+        downloadThroughput,
+      });
+    });
+  });
 });
 
 class CLIMock {
@@ -404,9 +498,9 @@ class CLIMock {
 
     this.exitCode = new Promise(res => {
       // Uncomment to debug stderr
-      //this.process.stderr.on('data', data => {
+      // this.process.stderr.on('data', data => {
       //  console.log('climock.stderr:  ', data.toString());
-      //});
+      // });
       this.process.on('exit', code => res(code));
     });
 
diff --git a/__tests__/core/gatherer.test.ts b/__tests__/core/gatherer.test.ts
index 6b3ac70..e9669f6 100644
--- a/__tests__/core/gatherer.test.ts
+++ b/__tests__/core/gatherer.test.ts
@@ -116,4 +116,51 @@ describe('Gatherer', () => {
       await Gatherer.stop();
     });
   });
+
+  describe('Network emulation', () => {
+    const networkConditions = { 
+      downloadThroughput: 1024 * 1024 * 0.05, // slow 3g speeds, 0.4 Mbits
+      uploadThroughput: 1024 * 1024 * 0.02,
+      latency: 20,
+      offline: false,
+    }
+    it('applies network throttling', async () => {
+      const driver = await Gatherer.setupDriver({ 
+        wsEndpoint, 
+        networkConditions,
+      });
+      // @ts-ignore
+      // Experimental browser API https://developer.mozilla.org/en-US/docs/Web/API/NetworkInformation/downlink
+      const downlink = await driver.page.evaluate(() => navigator.connection.downlink);
+
+      expect(0.5 > downlink && downlink > 0.3).toBe(true);
+      await Gatherer.dispose(driver);
+      await Gatherer.stop();
+    });
+
+    it('works with popup window', async () => {
+      const driver = await Gatherer.setupDriver({
+        wsEndpoint,
+        networkConditions,
+      });
+      const { page, context } = driver;
+      await page.goto(server.TEST_PAGE);
+      await page.setContent(
+        '<a target=_blank rel=noopener href="/popup.html">popup</a>'
+      );
+      const [page1] = await Promise.all([
+        context.waitForEvent('page'),
+        page.click('a'),
+      ]);
+      await page1.waitForLoadState();
+
+      // @ts-ignore
+      // Experimental browser API https://developer.mozilla.org/en-US/docs/Web/API/NetworkInformation/downlink
+      const downlink = await page1.evaluate(() => navigator.connection.downlink);
+
+      expect(0.5 > downlink && downlink > 0.3).toBe(true);
+      await Gatherer.dispose(driver);
+      await Gatherer.stop();
+    });
+  });
 });