Repository: elastic/synthetics
Pull request #913 (2024-04-03 19:52:03)

feat: support tags/match filtering in push
+ Part of https://github.com/elastic/synthetics/issues/910
+ Filtering in push via tags/match has been asked a lot both externally and internally as it makes it easier for the users to control the monitors getting pushed to Kibana. We have [intentionally made the decision](https://github.com/elastic/synthetics/issues/780) to not support it, mainly because monitors were deleted behind the scenes when users pushed monitors with different tags. We [improved deletion flow](https://github.com/elastic/synthetics/pull/912) and requires explicit approval from the user before all the monitors gets deleted, Also the monitors deleted are shown to the user if `DEBUG=synthetics` flag is enabled. 
+ PR adds support for filtering monitors via `--tags and --match` CLI flags in addition to the undocumented `--pattern` field. It behaves similar to the journey test runner which is used for filtering the journeys when running locally. 

```sh
// filter monitor names 
npx @elastic/synthetics --match "foo*"

// filter monitor with tags

npx @elastic/synthetics --tags "env:qa, env:staging"
```

+ Users can tag the monitors/journeys in different ways and its still supported and backwards compatible. 
```ts
// tag on the journey level
journey({name: "using journey param", tags: ["env:qa"], async(){})

// tag using monitor.use API
journey("using monitor API", async(){
  monitor.use({ tags: ["env:qa"]})
  step("blah", () => {})
})

// global tags via Synthetics.config.ts
export default () => {
  return {
    monitor: {
      tags: ["env:dev"]
    }
  }
};
```

Lightweight monitors
```yaml
heartbeat.monitors:
  - type: http
    enabled: true
    name: google.com
    id: google-1
    urls: ["google.com."]
    tags: ["env:qa"]
```

ale-neto: nice bro, I'm currently going through a problem, and I believe this might be the solution.
gsantoro: It works as expected!

Thanks

Patch:
diff --git a/src/cli.ts b/src/cli.ts
index 932b2b2..19be152 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -56,20 +56,25 @@ import { installTransform } from './core/transform';
 /* eslint-disable-next-line @typescript-eslint/no-var-requires */
 const { name, version } = require('../package.json');
 
-const { params, pattern, playwrightOpts, auth, authMandatory, configOpt } =
-  getCommonCommandOpts();
+const {
+  params,
+  pattern,
+  playwrightOpts,
+  auth,
+  authMandatory,
+  configOpt,
+  tags,
+  match,
+} = getCommonCommandOpts();
 
 program
   .name(`npx ${name}`)
   .usage('[options] [dir] [files] file')
   .addOption(configOpt)
   .addOption(pattern)
+  .addOption(tags)
+  .addOption(match)
   .addOption(params)
-  .option('--tags <name...>', 'run tests with a tag that matches the glob')
-  .option(
-    '--match <name>',
-    'run tests with a name or tags that matches the glob'
-  )
   .addOption(
     new Option('--reporter <value>', `output reporter format`).choices(
       Object.keys(reporters)
@@ -157,6 +162,7 @@ program
   .description(
     'Push all journeys in the current directory to create monitors within the Kibana monitor management UI'
   )
+  .addOption(authMandatory)
   .option(
     '--schedule <time-in-minutes>',
     "schedule in minutes for the pushed monitors. Setting `10`, for example, configures monitors which don't have an interval defined to run every 10 minutes.",
@@ -172,7 +178,7 @@ program
     '--private-locations <locations...>',
     'default list of private locations from which your monitors will run.'
   )
-  .option('--url <url>', 'Kibana URL to upload the monitors')
+  .option('--url <url>', 'Kibana URL to upload the project monitors')
   .option(
     '--id <id>',
     'project id that will be used for logically grouping monitors'
@@ -182,8 +188,10 @@ program
     'the target Kibana spaces for the pushed monitors — spaces help you organise pushed monitors.'
   )
   .option('-y, --yes', 'skip all questions and run non-interactively')
-  .addOption(authMandatory)
+
   .addOption(pattern)
+  .addOption(tags)
+  .addOption(match)
   .addOption(params)
   .addOption(playwrightOpts)
   .addOption(configOpt)
diff --git a/src/common_types.ts b/src/common_types.ts
index d904cf6..21c2df2 100644
--- a/src/common_types.ts
+++ b/src/common_types.ts
@@ -200,14 +200,18 @@ export type ThrottlingOptions = {
   latency?: number;
 };
 
+type GrepOptions = {
+  pattern?: string;
+  tags?: Array<string>;
+  match?: string;
+};
+
 type BaseArgs = {
   params?: Params;
   screenshots?: ScreenshotOptions;
   dryRun?: boolean;
   config?: string;
-  pattern?: string;
-  match?: string;
-  tags?: Array<string>;
+  auth?: string;
   outfd?: number;
   wsEndpoint?: string;
   pauseOnError?: boolean;
@@ -220,6 +224,9 @@ type BaseArgs = {
 };
 
 export type CliArgs = BaseArgs & {
+  pattern?: string;
+  match?: string;
+  tags?: Array<string>;
   reporter?: BuiltInReporterName;
   inline?: boolean;
   require?: Array<string>;
@@ -239,6 +246,7 @@ export type RunOptions = BaseArgs & {
   environment?: string;
   networkConditions?: NetworkConditions;
   reporter?: BuiltInReporterName | ReporterInstance;
+  grepOpts?: GrepOptions;
 };
 
 export type PushOptions = Partial<ProjectSettings> &
@@ -246,9 +254,11 @@ export type PushOptions = Partial<ProjectSettings> &
     auth: string;
     kibanaVersion?: string;
     yes?: boolean;
+    tags?: Array<string>;
     alert?: AlertConfig;
     retestOnFailure?: MonitorConfig['retestOnFailure'];
     enabled?: boolean;
+    grepOpts?: GrepOptions;
   };
 
 export type ProjectSettings = {
diff --git a/src/core/runner.ts b/src/core/runner.ts
index 3d28737..c30ce38 100644
--- a/src/core/runner.ts
+++ b/src/core/runner.ts
@@ -404,8 +404,7 @@ export default class Runner {
 
   buildMonitors(options: PushOptions) {
     /**
-     * Update the global monitor configuration required for
-     * setting defaults
+     * Update the global monitor configuration required for setting defaults
      */
     this.updateMonitor({
       throttling: options.throttling,
@@ -430,11 +429,23 @@ export default class Runner {
         );
       }
       /**
-       * Execute dummy callback to get all monitor specific
-       * configurations for the current journey
+       * Before pushing a browser monitor, three things need to be done:
+       *
+       * - execute callback `monitor.use` in particular to get monitor configurations
+       * - update the monitor config with global configuration
+       * - filter out monitors based on matched tags and name after applying both
+       *  global and local monitor configurations
        */
       journey.callback({ params: options.params } as any);
       journey.monitor.update(this.monitor?.config);
+      if (
+        !journey.monitor.isMatch(
+          options.grepOpts?.match,
+          options.grepOpts?.tags
+        )
+      ) {
+        continue;
+      }
       journey.monitor.validate();
       monitors.push(journey.monitor);
     }
@@ -454,7 +465,7 @@ export default class Runner {
       params: options.params,
     }).catch(e => (this.hookError = e));
 
-    const { dryRun, match, tags } = options;
+    const { dryRun, grepOpts } = options;
     /**
      * Skip other journeys when using `.only`
      */
@@ -471,7 +482,7 @@ export default class Runner {
         this.#reporter.onJourneyRegister?.(journey);
         continue;
       }
-      if (!journey.isMatch(match, tags) || journey.skip) {
+      if (!journey.isMatch(grepOpts?.match, grepOpts?.tags) || journey.skip) {
         continue;
       }
       const journeyResult: JourneyResult = this.hookError
diff --git a/src/dsl/monitor.ts b/src/dsl/monitor.ts
index 9f3411f..e007153 100644
--- a/src/dsl/monitor.ts
+++ b/src/dsl/monitor.ts
@@ -33,7 +33,7 @@ import {
   Params,
   PlaywrightOptions,
 } from '../common_types';
-import { indent } from '../helpers';
+import { indent, isMatch } from '../helpers';
 import { LocationsMap } from '../locations/public-locations';
 
 export type SyntheticsLocationsType = keyof typeof LocationsMap;
@@ -129,6 +129,18 @@ export class Monitor {
     this.filter = filter;
   }
 
+  /**
+   * Matches monitors based on the provided args. Proitize tags over match
+   */
+  isMatch(matchPattern: string, tagsPattern: Array<string>) {
+    return isMatch(
+      this.config.tags,
+      this.config.name,
+      tagsPattern,
+      matchPattern
+    );
+  }
+
   /**
    * Hash is used to identify if the monitor has changed since the last time
    * it was pushed to Kibana. Change is based on three factors:
diff --git a/src/options.ts b/src/options.ts
index 789373f..a37d900 100644
--- a/src/options.ts
+++ b/src/options.ts
@@ -26,74 +26,49 @@
 import merge from 'deepmerge';
 import { createOption } from 'commander';
 import { readConfig } from './config';
-import type { CliArgs, PushOptions, RunOptions } from './common_types';
-import { THROTTLING_WARNING_MSG, error, warn } from './helpers';
+import type { CliArgs, RunOptions } from './common_types';
+import { THROTTLING_WARNING_MSG, warn } from './helpers';
 
 type Mode = 'run' | 'push';
 
+/**
+ * Normalize the options passed via CLI and Synthetics config file
+ *
+ * Order of preference for options:
+ * 1. Local options configured via Runner API
+ * 2. CLI flags
+ * 3. Configuration file
+ */
 export async function normalizeOptions(
   cliArgs: CliArgs,
   mode: Mode = 'run'
 ): Promise<RunOptions> {
+  /**
+   * Move filtering flags from the top level to filter object
+   * and delete the old keys
+   */
+  const grepOpts = {
+    pattern: cliArgs.pattern,
+    tags: cliArgs.tags,
+    match: cliArgs.match,
+  };
+  delete cliArgs.pattern;
+  delete cliArgs.tags;
+  delete cliArgs.match;
+
   const options: RunOptions = {
     ...cliArgs,
+    grepOpts,
     environment: process.env['NODE_ENV'] || 'development',
   };
   /**
-   * Group all events that can be consumed by heartbeat and
-   * eventually by the Synthetics UI.
-   */
-  if (cliArgs.richEvents) {
-    options.reporter = cliArgs.reporter ?? 'json';
-    options.ssblocks = true;
-    options.network = true;
-    options.trace = true;
-    options.quietExitCode = true;
-  }
-
-  if (cliArgs.capability) {
-    const supportedCapabilities = [
-      'trace',
-      'network',
-      'filmstrips',
-      'metrics',
-      'ssblocks',
-    ];
-    /**
-     * trace - record chrome trace events(LCP, FCP, CLS, etc.) for all journeys
-     * network - capture network information for all journeys
-     * filmstrips - record detailed filmstrips for all journeys
-     * metrics - capture performance metrics (DOM Nodes, Heap size, etc.) for each step
-     * ssblocks - Dedupes the screenshots in to blocks to save storage space
-     */
-    for (const flag of cliArgs.capability) {
-      if (supportedCapabilities.includes(flag)) {
-        options[flag] = true;
-      } else {
-        console.warn(
-          `Missing capability "${flag}", current supported capabilities are ${supportedCapabilities.join(
-            ', '
-          )}`
-        );
-      }
-    }
-  }
-
-  /**
-   * Validate and read synthetics config file
-   * based on the environment
+   * Validate and read synthetics config file based on the environment
    */
   const config =
     cliArgs.config || !cliArgs.inline
       ? await readConfig(options.environment, cliArgs.config)
       : {};
 
-  /**
-   * 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 || {}));
 
   /**
@@ -118,10 +93,49 @@ export async function normalizeOptions(
    */
   switch (mode) {
     case 'run':
+      if (cliArgs.capability) {
+        const supportedCapabilities = [
+          'trace',
+          'network',
+          'filmstrips',
+          'metrics',
+          'ssblocks',
+        ];
+        /**
+         * trace - record chrome trace events(LCP, FCP, CLS, etc.) for all journeys
+         * network - capture network information for all journeys
+         * filmstrips - record detailed filmstrips for all journeys
+         * metrics - capture performance metrics (DOM Nodes, Heap size, etc.) for each step
+         * ssblocks - Dedupes the screenshots in to blocks to save storage space
+         */
+        for (const flag of cliArgs.capability) {
+          if (supportedCapabilities.includes(flag)) {
+            options[flag] = true;
+          } else {
+            console.warn(
+              `Missing capability "${flag}", current supported capabilities are ${supportedCapabilities.join(
+                ', '
+              )}`
+            );
+          }
+        }
+      }
+
+      /**
+       * Group all events that can be consumed by heartbeat and
+       * eventually by the Synthetics UI.
+       */
+      if (cliArgs.richEvents) {
+        options.reporter = cliArgs.reporter ?? 'json';
+        options.ssblocks = true;
+        options.network = true;
+        options.trace = true;
+        options.quietExitCode = true;
+      }
+
       options.screenshots = cliArgs.screenshots ?? 'on';
       break;
     case 'push':
-      validatePushOptions(options as PushOptions);
       /**
        * Merge the default monitor config from synthetics.config.ts file
        * with the CLI options passed via push command
@@ -152,14 +166,6 @@ export function getHeadlessFlag(
   return configHeadless ?? true;
 }
 
-export function validatePushOptions(opts: PushOptions) {
-  if (opts.tags || opts.match) {
-    throw error(`Aborted. Invalid CLI flags.
-
-Tags and Match are not supported in push command.`);
-  }
-}
-
 /* eslint-disable-next-line @typescript-eslint/no-unused-vars */
 function toObject(value: boolean | Record<string, any>): Record<string, any> {
   const defaulVal = {};
@@ -211,6 +217,15 @@ export function getCommonCommandOpts() {
     'configuration path (default: synthetics.config.js)'
   );
 
+  const tags = createOption(
+    '--tags <name...>',
+    'run/push tests with a tag that matches the glob'
+  );
+  const match = createOption(
+    '--match <name>',
+    'run/push tests with a name or tags that matches the glob'
+  );
+
   return {
     auth,
     authMandatory,
@@ -218,5 +233,7 @@ export function getCommonCommandOpts() {
     playwrightOpts,
     pattern,
     configOpt,
+    tags,
+    match,
   };
 }
diff --git a/src/push/monitor.ts b/src/push/monitor.ts
index f1683e8..b7982c7 100644
--- a/src/push/monitor.ts
+++ b/src/push/monitor.ts
@@ -167,8 +167,8 @@ export async function createLightweightMonitors(
 ) {
   const lwFiles = new Set<string>();
   // Filter monitor files based on the provided pattern
-  const pattern = options.pattern
-    ? new RegExp(options.pattern, 'i')
+  const pattern = options.grepOpts?.pattern
+    ? new RegExp(options.grepOpts?.pattern, 'i')
     : /.(yml|yaml)$/;
   const ignore = /(node_modules|.github)/;
   await totalist(workDir, (rel, abs) => {
@@ -211,7 +211,9 @@ export async function createLightweightMonitors(
       offsets.push(monNode.srcToken.offset);
     }
 
-    const mergedConfig = parsedDoc.toJS()['heartbeat.monitors'];
+    const mergedConfig = parsedDoc.toJS()[
+      'heartbeat.monitors'
+    ] as Array<MonitorConfig>;
     for (let i = 0; i < mergedConfig.length; i++) {
       const monitor = mergedConfig[i];
       // Skip browser monitors from the YML files
@@ -220,7 +222,14 @@ export async function createLightweightMonitors(
       }
       const { line, col } = lineCounter.linePos(offsets[i]);
       try {
+        /**
+         * Build the monitor object from the yaml config along with global configuration
+         * and perform the match based on the provided filters
+         */
         const mon = buildMonitorFromYaml(monitor, options);
+        if (!mon.isMatch(options.grepOpts?.match, options.grepOpts?.tags)) {
+          continue;
+        }
         mon.setSource({ file, line, column: col });
         monitors.push(mon);
       } catch (e) {


Test patch:
diff --git a/__tests__/core/runner.test.ts b/__tests__/core/runner.test.ts
index b642c10..f3301b5 100644
--- a/__tests__/core/runner.test.ts
+++ b/__tests__/core/runner.test.ts
@@ -338,7 +338,7 @@ describe('runner', () => {
     expect(
       await runner.run({
         ...defaultRunOptions,
-        match: 'j2',
+        grepOpts: { match: 'j2' },
       })
     ).toMatchObject({
       j2: { status: 'succeeded' },
@@ -351,7 +351,7 @@ describe('runner', () => {
     expect(
       await runner.run({
         ...defaultRunOptions,
-        match: 'j*',
+        grepOpts: { match: 'j*' },
       })
     ).toMatchObject({
       j1: { status: 'succeeded' },
@@ -368,8 +368,7 @@ describe('runner', () => {
     expect(
       await runner.run({
         ...defaultRunOptions,
-        tags: ['foo*'],
-        match: 'j*',
+        grepOpts: { tags: ['foo*'], match: 'j*' },
       })
     ).toMatchObject({
       j1: { status: 'succeeded' },
@@ -385,7 +384,7 @@ describe('runner', () => {
     expect(
       await runner.run({
         ...defaultRunOptions,
-        tags: ['hello:b*'],
+        grepOpts: { tags: ['hello:b*'] },
       })
     ).toMatchObject({
       j2: { status: 'succeeded' },
@@ -400,7 +399,7 @@ describe('runner', () => {
     expect(
       await runner.run({
         ...defaultRunOptions,
-        tags: ['!hello:b*'],
+        grepOpts: { tags: ['!hello:b*'] },
       })
     ).toMatchObject({
       j1: { status: 'succeeded' },
@@ -750,6 +749,7 @@ describe('runner', () => {
         throttling: { latency: 1000 },
         schedule: 1,
         alert: { status: { enabled: false } },
+        tags: [],
       });
     });
 
@@ -810,6 +810,58 @@ describe('runner', () => {
         alert: { tls: { enabled: true } },
       });
     });
+
+    it('runner - build monitors filtered via "match"', async () => {
+      const j1 = new Journey({ name: 'j1' }, noop);
+      const j2 = new Journey({ name: 'j2' }, noop);
+      runner.addJourney(j1);
+      runner.addJourney(j2);
+
+      const monitors = runner.buildMonitors({
+        ...options,
+        grepOpts: { match: 'j1' },
+        schedule: 1,
+      });
+      expect(monitors.length).toBe(1);
+      expect(monitors[0].config.name).toBe('j1');
+    });
+
+    it('runner - build monitors with  via "tags"', async () => {
+      const j1 = new Journey({ name: 'j1', tags: ['first'] }, noop);
+      const j2 = new Journey({ name: 'j2', tags: ['second'] }, noop);
+      const j3 = new Journey({ name: 'j3' }, noop);
+      runner.addJourney(j1);
+      runner.addJourney(j2);
+      runner.addJourney(j3);
+
+      const monitors = runner.buildMonitors({
+        ...options,
+        grepOpts: { tags: ['first'] },
+        schedule: 1,
+      });
+      expect(monitors.length).toBe(1);
+      expect(monitors[0].config.name).toBe('j1');
+    });
+
+    it('runner - build monitors with config and filter  via "tags"', async () => {
+      const j1 = new Journey({ name: 'j1', tags: ['first'] }, noop);
+      const j2 = new Journey({ name: 'j2', tags: ['second'] }, noop);
+      const j3 = new Journey({ name: 'j3' }, noop);
+      runner.addJourney(j1);
+      runner.addJourney(j2);
+      runner.addJourney(j3);
+      // using monitor.use
+      j2.updateMonitor({ tags: ['newtag'] });
+
+      const monitors = runner.buildMonitors({
+        ...options,
+        tags: ['newtag'],
+        grepOpts: { tags: ['newtag'] },
+        schedule: 1,
+      });
+      expect(monitors.length).toBe(2);
+      expect(monitors.map(m => m.config.name)).toEqual(['j2', 'j3']);
+    });
   });
 
   describe('journey and step annotations', () => {
diff --git a/__tests__/fixtures/synthetics.config.ts b/__tests__/fixtures/synthetics.config.ts
index 7319928..d202443 100644
--- a/__tests__/fixtures/synthetics.config.ts
+++ b/__tests__/fixtures/synthetics.config.ts
@@ -38,6 +38,7 @@ module.exports = env => {
       screenshot: 'off',
       schedule: 10,
       locations: ['us_east'],
+      tags: ['foo', 'bar'],
       privateLocations: ['test-location'],
       alert: {
         status: {
diff --git a/__tests__/options.test.ts b/__tests__/options.test.ts
index cd2a83f..6ad35f8 100644
--- a/__tests__/options.test.ts
+++ b/__tests__/options.test.ts
@@ -51,7 +51,7 @@ describe('options', () => {
     expect(await normalizeOptions(cliArgs)).toMatchObject({
       dryRun: true,
       environment: 'test',
-      match: 'check*',
+      grepOpts: { match: 'check*' },
       params: {
         foo: 'bar',
         url: 'non-dev',
@@ -108,6 +108,7 @@ describe('options', () => {
       screenshots: 'only-on-failure',
       schedule: 3,
       privateLocations: ['test'],
+      tags: ['foo', 'bar'],
       locations: ['australia_east'],
       alert: {
         status: {
diff --git a/__tests__/push/__snapshots__/index.test.ts.snap b/__tests__/push/__snapshots__/index.test.ts.snap
index c4d8e0b..1ec22f7 100644
--- a/__tests__/push/__snapshots__/index.test.ts.snap
+++ b/__tests__/push/__snapshots__/index.test.ts.snap
@@ -1,12 +1,5 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`Push abort on tags and match 1`] = `
-"Aborted. Invalid CLI flags.
-
-Tags and Match are not supported in push command.
-"
-`;
-
 exports[`Push error on empty project id 1`] = `
 "Aborted. Invalid synthetics project settings.
 
diff --git a/__tests__/push/index.test.ts b/__tests__/push/index.test.ts
index 2748b2d..b79e9ad 100644
--- a/__tests__/push/index.test.ts
+++ b/__tests__/push/index.test.ts
@@ -123,12 +123,6 @@ describe('Push', () => {
     expect(output).toContain('Push command Aborted');
   });
 
-  it('abort on tags and match', async () => {
-    await fakeProjectSetup({}, {});
-    const output = await runPush([...DEFAULT_ARGS, '--tags', 'foo:*']);
-    expect(output).toMatchSnapshot();
-  });
-
   it('error on invalid schedule in monitor DSL', async () => {
     await fakeProjectSetup(
       { id: 'test-project', space: 'dummy', url: 'http://localhost:8080' },
diff --git a/__tests__/push/monitor.test.ts b/__tests__/push/monitor.test.ts
index eabb026..34bd1f5 100644
--- a/__tests__/push/monitor.test.ts
+++ b/__tests__/push/monitor.test.ts
@@ -207,7 +207,7 @@ heartbeat.monitors:
       `);
       const monitors = await createLightweightMonitors(PROJECT_DIR, {
         ...opts,
-        pattern: '.yaml$',
+        grepOpts: { pattern: '.yaml$' },
       });
       expect(monitors.length).toBe(0);
     });
@@ -236,6 +236,73 @@ heartbeat.monitors:
       expect(monitors.length).toBe(1);
     });
 
+    it('push - match filter', async () => {
+      await writeHBFile(`
+heartbeat.monitors:
+- type: http
+  name: "m1"
+  id: "mon1"
+  tags: "tag1"
+- type: http
+  name: "m2"
+  id: "mon2"
+  tags: "tag2"
+      `);
+      const monitors = await createLightweightMonitors(PROJECT_DIR, {
+        ...opts,
+        grepOpts: { match: 'm1' },
+      });
+      expect(monitors.length).toBe(1);
+      expect(monitors[0].config.name).toEqual('m1');
+    });
+
+    it('push - tags filter', async () => {
+      await writeHBFile(`
+heartbeat.monitors:
+- type: http
+  name: "m1"
+  id: "mon1"
+  tags: ["foo", "bar"]
+- type: http
+  name: "m2"
+  id: "mon2"
+  tags: ["bar", "baz"]
+- type: http
+  name: "m3"
+  id: "mon3"
+  tags: ["baz", "boom"]
+      `);
+      const monitors = await createLightweightMonitors(PROJECT_DIR, {
+        ...opts,
+        grepOpts: { tags: ['bar'] },
+      });
+      expect(monitors.length).toBe(2);
+      expect(monitors.map(m => m.config.name)).toEqual(['m1', 'm2']);
+    });
+
+    it('push - apply tags config and also filter', async () => {
+      await writeHBFile(`
+heartbeat.monitors:
+- type: http
+  name: "m1"
+  id: "mon1"
+  tags: ["foo"]
+- type: http
+  name: "m2"
+  id: "mon2"
+- type: http
+  name: "m3"
+  id: "mon3"
+      `);
+      const monitors = await createLightweightMonitors(PROJECT_DIR, {
+        ...opts,
+        tags: ['ltag'],
+        grepOpts: { tags: ['ltag'] },
+      });
+      expect(monitors.length).toBe(2);
+      expect(monitors.map(m => m.config.name)).toEqual(['m2', 'm3']);
+    });
+
     it('prefer local monitor config', async () => {
       await writeHBFile(`
 heartbeat.monitors: