Repository: elastic/synthetics
Pull request #493 (2022-04-16 00:59:21)

feat: introduce monitor DSL
+ Part of #470
+ Expose the new Monitor DSL that will be used alongside `journey, step` which can be used to configure the monitor settings/configuration before uploading via the `push` command.
+ Instead of exposing it through the callback arguments which we agreed on earlier, `monitor` API sits similar to `journey/step` level which improves the DX by allowing users to set the configuration across all journeys or via file level if we decide to change it in future. 
    - Allows for future extensibility to tune it based on file - depending on which file the `monitor.use` API is called, the journeys sits inside those files will get those defaults.
    - Extensible that allows the configuration to be exposed via `journey.use`. 
```ts
import { journey, step, monitor } from '@elastic/synthetics';

// Applied to all journeys that are pushed
monitor.use({
  locations: ['EU West'],
  throttling: {
    download: 200,
  },
});

journey('check if title is present', ({ page, params }) => {
  // Applied only to this specific journey
  monitor.use({
    schedule: '15m',
    throttling: { latency: 100 },
  });
  step('launch app', async () => {
    await page.goto(params.url);
  });
});
```
+ Every option that is configurable for monitor is available via `Synthetics configuration file`, `CLI Flags` and `monitor.use` API and the order of preference is  API > CLI Flags > Synthetics config file. 
+ Throttling flag is backwards compatibile which means it should work with the previous format `<5d/3l/10u>` and also the newer JSON format.
+ Push commands gets new flags `schedule` and `locations` that are relevant for the monitors to be pushed. However `throttling` is used differently based on the context. 
     - While running, gets applied to Network throttling using Chrome devtools protocol.
     - While pushing, gets applied to the monitor configuration.

### Examples

Example to configure monitor settings which will be used for uploading to Kibana. 

#### Synthetics config file
```ts
// synthetics.config.ts
export default env => {
  monitors: {
    throttling: {
      download: 200,
      upload: 100,
      latency: 50,
    },
    schedule: '5m',
  },
}
```

### CLI Flags
```
npx @elastic/synthetics push examples/todos --schedule 5m --throttling '{"upload": 30}' --locations="US East"
```

### Monitor.use API
```
journey('Test, ({ }) => {
  monitor.use({
    schedule: '15m',
    throttling: { latency: 100 },
  });
  // steps
});
```


apmmachine: 
## :broken_heart: Build Failed


<!-- BUILD BADGES-->
> _the below badges are clickable and redirect to their specific view in the CI or DOCS_
[![Pipeline View](https://img.shields.io/badge/pipeline-pipeline%20-green)](https://apm-ci.elastic.co/blue/organizations/jenkins/apm-agent-rum%2Felastic-synthetics%2FPR-493/detail/PR-493/1//pipeline) [![Test View](https://img.shields.io/badge/test-test-green)](https://apm-ci.elastic.co/blue/organizations/jenkins/apm-agent-rum%2Felastic-synthetics%2FPR-493/detail/PR-493/1//tests) [![Changes](https://img.shields.io/badge/changes-changes-green)](https://apm-ci.elastic.co/blue/organizations/jenkins/apm-agent-rum%2Felastic-synthetics%2FPR-493/detail/PR-493/1//changes) [![Artifacts](https://img.shields.io/badge/artifacts-artifacts-yellow)](https://apm-ci.elastic.co/blue/organizations/jenkins/apm-agent-rum%2Felastic-synthetics%2FPR-493/detail/PR-493/1//artifacts) [![preview](https://img.shields.io/badge/docs-preview-yellowgreen)](http://synthetics_493.docs-preview.app.elstc.co/diff)  [![preview](https://img.shields.io/badge/elastic-observability-blue)](https://ci-stats.elastic.co/app/apm/services/apm-ci/transactions/view?rangeFrom=2022-04-16T00:49:38.057Z&rangeTo=2022-04-16T01:09:38.057Z&transactionName=apm-agent-rum/elastic-synthetics/PR-{number}&transactionType=job&latencyAggregationType=avg&traceId=c06232efdd60a80d7672726efa49b626&transactionId=ea6d08c4aa6dab03)

<!-- BUILD SUMMARY-->
<details><summary>Expand to view the summary</summary>
<p>

#### Build stats




* Start Time: 2022-04-16T00:59:38.057+0000


* Duration: 5 min 51 sec





</p>
</details>

<!-- TEST RESULTS IF ANY-->


<!-- STEPS ERRORS IF ANY -->












### Steps errors [![1](https://img.shields.io/badge/1%20-red)](https://apm-ci.elastic.co/blue/organizations/jenkins/apm-agent-rum%2Felastic-synthetics%2FPR-493/detail/PR-493/1//pipeline)
  <details><summary>Expand to view the steps failures</summary>
  <p>


  
  
  
  
##### `Checks linting`
<ul>
<li>Took 0 min 4 sec . View more details <a href="https://apm-ci.elastic.co//blue/rest/organizations/jenkins/pipelines/apm-agent-rum/pipelines/elastic-synthetics/pipelines/PR-493/runs/1/steps/184/log/?start=0">here</a></li>
<li>Description: <code>npm run-script lint</code></l1>
</ul>
  
  </p>
  </details>



## :robot: GitHub comments

To re-run your PR in the CI, just comment with:



- `/test` : Re-trigger the build.




<!--COMMENT_GENERATED_WITH_ID_comment.id-->
vigneshshanmugam: > In other words, some of the params are only relevant (used) in conjunction with push, whilst others may be relevant always?

That is correct Paul, `schedule` and `locations` are irrelevant for the runner and gets applied to the child command `push`. But the `throttling` command gets applied based on the context.
 - While running, gets applied to Network throttling using Chrome devtools protocol. 
 - While pushing, gets applied to the monitor configuration. 

Will update the description with this details as well. 👍🏽 
vigneshshanmugam: > Would you like to merge this now and not do any releases until we are ready for those changes to go into Heartbeat and Kibana?

The plan that I had in mind was to merge this and not do any releases till 8.2 is out and then any release post that would not affect the HB side unless a new Image is built. But after seeing your comments on how these values and mappings are getting used in Synthetics integration and Kibana. I have added a backwards compatible option for `--throttling` which should work with previous format as well. 



Patch:
diff --git a/src/cli.ts b/src/cli.ts
index 530015d..f8092da 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -28,10 +28,10 @@
 import { program, Option } from 'commander';
 import { CliArgs } from './common_types';
 import { reporters } from './reporters';
-import { DEFAULT_NETWORK_CONDITIONS_ARG } from './helpers';
-import { normalizeOptions } from './options';
+import { normalizeOptions, parseThrottling } from './options';
 import { loadTestFiles } from './loader';
 import { run } from './';
+import { runner } from './core';
 
 /* eslint-disable-next-line @typescript-eslint/no-var-requires */
 const { name, version } = require('../package.json');
@@ -105,9 +105,10 @@ program
     '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'
   )
   .option(
-    '--throttling <d/u/l>',
-    'List of options to throttle network conditions for download throughput (d) in megabits/second, upload throughput (u) in megabits/second and latency (l) in milliseconds.',
-    DEFAULT_NETWORK_CONDITIONS_ARG
+    '--throttling <config>',
+    'JSON object to throttle network conditions for download and upload throughput in megabits/second and latency in milliseconds. Ex: { "download": 10, "upload": 5, "latency": 200 }.',
+    parseThrottling,
+    {}
   )
   .option('--no-throttling', 'Turns off default network throttling.')
   .option(
@@ -139,12 +140,29 @@ program
   });
 
 program
-  .command('push')
+  .command('push [files...]')
   .description(
     'Push monitors to create new montors with Kibana monitor management UI'
   )
-  .action(() => {
-    throw new Error('TODO: Implement me');
+  .option(
+    '--schedule <time>',
+    "The default interval for the pushed monitors. Setting `10m`, for example, configures monitors which don't have a specified interval defined to run every 10 minutes."
+  )
+  .option(
+    '--locations <locations...>',
+    'The default list of locations from which your monitors will run.'
+  )
+  .action(async (files, cmdOpts) => {
+    try {
+      const cliArgs = { inline: false };
+      await loadTestFiles(cliArgs, files);
+      const options = normalizeOptions({ ...program.opts(), ...cmdOpts });
+      runner.buildMonitors(options);
+      // TODO: Push logic will be implemented in follow up PR's
+    } catch (e) {
+      console.error(e);
+      process.exit(1);
+    }
   });
 
 program.parse(process.argv);
diff --git a/src/common_types.ts b/src/common_types.ts
index 4d49b3b..26ae7a9 100644
--- a/src/common_types.ts
+++ b/src/common_types.ts
@@ -33,6 +33,7 @@ import {
 } from 'playwright-chromium';
 import { Step } from './dsl';
 import { BuiltInReporterName, ReporterInstance } from './reporters';
+import { MonitorConfig, SyntheticsLocations } from './dsl/monitor';
 
 export type VoidCallback = () => void;
 export type Location = {
@@ -188,6 +189,12 @@ export type PluginOutput = {
 
 export type ScreenshotOptions = 'on' | 'off' | 'only-on-failure';
 
+export type ThrottlingOptions = {
+  download?: number;
+  upload?: number;
+  latency?: number;
+};
+
 type BaseArgs = {
   params?: Params;
   screenshots?: ScreenshotOptions;
@@ -200,6 +207,9 @@ type BaseArgs = {
   ignoreHttpsErrors?: boolean;
   playwrightOptions?: PlaywrightOptions;
   quietExitCode?: boolean;
+  throttling?: ThrottlingOptions;
+  schedule?: string;
+  locations?: SyntheticsLocations[];
 };
 
 export type CliArgs = BaseArgs & {
@@ -213,7 +223,7 @@ export type CliArgs = BaseArgs & {
   richEvents?: boolean;
   capability?: Array<string>;
   ignoreHttpsErrors?: boolean;
-  throttling?: boolean | string;
+  throttling?: boolean | ThrottlingOptions;
 };
 
 export type RunOptions = BaseArgs & {
@@ -232,6 +242,7 @@ export type PlaywrightOptions = LaunchOptions & BrowserContextOptions;
 export type SyntheticsConfig = {
   params?: Params;
   playwrightOptions?: PlaywrightOptions;
+  monitor?: MonitorConfig;
 };
 
 /** Runner Payload types */
diff --git a/src/core/gatherer.ts b/src/core/gatherer.ts
index f745728..6d9639a 100644
--- a/src/core/gatherer.ts
+++ b/src/core/gatherer.ts
@@ -26,7 +26,7 @@
 import { chromium, ChromiumBrowser, BrowserContext } from 'playwright-chromium';
 import { PluginManager } from '../plugins';
 import { log } from './logger';
-import { Driver, RunOptions } from '../common_types';
+import { Driver, NetworkConditions, RunOptions } from '../common_types';
 
 /**
  * Purpose of the Gatherer is to set up the necessary browser driver
@@ -75,7 +75,7 @@ export class Gatherer {
 
   static setNetworkConditions(
     context: BrowserContext,
-    networkConditions: RunOptions['networkConditions']
+    networkConditions: NetworkConditions
   ) {
     if (networkConditions) {
       context.on('page', page => {
diff --git a/src/core/index.ts b/src/core/index.ts
index ce89b4f..ce806e6 100644
--- a/src/core/index.ts
+++ b/src/core/index.ts
@@ -28,6 +28,7 @@ import Runner from './runner';
 import { VoidCallback, HooksCallback, Location } from '../common_types';
 import { wrapFnWithLocation } from '../helpers';
 import { log } from './logger';
+import { MonitorConfig } from '../dsl/monitor';
 
 /**
  * Use a gloabl Runner which would be accessed by the runtime and
@@ -63,6 +64,20 @@ export const step = wrapFnWithLocation(
   }
 );
 
+export const monitor = {
+  use: wrapFnWithLocation((location: Location, config: MonitorConfig) => {
+    /**
+     * If the context is inside journey, then set it to journey context
+     * otherwise set to the global monitor which will be used for all journeys
+     */
+    if (runner.currentJourney) {
+      runner.currentJourney.updateMonitor(config);
+    } else {
+      runner.updateMonitor(config);
+    }
+  }),
+};
+
 export const beforeAll = (callback: HooksCallback) => {
   runner.addHook('beforeAll', callback);
 };
diff --git a/src/core/runner.ts b/src/core/runner.ts
index 89f4a99..82006f1 100644
--- a/src/core/runner.ts
+++ b/src/core/runner.ts
@@ -51,6 +51,7 @@ import { PluginManager } from '../plugins';
 import { PerformanceManager } from '../plugins';
 import { Gatherer } from './gatherer';
 import { log } from './logger';
+import { Monitor, MonitorConfig } from '../dsl/monitor';
 
 type HookType = 'beforeAll' | 'afterAll';
 export type SuiteHooks = Record<HookType, Array<HooksCallback>>;
@@ -71,6 +72,7 @@ export default class Runner {
   journeys: Journey[] = [];
   hooks: SuiteHooks = { beforeAll: [], afterAll: [] };
   hookError: Error | undefined;
+  monitor?: Monitor;
   static screenshotPath = join(CACHE_PATH, 'screenshots');
 
   static async createContext(options: RunOptions): Promise<JourneyContext> {
@@ -121,12 +123,20 @@ export default class Runner {
     }
   }
 
+  get currentJourney() {
+    return this.#currentJourney;
+  }
+
   addHook(type: HookType, callback: HooksCallback) {
     this.hooks[type].push(callback);
   }
 
-  get currentJourney() {
-    return this.#currentJourney;
+  updateMonitor(config: MonitorConfig) {
+    if (!this.monitor) {
+      this.monitor = new Monitor(config);
+      return;
+    }
+    this.monitor.update(config);
   }
 
   addJourney(journey: Journey) {
@@ -374,6 +384,31 @@ export default class Runner {
     await mkdirAsync(CACHE_PATH, { recursive: true });
   }
 
+  buildMonitors(options: RunOptions) {
+    /**
+     * Update the global monitor configuration required for
+     * setting defaults
+     */
+    this.updateMonitor({
+      throttling: options.throttling,
+      schedule: options.schedule,
+      locations: options.locations,
+    });
+
+    const monitors: Monitor[] = [];
+    for (const journey of this.journeys) {
+      this.#currentJourney = journey;
+      /**
+       * Execute dummy callback to get all monitor specific
+       * configurations for the current journey
+       */
+      journey.callback({ params: options.params } as any);
+      journey.monitor.update(this.monitor?.config);
+      monitors.push(journey.monitor);
+    }
+    return monitors;
+  }
+
   async run(options: RunOptions) {
     const result: RunResult = {};
     if (this.#active) {
diff --git a/src/dsl/journey.ts b/src/dsl/journey.ts
index 22aa144..b6d95fd 100644
--- a/src/dsl/journey.ts
+++ b/src/dsl/journey.ts
@@ -27,6 +27,7 @@ import { Browser, Page, BrowserContext, CDPSession } from 'playwright-chromium';
 import micromatch, { isMatch } from 'micromatch';
 import { Step } from './step';
 import { VoidCallback, HooksCallback, Params, Location } from '../common_types';
+import { Monitor, MonitorConfig } from './monitor';
 
 export type JourneyOptions = {
   name: string;
@@ -52,6 +53,7 @@ export class Journey {
   location?: Location;
   steps: Step[] = [];
   hooks: Hooks = { before: [], after: [] };
+  monitor: Monitor;
 
   constructor(
     options: JourneyOptions,
@@ -63,6 +65,7 @@ export class Journey {
     this.tags = options.tags;
     this.callback = callback;
     this.location = location;
+    this.monitor = new Monitor({ name: this.name, id: this.id });
   }
 
   addStep(name: string, callback: VoidCallback, location?: Location) {
@@ -74,6 +77,14 @@ export class Journey {
   addHook(type: HookType, callback: HooksCallback) {
     this.hooks[type].push(callback);
   }
+
+  updateMonitor(config: MonitorConfig) {
+    /**
+     * Use defaults values from journey for monitor like `name` and `id`
+     */
+    this.monitor = new Monitor({ name: this.name, id: this.id, ...config });
+  }
+
   /**
    * Matches journeys based on the provided args. Proitize tags over match
    * - tags pattern that matches only tags
diff --git a/src/dsl/monitor.ts b/src/dsl/monitor.ts
new file mode 100644
index 0000000..d831028
--- /dev/null
+++ b/src/dsl/monitor.ts
@@ -0,0 +1,51 @@
+/**
+ * MIT License
+ *
+ * Copyright (c) 2020-present, Elastic NV
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ */
+
+import merge from 'deepmerge';
+import { ThrottlingOptions } from '../common_types';
+
+export type SyntheticsLocations = 'US East' | 'EU West';
+export type MonitorConfig = {
+  id?: string;
+  name?: string;
+  schedule?: string;
+  locations?: SyntheticsLocations[];
+  throttling?: ThrottlingOptions;
+};
+
+export class Monitor {
+  constructor(public config: MonitorConfig = {}) {}
+  /**
+   * Treat the creation time config with `monitor.use` as source of truth by
+   * merging the values coming from CLI and Synthetics config file
+   */
+  update(globalOpts: MonitorConfig = {}) {
+    this.config = merge(globalOpts, this.config, {
+      arrayMerge(target, source) {
+        return [...new Set(source)];
+      },
+    });
+  }
+}
diff --git a/src/helpers.ts b/src/helpers.ts
index 76d2a57..b33fbd1 100644
--- a/src/helpers.ts
+++ b/src/helpers.ts
@@ -35,6 +35,7 @@ import {
   HooksCallback,
   NetworkConditions,
   Location,
+  ThrottlingOptions,
 } from './common_types';
 
 const lstatAsync = promisify(fs.lstat);
@@ -267,62 +268,27 @@ export function megabitsToBytes(megabytes: number) {
   return (megabytes * 1024 * 1024) / 8;
 }
 
-export function bytesToMegabits(bytes: number) {
-  return (bytes / 1024 / 1024) * 8;
-}
-
-export const DEFAULT_NETWORK_CONDITIONS: NetworkConditions = {
-  downloadThroughput: megabitsToBytes(5), // Devtools CDP expects format to be in bytes/second
-  uploadThroughput: megabitsToBytes(3), // Devtools CDP expects format to be in bytes/second
-  latency: 20, // milliseconds,
-  offline: false,
+export const DEFAULT_THROTTLING_OPTIONS: ThrottlingOptions = {
+  download: 5,
+  upload: 3,
+  latency: 20,
 };
 
-// Tranforms CDP dev tools format back to cli args format
-export function formatNetworkConditionsArgs(
-  networkConditions: NetworkConditions
-) {
-  const d = bytesToMegabits(networkConditions.downloadThroughput);
-  const u = bytesToMegabits(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,
+/**
+ * Transforms the CLI throttling arguments in to format
+ * expected by Chrome devtools protocol NetworkConditions
+ */
+export function getNetworkConditions(
+  throttlingOpts: ThrottlingOptions
+): NetworkConditions {
+  return {
+    downloadThroughput: megabitsToBytes(throttlingOpts.download),
+    uploadThroughput: megabitsToBytes(throttlingOpts.upload), // Devtools CDP expects format to be in bytes/second
+    latency: throttlingOpts.latency, // milliseconds,
+    offline: false,
   };
-
-  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 = megabitsToBytes(Number(value));
-        break;
-      case downloadToken:
-        networkConditions.downloadThroughput = megabitsToBytes(Number(value));
-        break;
-      case latencyToken:
-        networkConditions.latency = Number(value);
-        break;
-    }
-  });
-
-  return networkConditions;
 }
 
-// default stack trace limit
 const dstackTraceLimit = 10;
 
 // Uses the V8 Stacktrace API to get the function location
diff --git a/src/index.ts b/src/index.ts
index bea1b96..4d758e7 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -33,7 +33,15 @@ export async function run(options: RunOptions) {
 /**
  * Export all core module functions
  */
-export { beforeAll, afterAll, journey, step, before, after } from './core';
+export {
+  journey,
+  step,
+  monitor,
+  beforeAll,
+  afterAll,
+  before,
+  after,
+} from './core';
 export { expect } from './core/expect';
 /**
  * Export all the driver related types to be consumed
diff --git a/src/options.ts b/src/options.ts
index be42f01..0e11469 100644
--- a/src/options.ts
+++ b/src/options.ts
@@ -25,8 +25,9 @@
 
 import merge from 'deepmerge';
 import { readConfig } from './config';
-import { parseNetworkConditions } from './helpers';
-import type { CliArgs, RunOptions } from './common_types';
+import { getNetworkConditions, DEFAULT_THROTTLING_OPTIONS } from './helpers';
+import type { CliArgs, RunOptions, ThrottlingOptions } from './common_types';
+import { MonitorConfig } from './dsl/monitor';
 
 /**
  * Set debug based on DEBUG ENV and -d flags
@@ -91,11 +92,14 @@ export function normalizeOptions(cliArgs: CliArgs): RunOptions {
       ? readConfig(options.environment, cliArgs.config)
       : {};
 
-  options.params = Object.freeze(merge(config.params, cliArgs.params || {}));
-
   /**
-   * Favor playwright options passed via cli to inline playwright options
+   * Order of preference for options that are used while running are
+   * 1. Local options configured via Runner API
+   * 2. CLI flags
+   * 3. Configuration file
    */
+  options.params = Object.freeze(merge(config.params, cliArgs.params || {}));
+
   options.playwrightOptions = merge.all([
     config.playwrightOptions || {},
     cliArgs.playwrightOptions || {},
@@ -106,11 +110,70 @@ export function normalizeOptions(cliArgs: CliArgs): RunOptions {
     },
   ]);
 
+  const defaults = getDefaultMonitorConfig();
   if (cliArgs.throttling) {
-    options.networkConditions = parseNetworkConditions(
-      cliArgs.throttling as string
-    );
+    const throttleConfig = merge.all([
+      defaults.throttling,
+      config.monitor?.throttling || {},
+      cliArgs.throttling as ThrottlingOptions,
+    ]);
+    options.throttling = throttleConfig;
+    options.networkConditions = getNetworkConditions(throttleConfig);
+  } else {
+    /**
+     * Do not apply throttling when `--no-throttling` flag is passed
+     */
+    options.throttling = {};
   }
 
+  options.locations =
+    cliArgs.locations ?? config.monitor?.locations ?? defaults.locations;
+
+  options.schedule =
+    cliArgs.schedule ?? config.monitor?.schedule ?? defaults.schedule;
+
   return options;
 }
+
+/**
+ * Get the default monitor configuration for all journeys
+ */
+export function getDefaultMonitorConfig(): MonitorConfig {
+  return {
+    throttling: DEFAULT_THROTTLING_OPTIONS,
+    locations: ['US East'],
+    schedule: '10m',
+  };
+}
+
+/**
+ * Parses the throttling CLI settings and also
+ * adapts to the format to keep the backwards compatability
+ * - Accepts old format `<5d/3u/20l>`
+ * - Processess new format otherwise `{download: 5, upload: 3, latency: 20}`
+ */
+export function parseThrottling(value: string, prev?: string) {
+  const THROTTLING_REGEX = /([0-9]{1,}u)|([0-9]{1,}d)|([0-9]{1,}l)/gm;
+  if (THROTTLING_REGEX.test(value)) {
+    const throttling: ThrottlingOptions = {};
+    const conditions = value.split('/');
+
+    conditions.forEach(condition => {
+      const setting = condition.slice(0, condition.length - 1);
+      const token = condition.slice(-1);
+      switch (token) {
+        case 'd':
+          throttling.download = Number(setting);
+          break;
+        case 'u':
+          throttling.upload = Number(setting);
+          break;
+        case 'l':
+          throttling.latency = Number(setting);
+          break;
+      }
+    });
+    return throttling;
+  }
+  return JSON.parse(value || prev);
+}


Test patch:
diff --git a/__tests__/cli.test.ts b/__tests__/cli.test.ts
index 8548461..99b6c73 100644
--- a/__tests__/cli.test.ts
+++ b/__tests__/cli.test.ts
@@ -27,7 +27,11 @@ import { ChildProcess, spawn } from 'child_process';
 import { join } from 'path';
 import { devices } from 'playwright-chromium';
 import { Server } from './utils/server';
-import { megabitsToBytes, DEFAULT_NETWORK_CONDITIONS } from '../src/helpers';
+import {
+  DEFAULT_THROTTLING_OPTIONS,
+  getNetworkConditions,
+  megabitsToBytes,
+} from '../src/helpers';
 
 const safeParse = (chunks: string[]) => {
   return chunks.map(data => {
@@ -418,57 +422,59 @@ describe('CLI', () => {
       expect(await cli.exitCode).toBe(0);
       expect(journeyStartOutput.payload).toHaveProperty(
         'network_conditions',
-        DEFAULT_NETWORK_CONDITIONS
+        getNetworkConditions(DEFAULT_THROTTLING_OPTIONS)
       );
     });
 
     it('applies custom throttling', async () => {
-      const downloadThroughput = megabitsToBytes(3);
-      const uploadThroughput = megabitsToBytes(1);
-      const latency = 30;
       const cli = new CLIMock()
-        .args(cliArgs.concat(['--throttling', '3d/1u/30l']))
+        .args(
+          cliArgs.concat([
+            '--throttling',
+            JSON.stringify({
+              download: 3,
+              upload: 1,
+              latency: 30,
+            }),
+          ])
+        )
         .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,
+        downloadThroughput: megabitsToBytes(3),
+        uploadThroughput: megabitsToBytes(1),
+        latency: 30,
         offline: false,
-        uploadThroughput,
       });
     });
 
-    it('applies custom throttling order agnostic', async () => {
-      const downloadThroughput = megabitsToBytes(3);
-      const uploadThroughput = megabitsToBytes(1);
-      const latency = 30;
+    it('supports older format', async () => {
       const cli = new CLIMock()
-        .args(cliArgs.concat(['--throttling', '1u/30l/3d']))
+        .args(cliArgs.concat(['--throttling', '17u/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,
+        downloadThroughput: megabitsToBytes(3),
+        uploadThroughput: megabitsToBytes(17),
+        latency: 30,
+        offline: false,
       });
     });
 
     it('uses default throttling when specific params are not provided', async () => {
-      const downloadThroughput = megabitsToBytes(2);
       const cli = new CLIMock()
-        .args(cliArgs.concat(['--throttling', '2d']))
+        .args(cliArgs.concat(['--throttling', JSON.stringify({ download: 2 })]))
         .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,
+        ...getNetworkConditions(DEFAULT_THROTTLING_OPTIONS),
+        downloadThroughput: megabitsToBytes(2),
       });
     });
   });
diff --git a/__tests__/core/runner.test.ts b/__tests__/core/runner.test.ts
index 76c1de0..f2bdda8 100644
--- a/__tests__/core/runner.test.ts
+++ b/__tests__/core/runner.test.ts
@@ -32,6 +32,7 @@ import { Server } from '../utils/server';
 import { generateTempPath, noop } from '../../src/helpers';
 import { wsEndpoint } from '../utils/test-config';
 import { Reporter } from '../../src/reporters';
+import { getDefaultMonitorConfig } from '../../src/options';
 import {
   JourneyEndResult,
   JourneyStartResult,
@@ -78,7 +79,7 @@ describe('runner', () => {
     expect(runner.hooks.afterAll).toEqual([noop]);
   });
 
-  it.only('run journey - report results payload', async () => {
+  it('run journey - report results payload', async () => {
     const j1 = new Journey({ name: 'j1' }, noop);
     const s1 = j1.addStep('step1', noop);
     runner.addJourney(j1);
@@ -672,6 +673,74 @@ describe('runner', () => {
     expect(collectOrder).toEqual(realEventsOrder);
   });
 
+  it('runner - build monitors with local config', async () => {
+    const j1 = new Journey({ name: 'j1' }, noop);
+    const j2 = new Journey({ name: 'j2' }, noop);
+    j1.updateMonitor({ id: 'test-j1', schedule: '2m', locations: ['EU West'] });
+    j2.updateMonitor({ throttling: { latency: 1000 } });
+    runner.addJourney(j1);
+    runner.addJourney(j2);
+
+    const monitors = runner.buildMonitors({
+      ...getDefaultMonitorConfig(),
+    });
+    expect(monitors.length).toBe(2);
+    expect(monitors[0].config).toEqual({
+      id: 'test-j1',
+      name: 'j1',
+      locations: ['EU West'],
+      schedule: '2m',
+      throttling: {
+        download: 5,
+        latency: 20,
+        upload: 3,
+      },
+    });
+    expect(monitors[1].config).toMatchObject({
+      locations: ['US East'],
+      schedule: '10m',
+      throttling: { latency: 1000 },
+    });
+  });
+
+  it('runner - build monitors with global config', async () => {
+    runner.updateMonitor({
+      schedule: '5m',
+      throttling: {
+        download: 100,
+        upload: 50,
+      },
+    });
+
+    const j1 = new Journey({ name: 'j1' }, noop);
+    const j2 = new Journey({ name: 'j2' }, noop);
+    j1.updateMonitor({ id: 'test-j1', schedule: '2m', locations: ['EU West'] });
+    j2.updateMonitor({ throttling: { latency: 1000 } });
+    runner.addJourney(j1);
+    runner.addJourney(j2);
+
+    const monitors = runner.buildMonitors({
+      ...getDefaultMonitorConfig(),
+    });
+    expect(monitors.length).toBe(2);
+    expect(monitors[0].config).toEqual({
+      id: 'test-j1',
+      name: 'j1',
+      locations: ['EU West'],
+      schedule: '2m',
+      throttling: {
+        download: 100,
+        latency: 20,
+        upload: 50,
+      },
+    });
+    expect(monitors[1].config).toMatchObject({
+      locations: ['US East'],
+      schedule: '5m',
+      throttling: { latency: 1000 },
+    });
+  });
+
   /**
    * Its really hard to ensure the journey/end is called for a real world page
    * without actually testing on a real world webpage.
diff --git a/__tests__/options.test.ts b/__tests__/options.test.ts
index 7081414..29c8d2d 100644
--- a/__tests__/options.test.ts
+++ b/__tests__/options.test.ts
@@ -24,11 +24,11 @@
  */
 
 import { CliArgs } from '../src/common_types';
-import { normalizeOptions } from '../src/options';
+import { normalizeOptions, parseThrottling } from '../src/options';
 import { join } from 'path';
 
 describe('options', () => {
-  it('normalize', async () => {
+  it('normalize', () => {
     const cliArgs: CliArgs = {
       params: {
         foo: 'bar',
@@ -72,4 +72,37 @@ describe('options', () => {
       screenshots: 'on',
     });
   });
+
+  it('normalize monitor configs', () => {
+    expect(normalizeOptions({ throttling: false })).toMatchObject({
+      locations: ['US East'],
+      schedule: '10m',
+      throttling: {},
+    });
+
+    expect(
+      normalizeOptions({ throttling: { download: 50 }, schedule: '2m' })
+    ).toMatchObject({
+      locations: ['US East'],
+      schedule: '2m',
+      throttling: {
+        download: 50,
+        upload: 3,
+        latency: 20,
+      },
+    });
+  });
+
+  it('parse throttling', () => {
+    expect(parseThrottling('{}')).toEqual({});
+    expect(parseThrottling('{"download": 20, "upload": 10}')).toEqual({
+      download: 20,
+      upload: 10,
+    });
+    expect(parseThrottling('100l/41u/9d')).toEqual({
+      download: 9,
+      upload: 41,
+      latency: 100,
+    });
+  });
 });