Repository: nearmap/libddog (language: python)

Issue:
support monitors
<!-- REMINDER: Please be aware that this is a public project, so do not include any company specific information in your issue! -->

The goal of this issue to add support for monitors as a new concept alongside dashboards. 

There are many types of monitors in Datadog[1] but the most widely used in Nearmap are Metric Alert Query and Event Alert Query, so these are the two we aim to support first.

[1] https://docs.datadoghq.com/api/latest/monitors/#create-a-monitor


Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index bf5326f..a366187 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,7 @@
 .tox/
 __pycache__
 _snapshots/
+_snapshots_monitors/
 build/
 dist/
 htmlcov/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f7295f0..bf3fffc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,8 @@
 
 ## 0.1.8 (next)
 
-- 
+- Added support for monitors: event and metric monitors are now supported. Issue
+  #60.
 
 ## 0.1.7
 
diff --git a/bin/ddog b/bin/ddog
index fcbbea9..79443ee 100755
--- a/bin/ddog
+++ b/bin/ddog
@@ -13,7 +13,9 @@ import warnings
 import click
 
 import libddog
-from libddog.command_line.dashboards import ConsoleWriter, DashboardManagerCli
+from libddog.command_line.console import ConsoleWriter
+from libddog.command_line.dashboards import DashboardManagerCli
+from libddog.command_line.monitors import MonitorManagerCli
 from libddog.command_line.options import attach_help_option
 from libddog.command_line.upgrade_check import UpgradeChecker
 
@@ -46,6 +48,26 @@ def dash(ctx, no_upgrade_check: bool):
         upgrade_checker.run()
 
 
+@click.group()
+@click.option(
+    "-U",
+    "--no-upgrade-check",
+    help="Skip libddog upgrade check.",
+    is_flag=True,
+    default=False,
+)
+@click.pass_context
+def mon(ctx, no_upgrade_check: bool):
+    "Datadog monitors management actions"
+
+    ctx.monitor_mgr = MonitorManagerCli(proj_path=".")
+    ctx.writer = ConsoleWriter()
+
+    if not no_upgrade_check:
+        upgrade_checker = UpgradeChecker()
+        upgrade_checker.run()
+
+
 @click.command()
 @click.pass_context
 def version(ctx):
@@ -58,7 +80,7 @@ def version(ctx):
     writer.println(f"libddog version {libddog.__version__}")
 
 
-@click.command()
+@click.command(name="delete-live")
 @click.option(
     "-i",
     "--id",
@@ -66,7 +88,7 @@ def version(ctx):
     help="The id of the dashboard to delete",
 )
 @click.pass_context
-def delete_live(ctx, id: str):
+def dash_delete_live(ctx, id: str):
     """
     Deletes a live dashboard in Datadog.
 
@@ -80,9 +102,9 @@ def delete_live(ctx, id: str):
     sys.exit(exit_code)
 
 
-@click.command()
+@click.command(name="list-defs")
 @click.pass_context
-def list_defs(ctx):
+def dash_list_defs(ctx):
     """
     Lists dashboard definitions in this project.
     """
@@ -93,9 +115,9 @@ def list_defs(ctx):
     sys.exit(exit_code)
 
 
-@click.command()
+@click.command(name="list-live")
 @click.pass_context
-def list_live(ctx):
+def dash_list_live(ctx):
     """
     Lists live dashboards in Datadog.
     """
@@ -106,7 +128,7 @@ def list_live(ctx):
     sys.exit(exit_code)
 
 
-@click.command()
+@click.command(name="publish-draft")
 @click.option(
     "-t",
     "--title",
@@ -114,7 +136,7 @@ def list_live(ctx):
     help="Select the dashboard to publish by title, matched like a wildcard",
 )
 @click.pass_context
-def publish_draft(ctx, title: str):
+def dash_publish_draft(ctx, title: str):
     """
     Publishes a dashboard definition as a [draft] live dashboard in Datadog.
 
@@ -129,7 +151,7 @@ def publish_draft(ctx, title: str):
     sys.exit(exit_code)
 
 
-@click.command()
+@click.command(name="publish-live")
 @click.option(
     "-t",
     "--title",
@@ -137,7 +159,7 @@ def publish_draft(ctx, title: str):
     help="Select dashboards to update by title, matched like a wildcard",
 )
 @click.pass_context
-def publish_live(ctx, title: str):
+def dash_publish_live(ctx, title: str):
     """
     Publishes multiple dashboard definitions as live dashboards in Datadog.
 
@@ -152,7 +174,7 @@ def publish_live(ctx, title: str):
     sys.exit(exit_code)
 
 
-@click.command()
+@click.command(name="snapshot-live")
 @click.option(
     "-i",
     "--id",
@@ -160,7 +182,7 @@ def publish_live(ctx, title: str):
     help="The id of the dashboard to snapshot",
 )
 @click.pass_context
-def snapshot_live(ctx, id: str):
+def dash_snapshot_live(ctx, id: str):
     """
     Creates a snapshot on disk in JSON format of a live dashboard in Datadog.
 
@@ -173,14 +195,136 @@ def snapshot_live(ctx, id: str):
     sys.exit(exit_code)
 
 
+@click.command(name="delete-live")
+@click.option(
+    "-i",
+    "--id",
+    required=True,
+    help="The id of the monitor to delete",
+)
+@click.pass_context
+def mon_delete_live(ctx, id: str):
+    """
+    Deletes a live monitor in Datadog.
+
+    First takes a snapshot of the monitor so that the deleted monitor can be
+    restored if needed.
+    """
+
+    mgr: MonitorManagerCli = ctx.parent.monitor_mgr
+
+    exit_code = mgr.delete_live(id=id)
+    sys.exit(exit_code)
+
+
+@click.command(name="list-defs")
+@click.pass_context
+def mon_list_defs(ctx):
+    """
+    Lists monitor definitions in this project.
+    """
+
+    mgr: MonitorManagerCli = ctx.parent.monitor_mgr
+
+    exit_code = mgr.list_defs()
+    sys.exit(exit_code)
+
+
+@click.command(name="list-live")
+@click.pass_context
+def mon_list_live(ctx):
+    """
+    Lists live monitors in Datadog.
+    """
+
+    mgr: MonitorManagerCli = ctx.parent.monitor_mgr
+
+    exit_code = mgr.list_live()
+    sys.exit(exit_code)
+
+
+@click.command(name="publish-draft")
+@click.option(
+    "-n",
+    "--name",
+    required=True,
+    help="Select the monitor to publish by name, matched like a wildcard",
+)
+@click.pass_context
+def mon_publish_draft(ctx, name: str):
+    """
+    Publishes a monitor definition as a [draft] live monitor in Datadog.
+
+    Prepends '[draft] ' to the monitor name.
+    If no live monitor with this name exists then a new monitor is created.
+    If a live monitor with this name does exist it is updated.
+    """
+
+    mgr: MonitorManagerCli = ctx.parent.monitor_mgr
+
+    exit_code = mgr.publish_draft(name_pat=name)
+    sys.exit(exit_code)
+
+
+@click.command(name="publish-live")
+@click.option(
+    "-n",
+    "--name",
+    required=True,
+    help="Select monitors to update by name, matched like a wildcard",
+)
+@click.pass_context
+def mon_publish_live(ctx, name: str):
+    """
+    Publishes multiple monitor definitions as live monitors in Datadog.
+
+    For each monitor:
+    If no live monitor with this name exists then a new monitor is created.
+    If a live monitor with this name does exist it is updated.
+    """
+
+    mgr: MonitorManagerCli = ctx.parent.monitor_mgr
+
+    exit_code = mgr.publish_live(name_pat=name)
+    sys.exit(exit_code)
+
+
+@click.command(name="snapshot-live")
+@click.option(
+    "-i",
+    "--id",
+    required=True,
+    help="The id of the monitor to snapshot",
+)
+@click.pass_context
+def mon_snapshot_live(ctx, id: str):
+    """
+    Creates a snapshot on disk in JSON format of a live monitor in Datadog.
+
+    The snapshot can be used to manually restore the monitor in the Datadog UI.
+    """
+
+    mgr: MonitorManagerCli = ctx.parent.monitor_mgr
+
+    exit_code = mgr.snapshot_live(id=id)
+    sys.exit(exit_code)
+
+
 cli.add_command(dash)
+cli.add_command(mon)
 cli.add_command(version)
-dash.add_command(delete_live)
-dash.add_command(list_defs)
-dash.add_command(list_live)
-dash.add_command(publish_draft)
-dash.add_command(publish_live)
-dash.add_command(snapshot_live)
+dash.add_command(dash_delete_live)
+dash.add_command(dash_list_defs)
+dash.add_command(dash_list_live)
+dash.add_command(dash_publish_draft)
+dash.add_command(dash_publish_live)
+dash.add_command(dash_snapshot_live)
+mon.add_command(mon_delete_live)
+mon.add_command(mon_list_defs)
+mon.add_command(mon_list_live)
+mon.add_command(mon_publish_draft)
+mon.add_command(mon_publish_live)
+mon.add_command(mon_snapshot_live)
 attach_help_option(cli)
 
 if __name__ == "__main__":
diff --git a/bin/fetch-all b/bin/fetch-all-dashboards
similarity index 100%
rename from bin/fetch-all
rename to bin/fetch-all-dashboards
diff --git a/bin/fetch-all-monitors b/bin/fetch-all-monitors
new file mode 100755
index 0000000..e4176c2
--- /dev/null
+++ b/bin/fetch-all-monitors
@@ -0,0 +1,37 @@
+#!/usr/bin/env python
+
+import sys
+
+try:
+    import libddog
+except ImportError:
+    sys.path.append(".")
+
+# isort: split
+import json
+import os
+import re
+
+from libddog.crud.monitors import MonitorManager
+from libddog.tools.text import sanitize_title_for_filename
+
+
+def main() -> None:
+    mgr = MonitorManager()
+    dst = "_snapshots_monitors"
+
+    monitor_dicts = mgr.client.list_monitors()
+
+    for monitor_dict in monitor_dicts:
+        name: str = monitor_dict["name"]
+        name_sanitized = sanitize_title_for_filename(name)
+        fp = os.path.join(dst, f"{name_sanitized}.json")
+
+        with open(fp, "w") as fl:
+            block = json.dumps(monitor_dict, indent=4, sort_keys=True)
+            print(f"Saving monitor {name!r} to {fp}")
+            fl.write(block)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/bin/parse-fetched b/bin/parse-fetched-dashboards
similarity index 100%
rename from bin/parse-fetched
rename to bin/parse-fetched-dashboards
diff --git a/bin/parse-fetched-monitors b/bin/parse-fetched-monitors
new file mode 100755
index 0000000..551a46e
--- /dev/null
+++ b/bin/parse-fetched-monitors
@@ -0,0 +1,31 @@
+#!/usr/bin/env python
+
+import sys
+
+try:
+    import libddog
+except ImportError:
+    sys.path.append(".")
+
+# isort: split
+import json
+import os
+
+
+def main(download_dir) -> None:
+    for fn in sorted(os.listdir(download_dir)):
+        fp = os.path.join(download_dir, fn)
+
+        with open(fp, "r") as fl:
+            doc = json.load(fl)
+
+        typ = doc["type"]
+        name = doc["query"]
+
+        fmt = "%16s  %s"
+        line = fmt % (typ, name)
+        print(line)
+
+
+if __name__ == "__main__":
+    main(sys.argv[1])
diff --git a/libddog/__init__.py b/libddog/__init__.py
index 3e779c1..4592883 100644
--- a/libddog/__init__.py
+++ b/libddog/__init__.py
@@ -1,1 +1,1 @@
-__version__ = "0.1.8a0"
+__version__ = "0.1.8a2"
diff --git a/libddog/command_line/dashboards.py b/libddog/command_line/dashboards.py
index 7c1008d..be4bff9 100644
--- a/libddog/command_line/dashboards.py
+++ b/libddog/command_line/dashboards.py
@@ -1,6 +1,6 @@
 import fnmatch
 import os
-from typing import Any, List, Union
+from typing import List, Union
 
 from libddog.command_line.console import ConsoleWriter
 from libddog.crud.dashboards import DashboardManager
diff --git a/libddog/command_line/monitors.py b/libddog/command_line/monitors.py
new file mode 100644
index 0000000..c91cb7e
--- /dev/null
+++ b/libddog/command_line/monitors.py
@@ -0,0 +1,229 @@
+import fnmatch
+import os
+from typing import List
+
+from libddog.command_line.console import ConsoleWriter
+from libddog.crud.errors import AbstractCrudError
+from libddog.crud.monitors import MonitorManager
+from libddog.monitors.monitors import Monitor
+from libddog.tools.timekeeping import parse_date, time_since, utcnow
+
+
+class MonitorManagerCli:
+    def __init__(self, proj_path: str) -> None:
+        self.proj_path = os.path.abspath(proj_path)
+
+        self.writer = ConsoleWriter()
+        self.manager = MonitorManager(self.proj_path)
+
+    def filter_definitions(
+        self, pattern: str, monitors: List[Monitor]
+    ) -> List[Monitor]:
+        return [mon for mon in monitors if fnmatch.fnmatch(mon.name, pattern)]
+
+    def delete_live(self, *, id: int) -> int:
+        # Take a snapshot first to make restoring it possible
+        exit_code = self.snapshot_live(id=id)
+        if exit_code != os.EX_OK:
+            return exit_code
+
+        self.writer.print("Deleting live monitor with id: %r... ", id)
+
+        try:
+            self.manager.delete_monitor(id=id)
+            self.writer.println("done")
+
+        except AbstractCrudError as exc:
+            self.writer.report_failed(exc)
+            return os.EX_UNAVAILABLE
+
+        return os.EX_OK
+
+    def list_defs(self) -> int:
+        monitors = None
+
+        try:
+            monitors = self.manager.load_definitions()
+
+        except AbstractCrudError as exc:
+            self.writer.report_failed(exc)
+            return os.EX_UNAVAILABLE
+
+        def format_tags(monitor: Monitor) -> str:
+            return ",".join(monitor.tags)
+
+        # sort by name
+        monitors = sorted(monitors, key=lambda mon: mon.name.lower())
+
+        tags_width = 4
+        if monitors:
+            tags_width = max([len(format_tags(mon)) for mon in monitors])
+
+        fmt = "%-6s  %-" + str(tags_width) + "s  %s"
+        self.writer.println(fmt, "TYPE", "TAGS", "NAME")
+
+        for monitor in monitors:
+            self.writer.println(
+                fmt,
+                monitor.type_desc,
+                format_tags(monitor),
+                monitor.name,
+            )
+
+        return os.EX_OK
+
+    def list_live(self) -> int:
+        monitor_dcts = None
+
+        try:
+            monitor_dcts = self.manager.list_monitors()
+
+        except AbstractCrudError as exc:
+            self.writer.report_failed(exc)
+            return os.EX_UNAVAILABLE
+
+        fmt = "%11s  %24s  %4s  %7s  %s"
+        header_cols = (
+            "ID",
+            "USER",
+            "TIME",
+            "LIBDDOG",
+            "NAME",
+        )
+        self.writer.println(fmt, *header_cols)
+
+        tuples = []
+        for dct in monitor_dcts:
+            modified_at = parse_date(dct["modified"])
+            modified_ago = utcnow() - modified_at
+            tuples.append((modified_ago, dct))
+
+        # sort by oldest modified time first
+        tuples.sort(reverse=True)
+
+        for modified_ago, dct in tuples:
+            id = dct["id"]
+            message = dct["message"]
+            user_handle = dct["creator"]["email"]
+            user_symbol = "c"
+            libddog_maintained = "n"
+            name = dct["name"]
+
+            # user@company.com -> user
+            user_handle = user_handle.split("@")[0]
+            user_handle = f"{user_handle} [{user_symbol}]"
+
+            # detect our own fingerprint in the message
+            if self.manager._libddog_proj_name in message:
+                libddog_maintained = "y"
+
+            cols = (
+                id,
+                user_handle,
+                time_since(modified_ago),
+                libddog_maintained,
+                name,
+            )
+            self.writer.println(fmt, *cols)
+
+        self.writer.println("%d monitors found" % len(tuples))
+
+        return os.EX_OK
+
+    def publish_draft(self, *, name_pat: str) -> int:
+        monitors = self.manager.load_definitions()
+        monitors = self.filter_definitions(name_pat, monitors)
+
+        if not monitors:
+            self.writer.println("Name pattern %r did not match any monitors", name_pat)
+            return os.EX_USAGE
+
+        if len(monitors) > 1:
+            fmt = "\n".join([f"- {mon.name}" for mon in monitors])
+            self.writer.println(
+                "Name pattern %r matched multiple monitors:\n%s", name_pat, fmt
+            )
+            return os.EX_USAGE
+
+        monitor = monitors[0]
+        monitor.name = self.manager.get_draft_name(monitor)
+        existing = self.manager.find_first_monitor_with_name(monitor.name)
+
+        if existing:
+            id = int(existing["id"])
+
+            self.writer.print(
+                f"Updating monitor with id: {id!r} named: {monitor.name!r}... "
+            )
+            try:
+                self.manager.update_monitor(monitor=monitor, id=id)
+                self.writer.println("done")
+
+            except AbstractCrudError as exc:
+                self.writer.report_failed(exc)
+                return os.EX_IOERR
+
+        else:
+            self.writer.print(f"Creating monitor named: {monitor.name!r}... ")
+            try:
+                id = self.manager.create_monitor(monitor=monitor)
+                self.writer.println("created with id: %r", id)
+
+            except AbstractCrudError as exc:
+                self.writer.report_failed(exc)
+                return os.EX_IOERR
+
+        return os.EX_OK
+
+    def publish_live(self, *, name_pat: str) -> int:
+        monitors = self.manager.load_definitions()
+        monitors = self.filter_definitions(name_pat, monitors)
+
+        for monitor in monitors:
+            existing = self.manager.find_first_monitor_with_name(monitor.name)
+
+            if existing:
+                id = int(existing["id"])
+
+                # Take a snapshot first to make restoring it possible
+                exit_code = self.snapshot_live(id=id)
+                if exit_code != os.EX_OK:
+                    return exit_code
+
+                self.writer.print(
+                    f"Updating monitor with id: {id!r} named: {monitor.name!r}... "
+                )
+
+                try:
+                    self.manager.update_monitor(monitor=monitor, id=id)
+                    self.writer.println("done")
+
+                except AbstractCrudError as exc:
+                    self.writer.report_failed(exc)
+                    return os.EX_IOERR
+
+            else:
+                self.writer.print(f"Creating monitor named: {monitor.name!r}... ")
+
+                try:
+                    id = self.manager.create_monitor(monitor=monitor)
+                    self.writer.println("created with id: %r", id)
+
+                except AbstractCrudError as exc:
+                    self.writer.report_failed(exc)
+                    return os.EX_IOERR
+
+        return os.EX_OK
+
+    def snapshot_live(self, *, id: int) -> int:
+        self.writer.print("Creating snapshot of live monitor with id: %r... ", id)
+
+        try:
+            fp = self.manager.create_snapshot(id)
+            self.writer.println("saved to: %s", fp)
+
+        except AbstractCrudError as exc:
+            self.writer.report_failed(exc)
+            return os.EX_UNAVAILABLE
+
+        return os.EX_OK
diff --git a/libddog/crud/client.py b/libddog/crud/client.py
index da0b63f..b513ad0 100644
--- a/libddog/crud/client.py
+++ b/libddog/crud/client.py
@@ -18,9 +18,15 @@
     DashboardUpdateFailed,
     MissingDatadogApiKey,
     MissingDatadogAppKey,
+    MonitorCreateFailed,
+    MonitorDeleteFailed,
+    MonitorGetFailed,
+    MonitorListFailed,
+    MonitorUpdateFailed,
 )
 from libddog.crud.users import UserIdentity
 from libddog.dashboards import Dashboard
+from libddog.monitors import Monitor
 from libddog.tools.logs import enable_logging
 
 
@@ -95,6 +101,14 @@ def build_dashboard_url(self, id: Optional[str] = None) -> str:
 
         return url
 
+    def build_monitor_url(self, id: Optional[int] = None) -> str:
+        url = f"{self.baseurl}/v1/monitor"
+
+        if id is not None:
+            url = f"{url}/{id}"
+
+        return url
+
     def try_parse_json_payload(self, response: requests.Response) -> Optional[JsonDict]:
         try:
             payload: JsonDict = response.json()
@@ -249,6 +263,81 @@ def update_dashboard(self, dashboard: Dashboard, id: Optional[str] = None) -> No
             request=request, expected_code=200, exc_cls=DashboardUpdateFailed
         )
 
+    # Monitor API
+
+    def create_monitor(self, monitor: Monitor) -> int:
+        client_kwargs = monitor.as_dict()
+        client_kwargs.pop("id", None)  # we cannot pass an id when creating
+
+        url = self.build_monitor_url()
+        headers = self.prepare_headers()
+        request = requests.Request(
+            method="POST", url=url, headers=headers, json=client_kwargs
+        )
+
+        payload = self.make_request(
+            request=request, expected_code=200, exc_cls=MonitorCreateFailed
+        )
+
+        assert isinstance(payload, dict)  # help mypy
+        id = payload["id"]
+        assert isinstance(id, int)  # help mypy
+
+        return id
+
+    def delete_monitor(self, *, id: int) -> None:
+        url = self.build_monitor_url(id=id)
+        headers = self.prepare_headers()
+        request = requests.Request(method="DELETE", url=url, headers=headers)
+
+        self.make_request(
+            request=request, expected_code=200, exc_cls=MonitorDeleteFailed
+        )
+
+    def get_monitor(self, *, id: int) -> JsonDict:
+        url = self.build_monitor_url(id=id)
+        headers = self.prepare_headers()
+        request = requests.Request(method="GET", url=url, headers=headers)
+
+        payload = self.make_request(
+            request=request, expected_code=200, exc_cls=MonitorGetFailed
+        )
+
+        assert isinstance(payload, dict)  # help mypy
+        return payload
+
+    def list_monitors(self) -> List[JsonDict]:
+        url = self.build_monitor_url()
+        headers = self.prepare_headers()
+        request = requests.Request(method="GET", url=url, headers=headers)
+
+        monitors = self.make_request(
+            request=request, expected_code=200, exc_cls=MonitorListFailed
+        )
+
+        assert isinstance(monitors, list)  # help mypy
+
+        return monitors
+
+    def update_monitor(self, monitor: Monitor, id: Optional[int] = None) -> None:
+        id = id or monitor.id
+
+        if id is None:
+            raise ValueError("Cannot update monitor without an id: %r" % monitor.name)
+
+        client_kwargs = monitor.as_dict()
+        client_kwargs.pop("id", None)  # we pass it separately
+
+        url = self.build_monitor_url(id=id)
+        headers = self.prepare_headers()
+        request = requests.Request(
+            method="PUT", url=url, headers=headers, json=client_kwargs
+        )
+
+        self.make_request(
+            request=request, expected_code=200, exc_cls=MonitorUpdateFailed
+        )
+
     # Key Management API
 
     def detect_current_user_identity(self) -> Optional[UserIdentity]:
diff --git a/libddog/crud/errors.py b/libddog/crud/errors.py
index 592c6f9..bfd9b8d 100644
--- a/libddog/crud/errors.py
+++ b/libddog/crud/errors.py
@@ -91,3 +91,31 @@ class MissingDatadogApiKey(AbstractCrudError):
 
 class MissingDatadogAppKey(AbstractCrudError):
     pass
+
+
+class MonitorCreateFailed(AbstractCrudError):
+    pass
+
+
+class MonitorDeleteFailed(AbstractCrudError):
+    pass
+
+
+class MonitorGetFailed(AbstractCrudError):
+    pass
+
+
+class MonitorListFailed(AbstractCrudError):
+    pass
+
+
+class MonitorUpdateFailed(AbstractCrudError):
+    pass
+
+
+class MonitorDefinitionsImportError(AbstractCrudError):
+    pass
+
+
+class MonitorDefinitionsLoadError(AbstractCrudError):
+    pass
diff --git a/libddog/crud/monitors.py b/libddog/crud/monitors.py
new file mode 100644
index 0000000..9b4f611
--- /dev/null
+++ b/libddog/crud/monitors.py
@@ -0,0 +1,184 @@
+import importlib
+import json
+import os
+import re
+import sys
+from pathlib import Path
+from types import ModuleType
+from typing import List, Optional
+
+import libddog
+from libddog.common.types import JsonDict
+from libddog.crud.client import DatadogClient
+from libddog.crud.errors import (
+    MonitorDefinitionsImportError,
+    MonitorDefinitionsLoadError,
+)
+from libddog.monitors.monitors import Monitor
+from libddog.tools.git import GitHelper
+from libddog.tools.text import sanitize_title_for_filename
+from libddog.tools.timekeeping import format_datetime_for_filename, utcnow
+
+
+class MonitorManager:
+    _name_sentinel = "Untitled monitor"
+    _snapshot_dirname = "_snapshots_monitors"
+
+    _defs_containing_dir = "config"
+    _defs_module_name = "monitors"
+    _defs_import_path = f"{_defs_containing_dir}.{_defs_module_name}"
+
+    _libddog_proj_name = "libddog"
+    _libddog_proj_url = "https://github.com/nearmap/libddog"
+
+    def __init__(self, proj_path: str) -> None:
+        self.proj_path = proj_path
+        self.snapshots_path: Path = Path(self.proj_path) / Path(self._snapshot_dirname)
+        self.git = GitHelper()
+
+        self._client: Optional[DatadogClient] = None  # lazy attribute
+
+    @property
+    def client(self) -> DatadogClient:
+        if self._client is None:
+            self._client = DatadogClient()
+            self._client.load_credentials_from_environment()
+
+        return self._client
+
+    def load_definitions_module(self) -> ModuleType:
+        # add proj_path to sys.path to make 'config' importable
+        if self.proj_path not in sys.path:
+            sys.path.append(self.proj_path)
+
+        import_path = self._defs_import_path
+        load_func = "get_monitors"
+
+        # import the module
+        try:
+            monitors_module = importlib.import_module(import_path)
+        except ModuleNotFoundError as exc:
+            error = (
+                f"Failed to import definitions module "
+                f"{import_path!r}: {exc.args[0]}"
+            )
+            raise MonitorDefinitionsImportError(errors=[error])
+
+        # probe for get_monitors()
+        get_monitors = getattr(monitors_module, load_func, None)
+        if get_monitors is None or not callable(get_monitors):
+            error = (
+                f"Definitions module {self._defs_import_path!r} "
+                f"does not contain {load_func!r} function"
+            )
+            raise MonitorDefinitionsLoadError(errors=[error])
+
+        # try calling get_monitors
+        monitors = get_monitors()
+
+        errors = []
+        if not isinstance(monitors, list):
+            error = f"{load_func!r} did not return a list of Monitor instances"
+            errors.append(error)
+
+        if not errors:
+            for idx, monitor in enumerate(monitors):
+                if not isinstance(monitor, Monitor):
+                    error = f"{idx}th value returned was not a Monitor: {monitor!r}"
+                    errors.append(error)
+
+        if errors:
+            raise MonitorDefinitionsLoadError(errors=[error])
+
+        return monitors_module
+
+    def load_definitions(self) -> List[Monitor]:
+        module = self.load_definitions_module()
+        monitors: List[Monitor] = module.get_monitors()  # type: ignore
+        return monitors
+
+    def get_draft_name(self, monitor: Monitor) -> str:
+        return f"[draft] {monitor.name}"
+
+    def insert_libddog_metadata_footer(self, monitor: Monitor) -> None:
+        opt_project_phrase = ""
+        libddog_link = f"[{self._libddog_proj_name}]({self._libddog_proj_url})"
+
+        output = self.git.get_remotes()
+        if output is not None:
+            remotes = self.git.parse_remotes(output)
+            if remotes:
+                name = self.git.get_repo_name(remotes)
+                url = self.git.get_repo_http_url(remotes)
+
+                if name and url:
+                    opt_project_phrase = (
+                        f"is defined in code as part of "
+                        f"the [{name}]({url}) project and "
+                    )
+                elif name:
+                    opt_project_phrase = (
+                        f"is defined in code as part of the *{name}* project and "
+                    )
+
+        content = (
+            f"\n\n---\n\n"
+            f"This monitor {opt_project_phrase}is maintained automatically "
+            f"using the {libddog_link} tool. "
+            f"If you make manual changes to it your changes may be lost."
+        )
+
+        message = monitor.message or ""
+        if content not in message:
+            monitor.message = f"{message}{content}"
+
+    def ensure_snapshot_path_exists(self) -> None:
+        if not os.path.exists(self.snapshots_path):
+            os.makedirs(self.snapshots_path)
+
+    def create_snapshot(self, id: int) -> Path:
+        self.ensure_snapshot_path_exists()
+
+        dct = self.client.get_monitor(id=id)
+
+        name = dct.get("name", self._name_sentinel)
+        name = sanitize_title_for_filename(name)
+        date = format_datetime_for_filename(utcnow())
+
+        block = json.dumps(dct, indent=2, sort_keys=True)
+        fn = Path(f"{id}--{name}--{date}.json")
+        fp = self.snapshots_path / fn
+
+        with open(fp, "w") as fl:
+            fl.write(block)
+            fl.write("\n")
+
+        return fp
+
+    # Monitor actions
+
+    def create_monitor(self, monitor: Monitor) -> int:
+        self.insert_libddog_metadata_footer(monitor)
+        return self.client.create_monitor(monitor=monitor)
+
+    def delete_monitor(self, *, id: int) -> None:
+        self.client.delete_monitor(id=id)
+
+    def get_monitor(self, *, id: int) -> JsonDict:
+        return self.client.get_monitor(id=id)
+
+    def list_monitors(self) -> List[JsonDict]:
+        return self.client.list_monitors()
+
+    def update_monitor(self, monitor: Monitor, id: Optional[int] = None) -> None:
+        self.insert_libddog_metadata_footer(monitor)
+        self.client.update_monitor(monitor=monitor, id=id)
+
+    def find_first_monitor_with_name(self, name: str) -> Optional[JsonDict]:
+        monitor_dicts = self.list_monitors()
+
+        for monitor_dict in monitor_dicts:
+            if name == monitor_dict.get("name"):
+                return monitor_dict
+
+        return None
diff --git a/libddog/metrics/query.py b/libddog/metrics/query.py
index ee6f0ae..51b5e32 100644
--- a/libddog/metrics/query.py
+++ b/libddog/metrics/query.py
@@ -3,7 +3,7 @@
 from typing import Any, Dict, List, Optional, Type
 
 from libddog.common.bases import Renderable
-from libddog.metrics.bases import QueryNode
+from libddog.metrics.bases import FormulaNode, QueryNode
 from libddog.metrics.literals import Identifier
 from libddog.parsing.query_parser import QueryParser
 
@@ -272,7 +272,7 @@ def as_dict(self) -> Dict[str, Any]:
         return dct
 
 
-class QueryMonad:
+class QueryMonad(FormulaNode):
     """
     QueryMonad provides a more succinct and convenient syntax to build up a
     query string than using QueryState directly. Internally, it just stores a
@@ -482,6 +482,9 @@ def fill(self, func: str, limit: Optional[int] = None) -> "QueryMonad":
 
         return self.__class__(state)
 
+    def codegen(self) -> str:
+        return self._state.codegen()
+
 
 def Query(metric: str, name: Optional[str] = None) -> QueryMonad:
     state = QueryState(metric=Metric(name=metric), name=name)
diff --git a/libddog/monitors/__init__.py b/libddog/monitors/__init__.py
new file mode 100644
index 0000000..180821d
--- /dev/null
+++ b/libddog/monitors/__init__.py
@@ -0,0 +1,35 @@
+from libddog.monitors.components import (
+    MonitorOptions,
+    MonitorThresholds,
+    MonitorThresholdWindows,
+)
+from libddog.monitors.enums import RenotifyStatus
+from libddog.monitors.event_v2_alert_query import event_query
+from libddog.monitors.metric_alert_query import (
+    avg,
+    change,
+    last,
+    max,
+    metric_query,
+    min,
+    pct_change,
+    sum,
+)
+from libddog.monitors.monitors import Monitor
+
+__all__ = [
+    "Monitor",
+    "MonitorOptions",
+    "MonitorThresholds",
+    "MonitorThresholdWindows",
+    "RenotifyStatus",
+    "avg",
+    "change",
+    "event_query",
+    "last",
+    "max",
+    "metric_query",
+    "min",
+    "pct_change",
+    "sum",
+]
diff --git a/libddog/monitors/bases.py b/libddog/monitors/bases.py
new file mode 100644
index 0000000..dbfeae2
--- /dev/null
+++ b/libddog/monitors/bases.py
@@ -0,0 +1,3 @@
+class AlertQuery:
+    def codegen(self) -> str:
+        raise NotImplementedError  # pragma: no cover
diff --git a/libddog/monitors/components.py b/libddog/monitors/components.py
new file mode 100644
index 0000000..8fde065
--- /dev/null
+++ b/libddog/monitors/components.py
@@ -0,0 +1,146 @@
+from typing import List, Optional
+
+from libddog.common.types import JsonDict
+from libddog.monitors.enums import RenotifyStatus
+
+
+class MonitorThresholds:
+    def __init__(
+        self,
+        *,
+        critical: Optional[float] = None,
+        critical_recovery: Optional[float] = None,
+        ok: Optional[float] = None,
+        # unknown: Optional[float] = None,
+        warning: Optional[float] = None,
+        warning_recovery: Optional[float] = None,
+    ):
+        self.critical = critical
+        self.critical_recovery = critical_recovery
+        self.ok = ok
+        # self.unknown = unknown
+        self.warning = warning
+        self.warning_recovery = warning_recovery
+
+    def as_dict(self) -> JsonDict:
+        attnames = [
+            "critical",
+            "critical_recovery",
+            "ok",
+            # "unknown",  # Datadog API rejects this field
+            "warning",
+            "warning_recovery",
+        ]
+
+        dct = {}
+        for attname in attnames:
+            value = getattr(self, attname)
+            if value is not None:
+                dct[attname] = value
+
+        return dct
+
+
+class MonitorThresholdWindows:
+    def __init__(
+        self,
+        *,
+        recovery_window: Optional[str] = None,
+        trigger_window: Optional[str] = None,
+    ):
+        self.recovery_window = recovery_window
+        self.trigger_window = trigger_window
+
+    def as_dict(self) -> JsonDict:
+        attnames = [
+            "recovery_window",
+            "trigger_window",
+        ]
+
+        dct = {}
+        for attname in attnames:
+            value = getattr(self, attname)
+            if value is not None:
+                dct[attname] = value
+
+        return dct
+
+
+class MonitorOptions:
+    def __init__(
+        self,
+        *,
+        enable_logs_sample: Optional[bool] = None,
+        escalation_message: Optional[str] = None,
+        evaluation_delay: Optional[int] = None,
+        # group_retention_duration,
+        groupby_simple_monitor: Optional[bool] = False,
+        include_tags: Optional[bool] = True,
+        # min_failure_duration,
+        # min_location_failed,
+        new_group_delay: Optional[int] = None,
+        no_data_timeframe: Optional[int] = None,
+        notify_audit: Optional[bool] = False,
+        # notify_by: Optional[List[str]] = None,
+        notify_no_data: Optional[bool] = False,
+        # on_missing_data: Optional[OnMissingData] = None,
+        renotify_interval: Optional[int] = None,
+        renotify_occurrences: Optional[int] = None,
+        renotify_statuses: Optional[List[RenotifyStatus]] = None,
+        require_full_window: Optional[bool] = False,
+        # scheduling_options,
+        threshold_windows: Optional[MonitorThresholdWindows] = None,
+        thresholds: Optional[MonitorThresholds] = None,
+        timeout_h: Optional[int] = None,
+        # variables,
+    ) -> None:
+        self.enable_logs_sample = enable_logs_sample
+        self.escalation_message = escalation_message
+        self.evaluation_delay = evaluation_delay
+        self.groupby_simple_monitor = groupby_simple_monitor
+        self.include_tags = include_tags
+        # self.on_missing_data = on_missing_data or OnMissingData.DEFAULT
+        self.new_group_delay = new_group_delay
+        self.no_data_timeframe = no_data_timeframe
+        self.notify_audit = notify_audit
+        # self.notify_by = notify_by
+        self.notify_no_data = notify_no_data
+        self.renotify_interval = renotify_interval
+        self.renotify_occurrences = renotify_occurrences
+        self.renotify_statuses = renotify_statuses
+        self.require_full_window = require_full_window
+        self.threshold_windows = threshold_windows or MonitorThresholdWindows()
+        self.thresholds = thresholds or MonitorThresholds()
+        self.timeout_h = timeout_h
+
+    def as_dict(self) -> JsonDict:
+        renotify_statuses = None
+        if self.renotify_statuses:
+            renotify_statuses = [status.value for status in self.renotify_statuses]
+
+        threshold_windows: Optional[JsonDict] = self.threshold_windows.as_dict()
+        threshold_windows = threshold_windows if threshold_windows else None
+
+        thresholds: Optional[JsonDict] = self.thresholds.as_dict()
+        thresholds = thresholds if thresholds else None
+
+        return {
+            "enable_logs_sample": self.enable_logs_sample,
+            "escalation_message": self.escalation_message,
+            "evaluation_delay": self.evaluation_delay,
+            "groupby_simple_monitor": self.groupby_simple_monitor,
+            "include_tags": self.include_tags,
+            # "on_missing_data": self.on_missing_data.value,  # Datadog API rejects this field
+            "new_group_delay": self.new_group_delay,
+            "no_data_timeframe": self.no_data_timeframe,
+            "notify_audit": self.notify_audit,
+            # "notify_by": self.notify_by,  # Datadog API rejects this field
+            "notify_no_data": self.notify_no_data,
+            "renotify_interval": self.renotify_interval,
+            "renotify_occurrences": self.renotify_occurrences,
+            "renotify_statuses": renotify_statuses,
+            "require_full_window": self.require_full_window,
+            "threshold_windows": threshold_windows,
+            "thresholds": thresholds,
+            "timeout_h": self.timeout_h,
+        }
diff --git a/libddog/monitors/enums.py b/libddog/monitors/enums.py
new file mode 100644
index 0000000..88e694e
--- /dev/null
+++ b/libddog/monitors/enums.py
@@ -0,0 +1,14 @@
+import enum
+
+
+class RenotifyStatus(enum.Enum):
+    ALERT = "alert"
+    WARN = "warn"
+    NO_DATA = "no data"
+
+
+class OnMissingData(enum.Enum):
+    DEFAULT = "default"
+    SHOW_NO_DATA = "show_no_data"
+    SHOW_AND_NOTIFY_NO_DATA = "show_and_notify_no_data"
+    RESOLVE = "resolve"
diff --git a/libddog/monitors/event_v2_alert_query.py b/libddog/monitors/event_v2_alert_query.py
new file mode 100644
index 0000000..3cf41e9
--- /dev/null
+++ b/libddog/monitors/event_v2_alert_query.py
@@ -0,0 +1,248 @@
+import copy
+import enum
+import re
+from typing import List, Optional, Type
+
+from libddog.monitors.bases import AlertQuery
+from libddog.monitors.exceptions import EventV2AlertQueryValidationError
+from libddog.parsing.query_parser import QueryParser
+
+
+def reverse_enum(enum_cls: Type[enum.Enum], literal: str, label: str) -> enum.Enum:
+    alternatives: List[enum.Enum] = list(enum_cls)
+    for alternative in alternatives:
+        if literal == alternative.value:
+            return alternative
+
+    values = [alt.value for alt in alternatives if alt.value]
+    values_fmt = ", ".join([f"{alt!r}" for alt in sorted(values)])
+    raise EventV2AlertQueryValidationError(
+        "%s %r must be one of %s" % (label, literal, values_fmt)
+    )
+
+
+class LogQuery:
+    """
+    Contains a log query in the form of a text string as detailed at:
+    https://docs.datadoghq.com/logs/explorer/search_syntax/
+
+    Example:
+      sources:nagios status:error,warning priority:normal tags:"string query"
+
+    The query is not checked for correctness.
+    """
+
+    def __init__(self, *, query: str) -> None:
+        self.query = query
+
+    def codegen(self) -> str:
+        return self.query
+
+
+class RollupMethod(enum.Enum):
+    AVG = "avg"
+    CARDINALITY = "cardinality"
+    COUNT = "count"
+
+    def codegen(self) -> str:
+        return self.value
+
+
+class Rollup:
+    def __init__(self, *, method: RollupMethod, measure: Optional[str] = None) -> None:
+        self.method = method
+        self.measure = measure
+
+    def codegen(self) -> str:
+        measure = f',"{self.measure}"' if self.measure else ""
+        return f'.rollup("{self.method.codegen()}"{measure})'
+
+
+class Last:
+    rx_value = re.compile(r"(?P<num>[0-9]+)(?P<unit>m|h)")
+
+    def __init__(self, num: int, unit: str) -> None:
+        self.num = num
+        self.unit = unit
+
+    @classmethod
+    def from_str(cls, period: str) -> "Last":
+        match = cls.rx_value.match(period)
+        if match is None:
+            raise EventV2AlertQueryValidationError(f"last period invalid: {period!r}")
+
+        num = int(match.group("num"))
+        unit = match.group("unit")
+
+        if unit == "m" and not 1 <= num <= 2880:
+            raise EventV2AlertQueryValidationError(
+                f"last period out of range: {period!r}"
+            )
+        if unit == "h" and not 1 <= num <= 48:
+            raise EventV2AlertQueryValidationError(
+                f"last period out of range: {period!r}"
+            )
+
+        return cls(num=num, unit=unit)
+
+    def codegen(self) -> str:
+        return f'.last("{self.num}{self.unit}")'
+
+
+class Operator(enum.Enum):
+    LT = "<"
+    LE = "<="
+    GT = ">"
+    GE = ">="
+    EQ = "=="
+    NE = "!="
+
+    def codegen(self) -> str:
+        return self.value
+
+
+class EventV2AlertQueryState:
+    """
+    from: https://docs.datadoghq.com/api/latest/monitors/#create-a-monitor
+
+    > Event V2 Alert Query
+
+    > Example: events(query).rollup(rollup_method[, measure]).last(time_window) operator #
+
+    > - query: The search query - following the Log search syntax.
+    > - rollup_method: The stats roll-up method - supports count, avg and
+    cardinality.
+    > - measure: For avg and cardinality rollup_method - specify the measure or
+    the facet name you want to use.
+    > - time_window: #m (between 1 and 2880), #h (between 1 and 48).
+    > - operator: <, <=, >, >=, ==, or !=.
+    > - #: an integer or decimal number used to set the threshold.
+    """
+
+    def __init__(
+        self,
+        *,
+        query: LogQuery,
+        rollup: Optional[Rollup] = None,
+        by: Optional[List[str]] = None,
+        last: Optional[Last] = None,
+        operator: Optional[Operator] = None,
+        alert_threshold: Optional[float] = None,
+    ) -> None:
+        self.query = query
+        self.rollup = rollup or Rollup(method=RollupMethod.COUNT)
+        self.by = by or []
+        self.last = last or Last(num=5, unit="m")
+        self.operator = operator or Operator.GE
+        self.alert_threshold = alert_threshold if alert_threshold is not None else 1
+
+    def clone(self) -> "EventV2AlertQueryState":
+        return copy.deepcopy(self)
+
+    def codegen(self) -> str:
+        by = ""
+        if self.by:
+            items = ",".join(self.by)
+            by = f'.by("{items}")'
+
+        query = 'events("%s")%s%s%s %s %s' % (
+            self.query.codegen(),
+            self.rollup.codegen(),
+            by,
+            self.last.codegen(),
+            self.operator.codegen(),
+            self.alert_threshold,
+        )
+
+        return query
+
+
+class EventV2AlertQueryMonad(AlertQuery):
+    def __init__(self, state: EventV2AlertQueryState) -> None:
+        self._state = state
+
+    def rollup(
+        self, method: str, measure: Optional[str] = None
+    ) -> "EventV2AlertQueryMonad":
+        state = self._state.clone()
+
+        variant = reverse_enum(RollupMethod, method, label="Rollup method")
+        assert isinstance(variant, RollupMethod)  # help mypy
+        rollup = Rollup(method=variant, measure=measure)
+
+        state.rollup = rollup
+        return self.__class__(state)
+
+    def by(self, *tags: str) -> "EventV2AlertQueryMonad":
+        state = self._state.clone()
+        by_tags: List[str] = state.by or []
+
+        parser = QueryParser.get_instance()
+
+        for tag in tags:
+            if not parser.is_valid_tag_name(tag):
+                raise EventV2AlertQueryValidationError("Invalid by tag name: %r" % tag)
+
+            if tag not in by_tags:
+                by_tags.append(tag)
+
+        state.by = by_tags
+        return self.__class__(state)
+
+    def last(self, period: str) -> "EventV2AlertQueryMonad":
+        state = self._state.clone()
+
+        last = Last.from_str(period)
+
+        state.last = last
+        return self.__class__(state)
+
+    def __gt__(self, value: float) -> "EventV2AlertQueryMonad":
+        state = self._state.clone()
+
+        state.operator = Operator.GT
+        state.alert_threshold = value
+        return self.__class__(state)
+
+    def __ge__(self, value: float) -> "EventV2AlertQueryMonad":
+        state = self._state.clone()
+
+        state.operator = Operator.GE
+        state.alert_threshold = value
+        return self.__class__(state)
+
+    def __lt__(self, value: float) -> "EventV2AlertQueryMonad":
+        state = self._state.clone()
+
+        state.operator = Operator.LT
+        state.alert_threshold = value
+        return self.__class__(state)
+
+    def __le__(self, value: float) -> "EventV2AlertQueryMonad":
+        state = self._state.clone()
+
+        state.operator = Operator.LE
+        state.alert_threshold = value
+        return self.__class__(state)
+
+    def __eq__(self, value: float) -> "EventV2AlertQueryMonad":  # type: ignore
+        state = self._state.clone()
+
+        state.operator = Operator.EQ
+        state.alert_threshold = value
+        return self.__class__(state)
+
+    def __ne__(self, value: float) -> "EventV2AlertQueryMonad":  # type: ignore
+        state = self._state.clone()
+
+        state.operator = Operator.NE
+        state.alert_threshold = value
+        return self.__class__(state)
+
+    def codegen(self) -> str:
+        return self._state.codegen()
+
+
+def event_query(query: str) -> EventV2AlertQueryMonad:
+    state = EventV2AlertQueryState(query=LogQuery(query=query))
+    return EventV2AlertQueryMonad(state)
diff --git a/libddog/monitors/exceptions.py b/libddog/monitors/exceptions.py
new file mode 100644
index 0000000..9fc85ef
--- /dev/null
+++ b/libddog/monitors/exceptions.py
@@ -0,0 +1,6 @@
+class EventV2AlertQueryValidationError(Exception):
+    pass
+
+
+class MetricAlertQueryValidationError(Exception):
+    pass
diff --git a/libddog/monitors/metric_alert_query.py b/libddog/monitors/metric_alert_query.py
new file mode 100644
index 0000000..f52e5b0
--- /dev/null
+++ b/libddog/monitors/metric_alert_query.py
@@ -0,0 +1,223 @@
+import copy
+import enum
+import re
+from typing import Any, Optional, Union
+
+from libddog.metrics.bases import FormulaNode
+from libddog.metrics.query import QueryMonad, QueryState, Rollup
+from libddog.monitors.bases import AlertQuery
+from libddog.monitors.exceptions import MetricAlertQueryValidationError
+
+
+class TimeAggNode:
+    def codegen(self) -> str:
+        raise NotImplemented  # pragma: no cover
+
+
+class last(TimeAggNode):
+    rx_value = re.compile(r"((?P<mins>[0-9]+)m|(?P<hours>[0-9]+)h)|1d|1w")
+
+    def __init__(self, period: str) -> None:
+        match = self.rx_value.match(period)
+        if match is None:
+            raise MetricAlertQueryValidationError(f"last period invalid: {period!r}")
+
+        mins_str = match.groupdict().get("mins")
+        hours_str = match.groupdict().get("hours")
+
+        if mins_str:
+            mins = int(mins_str)
+            if not 1 <= mins <= 10080:
+                raise MetricAlertQueryValidationError(
+                    f"last period out of range: {period!r}"
+                )
+
+        if hours_str:
+            hours = int(hours_str)
+            if not 1 <= hours <= 168:
+                raise MetricAlertQueryValidationError(
+                    f"last period out of range: {period!r}"
+                )
+
+        self.period = period
+
+    def codegen(self) -> str:
+        return f"last_{self.period}"
+
+
+class TimeAggFunc(TimeAggNode):
+    pass
+
+
+class TimeAggFuncWithSingleNode(TimeAggFunc):
+    def __init__(self, node: TimeAggNode) -> None:
+        self.node = node
+
+    def codegen(self) -> str:
+        func_name = self.__class__.__name__
+        return f"{func_name}({self.node.codegen()})"
+
+
+class TimeAggFuncWithTwoNodes(TimeAggFunc):
+    def __init__(self, left: TimeAggNode, right: TimeAggNode) -> None:
+        self.left = left
+        self.right = right
+
+    def codegen(self) -> str:
+        func_name = self.__class__.__name__
+        return f"{func_name}({self.left.codegen()},{self.right.codegen()})"
+
+
+class avg(TimeAggFuncWithSingleNode):
+    pass
+
+
+class change(TimeAggFuncWithTwoNodes):
+    pass
+
+
+class min(TimeAggFuncWithSingleNode):
+    pass
+
+
+class max(TimeAggFuncWithSingleNode):
+    pass
+
+
+class pct_change(TimeAggFuncWithTwoNodes):
+    pass
+
+
+class sum(TimeAggFuncWithSingleNode):
+    pass
+
+
+class Operator(enum.Enum):
+    LT = "<"
+    LE = "<="
+    GT = ">"
+    GE = ">="
+    EQ = "=="
+    NE = "!="
+
+    def codegen(self) -> str:
+        return self.value
+
+
+class MetricAlertQueryState:
+    """
+    from: https://docs.datadoghq.com/api/latest/monitors/#create-a-monitor
+
+    > Metric Alert Query
+
+    > Example: time_aggr(time_window):space_aggr:metric{tags} [by {key}] operator #
+
+    > - time_aggr: avg, sum, max, min, change, or pct_change
+    > - time_window: last_#m (with # between 1 and 10080 depending on the
+    monitor type) or last_#h(with # between 1 and 168 depending on the monitor
+    type) or last_1d, or last_1w
+    > - space_aggr: avg, sum, min, or max
+    > - tags: one or more tags (comma-separated), or *
+    > - key: a `key` in key:value tag syntax; defines a separate alert for each
+    tag in the group (multi-alert)
+    > - operator: <, <=, >, >=, ==, or !=
+    > - #: an integer or decimal number used to set the threshold
+    """
+
+    def __init__(
+        self,
+        query: Union[QueryMonad, FormulaNode],
+        time_agg: Optional[TimeAggFunc] = None,
+        operator: Optional[Operator] = None,
+        alert_threshold: Optional[float] = None,
+    ) -> None:
+        self.query = query
+        self.time_agg = time_agg or avg(last("5m"))
+        self.operator = operator or Operator.GE
+        self.alert_threshold = alert_threshold if alert_threshold else 1
+
+    def clone(self) -> "MetricAlertQueryState":
+        return copy.deepcopy(self)
+
+    def codegen(self) -> str:
+        metric_query: Union[QueryMonad, QueryState, FormulaNode] = self.query
+
+        if isinstance(self.query, QueryMonad):
+            state = self.query._state.clone()
+
+            # Remove rollup function because time aggregation works differently
+            # for metric alert queries.
+            state.funcs = [func for func in state.funcs if not isinstance(func, Rollup)]
+
+            metric_query = state
+
+        # NOTE: If it's a FormulaNode removing the rollup function would require
+        # recursing into all nested functions. Technically this should also be
+        # done here, but the workaround is just not to use .rollup() in the
+        # input.
+
+        query = "%s:%s %s %s" % (
+            self.time_agg.codegen(),
+            metric_query.codegen(),
+            self.operator.codegen(),
+            self.alert_threshold,
+        )
+
+        return query
+
+
+class MetricAlertQueryMonad(AlertQuery):
+    def __init__(self, state: MetricAlertQueryState) -> None:
+        self._state = state
+
+    def __gt__(self, value: float) -> "MetricAlertQueryMonad":
+        state = self._state.clone()
+
+        state.operator = Operator.GT
+        state.alert_threshold = value
+        return self.__class__(state)
+
+    def __ge__(self, value: float) -> "MetricAlertQueryMonad":
+        state = self._state.clone()
+
+        state.operator = Operator.GE
+        state.alert_threshold = value
+        return self.__class__(state)
+
+    def __lt__(self, value: float) -> "MetricAlertQueryMonad":
+        state = self._state.clone()
+
+        state.operator = Operator.LT
+        state.alert_threshold = value
+        return self.__class__(state)
+
+    def __le__(self, value: float) -> "MetricAlertQueryMonad":
+        state = self._state.clone()
+
+        state.operator = Operator.LE
+        state.alert_threshold = value
+        return self.__class__(state)
+
+    def __eq__(self, value: float) -> "MetricAlertQueryMonad":  # type: ignore
+        state = self._state.clone()
+
+        state.operator = Operator.EQ
+        state.alert_threshold = value
+        return self.__class__(state)
+
+    def __ne__(self, value: float) -> "MetricAlertQueryMonad":  # type: ignore
+        state = self._state.clone()
+
+        state.operator = Operator.NE
+        state.alert_threshold = value
+        return self.__class__(state)
+
+    def codegen(self) -> str:
+        return self._state.codegen()
+
+
+def metric_query(
+    query: Union[QueryMonad, FormulaNode], time_agg: Optional[TimeAggFunc] = None
+) -> MetricAlertQueryMonad:
+    state = MetricAlertQueryState(query=query, time_agg=time_agg)
+    return MetricAlertQueryMonad(state)
diff --git a/libddog/monitors/monitors.py b/libddog/monitors/monitors.py
new file mode 100644
index 0000000..e1c37a3
--- /dev/null
+++ b/libddog/monitors/monitors.py
@@ -0,0 +1,53 @@
+from typing import List, Optional
+
+from libddog.common.types import JsonDict
+from libddog.monitors.bases import AlertQuery
+from libddog.monitors.components import MonitorOptions
+from libddog.monitors.event_v2_alert_query import EventV2AlertQueryMonad
+from libddog.monitors.metric_alert_query import MetricAlertQueryMonad
+
+
+class Monitor:
+    def __init__(
+        self,
+        *,
+        id: Optional[int] = None,
+        name: str,
+        message: str,
+        query: AlertQuery,
+        options: Optional[MonitorOptions] = None,
+        priority: Optional[int] = None,
+        restricted_roles: Optional[List[str]] = None,
+        tags: Optional[List[str]] = None,
+    ) -> None:
+        self.id = id
+        self.name = name
+        self.message = message
+        self.query = query
+        self.options = options or MonitorOptions()
+        self.priority = priority
+        self.restricted_roles = restricted_roles
+        self.tags = tags or []
+
+        self.type_name = "unsupported"
+        self.type_desc = "Unsupported"
+
+        if isinstance(self.query, MetricAlertQueryMonad):
+            self.type_name = "query alert"
+            self.type_desc = "Metric"
+        elif isinstance(self.query, EventV2AlertQueryMonad):
+            self.type_name = "event-v2 alert"
+            self.type_desc = "Event"
+
+    def as_dict(self) -> JsonDict:
+        return {
+            "id": self.id,
+            "name": self.name,
+            "message": self.message,
+            "query": self.query.codegen(),
+            "options": self.options.as_dict(),
+            "priority": self.priority,
+            "restricted_roles": self.restricted_roles,
+            "tags": self.tags,
+            "type": self.type_name,
+        }


Test patch:
diff --git a/libtests/managers.py b/libtests/managers.py
index d6f94d7..d53ac67 100644
--- a/libtests/managers.py
+++ b/libtests/managers.py
@@ -1,9 +1,13 @@
 import re
 from datetime import datetime
 from pathlib import Path
+from typing import List, Type
 
 from libddog.crud.dashboards import DashboardManager
+from libddog.crud.monitors import MonitorManager
 from libddog.dashboards import Dashboard
+from libddog.monitors import Monitor
+from libddog.monitors.bases import AlertQuery
 
 
 class QADashboardManager:
@@ -42,3 +46,43 @@ def update_live_dashboard(self, dashboard: Dashboard, id: str) -> None:
             )
 
         self.manager.update_dashboard(dashboard, id)
+
+
+class QAMonitorManager:
+    # detect occurrences of:  %(variable)s
+    rx_string_template = re.compile("%\\([a-zA-Z0-9_]+\\)s")
+
+    def __init__(self) -> None:
+        proj_root = Path(__file__).parent.parent
+        testdata_dir = proj_root.joinpath("testdata").absolute()
+
+        self.manager = MonitorManager(proj_path=str(testdata_dir))
+
+    def load_definition_by_query_class(self, cls: Type[AlertQuery]) -> List[Monitor]:
+        monitors = []
+
+        all_monitors = self.manager.load_definitions()
+        for monitor in all_monitors:
+            if isinstance(monitor.query, cls):
+                monitors.append(monitor)
+
+        return monitors
+
+    def assign_id_to_monitor(self, monitor: Monitor) -> int:
+        all_monitors = self.manager.list_monitors()
+
+        existing_id: int = -1
+        for mon in all_monitors:
+            if mon["name"] == monitor.name:
+                existing_id = mon["id"]
+                return existing_id
+
+        return self.manager.create_monitor(monitor=monitor)
+
+    def update_live_monitor(self, monitor: Monitor, id: int) -> None:
+        if self.rx_string_template.search(monitor.message):
+            raise RuntimeError(
+                "Monitor message contains unpopulated template: %s" % monitor.message
+            )
+
+        self.manager.update_monitor(monitor, id)
diff --git a/testdata/config/monitors.py b/testdata/config/monitors.py
new file mode 100644
index 0000000..e8c3ac4
--- /dev/null
+++ b/testdata/config/monitors.py
@@ -0,0 +1,15 @@
+from typing import List
+
+from monitors import qa_events, qa_metrics, qa_monitor_options
+
+from libddog.monitors import Monitor
+
+
+def get_monitors() -> List[Monitor]:
+    monitors = (
+        qa_events.get_monitors()
+        + qa_metrics.get_monitors()
+        + qa_monitor_options.get_monitors()
+    )
+
+    return monitors
diff --git a/testdata/monitors/__init__.py b/testdata/monitors/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/testdata/monitors/qa_events.py b/testdata/monitors/qa_events.py
new file mode 100644
index 0000000..f1362c1
--- /dev/null
+++ b/testdata/monitors/qa_events.py
@@ -0,0 +1,70 @@
+from typing import List
+
+from libddog.monitors import Monitor, event_query
+
+CASES = [
+    ("minimal event query", event_query("status:error")),
+    (
+        "exhaustive event query",
+        event_query("status:warn *SIGILL*")
+        .rollup("avg", "host")
+        .by("region", "service")
+        .last("1h")
+        < 1,
+    ),
+    (
+        "event query without rollup",
+        event_query("status:warn *SIGILL*").by("region", "service").last("1h") > 1,
+    ),
+    (
+        "event query with rollup without measure",
+        event_query("status:warn *SIGILL*")
+        .rollup("count")
+        .by("region", "service")
+        .last("1h")
+        == 1,
+    ),
+    (
+        "event query without by",
+        event_query("status:warn *SIGILL*").rollup("avg", "host").last("1h") != 1,
+    ),
+    (
+        "event query with chained by",
+        event_query("status:warn *SIGILL*")
+        .rollup("avg", "host")
+        .by("region")
+        .by("service")
+        .last("1h")
+        <= 1,
+    ),
+    (
+        "event query without last",
+        event_query("status:warn *SIGILL*")
+        .rollup("avg", "host")
+        .by("region", "service")
+        >= 1,
+    ),
+    (
+        "event query without operator nor threshold",
+        event_query("status:warn *SIGILL*")
+        .rollup("avg", "host")
+        .by("region", "service")
+        .last("1h"),
+    ),
+]
+
+
+def get_monitors() -> List[Monitor]:
+    monitors = []
+
+    for desc, query in CASES:
+        monitor = Monitor(
+            name=f"libddog QA: {desc}",
+            message=f"Integration test case: {desc}",
+            query=query,
+            tags=["owner:libddog"],
+        )
+        monitor.tags.append(f"type:{monitor.type_desc.lower()}")
+        monitors.append(monitor)
+
+    return monitors
diff --git a/testdata/monitors/qa_metrics.py b/testdata/monitors/qa_metrics.py
new file mode 100644
index 0000000..7b69c56
--- /dev/null
+++ b/testdata/monitors/qa_metrics.py
@@ -0,0 +1,63 @@
+from typing import List
+
+from libddog.metrics import Query, count_nonzero
+from libddog.monitors import (
+    Monitor,
+    avg,
+    change,
+    last,
+    max,
+    metric_query,
+    min,
+    pct_change,
+    sum,
+)
+
+CASES = [
+    ("minimal metric query", metric_query(Query("aws.elb.requests").agg("sum"))),
+    (
+        "exhaustive metric query",
+        metric_query(
+            count_nonzero(
+                Query("aws.elb.requests").agg("avg").by("az").fill("last", 45)
+            ),
+            pct_change(min(last("5m")), last("10m")),
+        )
+        < 43,
+    ),
+    (
+        "metric query with minutely time aggregation",
+        metric_query(Query("aws.elb.requests").agg("sum"), min(last("2m"))) > 2,
+    ),
+    (
+        "metric query with hourly time aggregation",
+        metric_query(Query("aws.elb.requests").agg("sum"), max(last("7h"))) >= 7,
+    ),
+    (
+        "metric query with daily time aggregation",
+        metric_query(Query("aws.elb.requests").agg("sum"), sum(last("1d"))) <= 50,
+    ),
+    (
+        "metric query with weekly time aggregation",
+        metric_query(
+            Query("aws.elb.requests").agg("sum"), change(avg(last("1w")), last("1d"))
+        )
+        == 90,
+    ),
+]
+
+
+def get_monitors() -> List[Monitor]:
+    monitors = []
+
+    for desc, query in CASES:
+        monitor = Monitor(
+            name=f"libddog QA: {desc}",
+            message=f"Integration test case: {desc}",
+            query=query,
+            tags=["owner:libddog"],
+        )
+        monitor.tags.append(f"type:{monitor.type_desc.lower()}")
+        monitors.append(monitor)
+
+    return monitors
diff --git a/testdata/monitors/qa_monitor_options.py b/testdata/monitors/qa_monitor_options.py
new file mode 100644
index 0000000..ef9735d
--- /dev/null
+++ b/testdata/monitors/qa_monitor_options.py
@@ -0,0 +1,55 @@
+from typing import List
+
+from libddog.metrics import Query, count_nonzero
+from libddog.monitors import (
+    Monitor,
+    MonitorOptions,
+    MonitorThresholds,
+    RenotifyStatus,
+    last,
+    metric_query,
+    min,
+    pct_change,
+)
+
+
+def get_monitors() -> List[Monitor]:
+    query = (
+        metric_query(
+            count_nonzero(Query("aws.elb.requests").agg("avg").by("region").by("az")),
+            pct_change(min(last("5m")), last("10m")),
+        )
+        > 43
+    )
+
+    monitor = Monitor(
+        name=f"libddog QA: exhaustive monitor options",
+        message=f"Integration test case: exhaustive monitor options",
+        query=query,
+        options=MonitorOptions(
+            enable_logs_sample=True,
+            escalation_message="this is the escalation message",
+            evaluation_delay=901,
+            groupby_simple_monitor=False,
+            include_tags=True,
+            new_group_delay=65,
+            notify_audit=True,
+            notify_no_data=False,
+            renotify_interval=1200,
+            renotify_occurrences=3,
+            renotify_statuses=[RenotifyStatus.WARN, RenotifyStatus.NO_DATA],
+            require_full_window=True,
+            thresholds=MonitorThresholds(
+                critical=43,
+                critical_recovery=40,
+                ok=10,
+                warning=35,
+                warning_recovery=30,
+            ),
+            timeout_h=2,
+        ),
+        priority=5,
+        tags=["owner:libddog"],
+    )
+
+    return [monitor]
diff --git a/tests_integ/test_monitors.py b/tests_integ/test_monitors.py
new file mode 100644
index 0000000..4f92bb9
--- /dev/null
+++ b/tests_integ/test_monitors.py
@@ -0,0 +1,76 @@
+from typing import List
+
+from libddog.monitors.event_v2_alert_query import EventV2AlertQueryMonad
+from libddog.monitors.metric_alert_query import MetricAlertQueryMonad
+from libtests.managers import QAMonitorManager
+from libtests.matchers import PatchInstruction, assign, obj_matcher
+
+PATCHES: List[PatchInstruction] = [
+    assign('.["created"]', "created"),
+    assign('.["created_at"]', "created-at"),
+    assign('.["creator"]', "creator"),
+    assign('.["deleted"]', "deleted"),
+    assign('.["id"]', "id"),
+    assign('.["modified"]', "modified"),
+    assign('.["multi"]', "multi"),
+    assign('.["options"]["new_host_delay"]', "new_host_delay"),
+    assign('.["options"]["silenced"]', "silenced"),
+    assign('.["options"]["thresholds"]', "thresholds"),
+    assign('.["org_id"]', "org_id"),
+    assign('.["overall_state"]', "overall_state"),
+    assign('.["overall_state_modified"]', "overall_state_modified"),
+]
+
+
+def test_put_and_get_event_monitors() -> None:
+    mgr = QAMonitorManager()
+    monitors = mgr.load_definition_by_query_class(cls=EventV2AlertQueryMonad)
+
+    for monitor in monitors:
+        monitor_id = mgr.assign_id_to_monitor(monitor)
+
+        # put the monitor
+        mgr.update_live_monitor(monitor, monitor_id)
+
+        # now read it back and assert that it matches our model
+        expected = monitor.as_dict()
+        actual = mgr.manager.get_monitor(id=monitor_id)
+
+        assert obj_matcher(expected, PATCHES) == obj_matcher(
+            actual, PATCHES
+        ), f"failed on: {monitor.name!r}"
+
+
+def test_put_and_get_metric_monitors() -> None:
+    mgr = QAMonitorManager()
+    monitors = mgr.load_definition_by_query_class(cls=MetricAlertQueryMonad)
+
+    for monitor in monitors:
+        monitor_id = mgr.assign_id_to_monitor(monitor)
+
+        # put the monitor
+        mgr.update_live_monitor(monitor, monitor_id)
+
+        # now read it back and assert that it matches our model
+        expected = monitor.as_dict()
+        actual = mgr.manager.get_monitor(id=monitor_id)
+
+        assert obj_matcher(expected, PATCHES) == obj_matcher(
+            actual, PATCHES
+        ), f"failed on: {monitor.name!r}"
+
+
+def test_monitor_lifecycle() -> None:
+    mgr = QAMonitorManager()
+    monitors = mgr.load_definition_by_query_class(cls=EventV2AlertQueryMonad)
+    monitor = monitors[0]
+
+    payload = mgr.manager.find_first_monitor_with_name(monitor.name)
+    if payload:
+        monitor_id = payload["id"]
+        mgr.manager.delete_monitor(id=monitor_id)
+
+    monitor_id = mgr.manager.create_monitor(monitor)
+    mgr.manager.get_monitor(id=monitor_id)
+    mgr.manager.update_monitor(monitor, id=monitor_id)
+    mgr.manager.delete_monitor(id=monitor_id)
diff --git a/tests_unit/monitors/__init__.py b/tests_unit/monitors/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests_unit/monitors/test_event_query.py b/tests_unit/monitors/test_event_query.py
new file mode 100644
index 0000000..9acde7b
--- /dev/null
+++ b/tests_unit/monitors/test_event_query.py
@@ -0,0 +1,226 @@
+import pytest
+
+from libddog.monitors import event_query
+from libddog.monitors.exceptions import EventV2AlertQueryValidationError
+
+# 'empty' and 'full' states
+
+
+def test__minimal() -> None:
+    query = event_query("*error*")
+
+    assert query._state.codegen() == (
+        'events("*error*").rollup("count").last("5m") >= 1'
+    )
+
+
+def test__exhaustive() -> None:
+    query = (
+        event_query("status:warn *SIGILL*")
+        .rollup("avg", "host")
+        .by("region", "service")
+        .last("1h")
+        != 1
+    )
+
+    assert query._state.codegen() == (
+        'events("status:warn *SIGILL*").rollup("avg","host")'
+        '.by("region,service").last("1h") != 1'
+    )
+
+
+# start with the 'full' state and remove parts, covering most combinations
+
+
+def test__exhaustive__no_rollup() -> None:
+    query = event_query("status:warn *SIGILL*").by("region", "service").last("1h") != 1
+
+    assert query._state.codegen() == (
+        'events("status:warn *SIGILL*").rollup("count")'
+        '.by("region,service").last("1h") != 1'
+    )
+
+
+def test__exhaustive__rollup_without_measure() -> None:
+    query = (
+        event_query("status:warn *SIGILL*")
+        .rollup("count")
+        .by("region", "service")
+        .last("1h")
+        != 1
+    )
+
+    assert query._state.codegen() == (
+        'events("status:warn *SIGILL*").rollup("count")'
+        '.by("region,service").last("1h") != 1'
+    )
+
+
+def test__exhaustive__no_by() -> None:
+    query = event_query("status:warn *SIGILL*").rollup("avg", "host").last("1h") != 1
+
+    assert query._state.codegen() == (
+        'events("status:warn *SIGILL*").rollup("avg","host").last("1h") != 1'
+    )
+
+
+def test__exhaustive__chained_by() -> None:
+    query = (
+        event_query("status:warn *SIGILL*")
+        .rollup("avg", "host")
+        .by("region")
+        .by("service")
+        .last("1h")
+        != 1
+    )
+
+    assert query._state.codegen() == (
+        'events("status:warn *SIGILL*").rollup("avg","host")'
+        '.by("region,service").last("1h") != 1'
+    )
+
+
+def test__exhaustive__no_last() -> None:
+    query = (
+        event_query("status:warn *SIGILL*")
+        .rollup("avg", "host")
+        .by("region", "service")
+        != 1
+    )
+
+    assert query._state.codegen() == (
+        'events("status:warn *SIGILL*").rollup("avg","host")'
+        '.by("region,service").last("5m") != 1'
+    )
+
+
+def test__exhaustive__no_operator_nor_threshold() -> None:
+    query = (
+        event_query("status:warn *SIGILL*")
+        .rollup("avg", "host")
+        .by("region", "service")
+        .last("1h")
+    )
+
+    assert query._state.codegen() == (
+        'events("status:warn *SIGILL*").rollup("avg","host")'
+        '.by("region,service").last("1h") >= 1'
+    )
+
+
+# rollup
+
+
+def test__rollup_invalid_method() -> None:
+    with pytest.raises(EventV2AlertQueryValidationError) as ctx:
+        event_query("*error*").rollup("flat")
+
+    assert ctx.value.args[0] == (
+        "Rollup method 'flat' must be one of 'avg', 'cardinality', 'count'"
+    )
+
+
+# by
+
+
+def test__by_invalid_tag_name() -> None:
+    with pytest.raises(EventV2AlertQueryValidationError) as ctx:
+        event_query("*error*").by("aws region")
+
+    assert ctx.value.args[0] == "Invalid by tag name: 'aws region'"
+
+
+# last
+
+
+def test__last__invalid_input() -> None:
+    with pytest.raises(EventV2AlertQueryValidationError) as ctx:
+        event_query("*error*").last("1y")
+
+    assert ctx.value.args[0] == "last period invalid: '1y'"
+
+
+def test__last_minutes() -> None:
+    query = event_query("*error*").last("2m")
+
+    assert query._state.codegen() == (
+        'events("*error*").rollup("count").last("2m") >= 1'
+    )
+
+
+def test__last_minutes_out_of_range() -> None:
+    with pytest.raises(EventV2AlertQueryValidationError) as ctx:
+        event_query("*error*").last("0m")
+
+    assert ctx.value.args[0] == "last period out of range: '0m'"
+
+
+def test__last_hours() -> None:
+    query = event_query("*error*").last("10h")
+
+    assert query._state.codegen() == (
+        'events("*error*").rollup("count").last("10h") >= 1'
+    )
+
+
+def test__last_hours_out_of_range() -> None:
+    with pytest.raises(EventV2AlertQueryValidationError) as ctx:
+        event_query("*error*").last("49h")
+
+    assert ctx.value.args[0] == "last period out of range: '49h'"
+
+
+# operator
+
+
+def test__operator_invalid() -> None:
+    with pytest.raises(TypeError):
+        event_query("*error*") << 1  # type: ignore
+
+
+def test__operator_lt() -> None:
+    query = event_query("*error*") < 7
+
+    assert query._state.codegen() == (
+        'events("*error*").rollup("count").last("5m") < 7'
+    )
+
+
+def test__operator_le() -> None:
+    query = event_query("*error*") <= 2
+
+    assert query._state.codegen() == (
+        'events("*error*").rollup("count").last("5m") <= 2'
+    )
+
+
+def test__operator_gt() -> None:
+    query = event_query("*error*") > 19
+
+    assert query._state.codegen() == (
+        'events("*error*").rollup("count").last("5m") > 19'
+    )
+
+
+def test__operator_ge() -> None:
+    query = event_query("*error*") >= 2.3
+
+    assert query._state.codegen() == (
+        'events("*error*").rollup("count").last("5m") >= 2.3'
+    )
+
+
+def test__operator_eq() -> None:
+    query = event_query("*error*") == -4
+
+    assert query._state.codegen() == (
+        'events("*error*").rollup("count").last("5m") == -4'
+    )
+
+
+def test__operator_ne() -> None:
+    query = event_query("*error*") != 0
+
+    assert query._state.codegen() == (
+        'events("*error*").rollup("count").last("5m") != 0'
+    )
diff --git a/tests_unit/monitors/test_metric_query.py b/tests_unit/monitors/test_metric_query.py
new file mode 100644
index 0000000..5b136f4
--- /dev/null
+++ b/tests_unit/monitors/test_metric_query.py
@@ -0,0 +1,155 @@
+import pytest
+
+from libddog.metrics import Query, count_nonzero
+from libddog.monitors import avg, change, last, max, metric_query, min, pct_change, sum
+from libddog.monitors.exceptions import MetricAlertQueryValidationError
+
+# 'empty' and 'full' states
+
+
+def test__minimal() -> None:
+    query = metric_query(Query("aws.elb.requests").agg("sum"))
+
+    assert query._state.codegen() == "avg(last_5m):sum:aws.elb.requests{*} >= 1"
+
+
+def test__exhaustive() -> None:
+    query = (
+        metric_query(
+            count_nonzero(
+                Query("aws.elb.requests").agg("avg").by("az").fill("last", 45)
+            ),
+            pct_change(min(last("5m")), last("10m")),
+        )
+        < 43
+    )
+
+    assert query._state.codegen() == (
+        "pct_change(min(last_5m),last_10m):"
+        "count_nonzero(avg:aws.elb.requests{*} by {az}.fill(last, 45)) < 43"
+    )
+
+
+# rollup removal
+
+
+def test__rollup__remove() -> None:
+    query = metric_query(Query("aws.elb.requests").agg("sum").rollup("avg"))
+
+    assert query._state.codegen() == "avg(last_5m):sum:aws.elb.requests{*} >= 1"
+
+
+# time_agg
+
+
+def test__time_agg__invalid_input() -> None:
+    with pytest.raises(MetricAlertQueryValidationError) as ctx:
+        metric_query(Query("aws.elb.requests").agg("sum"), avg(last("7 seconds away")))
+
+    assert ctx.value.args[0] == "last period invalid: '7 seconds away'"
+
+
+def test__time_agg__minutes() -> None:
+    query = metric_query(Query("aws.elb.requests").agg("sum"), avg(last("2m")))
+
+    assert query._state.codegen() == "avg(last_2m):sum:aws.elb.requests{*} >= 1"
+
+
+def test__time_agg__minutes_out_of_range() -> None:
+    with pytest.raises(MetricAlertQueryValidationError) as ctx:
+        metric_query(Query("aws.elb.requests").agg("sum"), avg(last("10081m")))
+
+    assert ctx.value.args[0] == "last period out of range: '10081m'"
+
+
+def test__time_agg__hours() -> None:
+    query = metric_query(Query("aws.elb.requests").agg("sum"), min(last("7h")))
+
+    assert query._state.codegen() == "min(last_7h):sum:aws.elb.requests{*} >= 1"
+
+
+def test__time_agg__hours_out_of_range() -> None:
+    with pytest.raises(MetricAlertQueryValidationError) as ctx:
+        metric_query(Query("aws.elb.requests").agg("sum"), avg(last("169h")))
+
+    assert ctx.value.args[0] == "last period out of range: '169h'"
+
+
+def test__time_agg__days() -> None:
+    query = metric_query(Query("aws.elb.requests").agg("sum"), max(last("1d")))
+
+    assert query._state.codegen() == "max(last_1d):sum:aws.elb.requests{*} >= 1"
+
+
+def test__time_agg__days_out_of_range() -> None:
+    with pytest.raises(MetricAlertQueryValidationError) as ctx:
+        metric_query(Query("aws.elb.requests").agg("sum"), avg(last("2d")))
+
+    assert ctx.value.args[0] == "last period invalid: '2d'"
+
+
+def test__time_agg__weeks() -> None:
+    query = metric_query(Query("aws.elb.requests").agg("sum"), sum(last("1w")))
+
+    assert query._state.codegen() == "sum(last_1w):sum:aws.elb.requests{*} >= 1"
+
+
+def test__time_agg__weeks_out_of_range() -> None:
+    with pytest.raises(MetricAlertQueryValidationError) as ctx:
+        metric_query(Query("aws.elb.requests").agg("sum"), avg(last("2w")))
+
+    assert ctx.value.args[0] == "last period invalid: '2w'"
+
+
+def test__time_agg__change() -> None:
+    query = metric_query(
+        Query("aws.elb.requests").agg("sum"), change(avg(last("20m")), last("4m"))
+    )
+
+    assert query._state.codegen() == (
+        "change(avg(last_20m),last_4m):sum:aws.elb.requests{*} >= 1"
+    )
+
+
+# operator
+
+
+def test__operator_invalid() -> None:
+    with pytest.raises(TypeError):
+        metric_query(Query("aws.elb.requests").agg("sum")) << 1  # type: ignore
+
+
+def test__operator_lt() -> None:
+    query = metric_query(Query("aws.elb.requests").agg("sum")) < 7
+
+    assert query._state.codegen() == "avg(last_5m):sum:aws.elb.requests{*} < 7"
+
+
+def test__operator_le() -> None:
+    query = metric_query(Query("aws.elb.requests").agg("sum")) <= 2
+
+    assert query._state.codegen() == "avg(last_5m):sum:aws.elb.requests{*} <= 2"
+
+
+def test__operator_gt() -> None:
+    query = metric_query(Query("aws.elb.requests").agg("sum")) > 17
+
+    assert query._state.codegen() == "avg(last_5m):sum:aws.elb.requests{*} > 17"
+
+
+def test__operator_ge() -> None:
+    query = metric_query(Query("aws.elb.requests").agg("sum")) >= 4.13
+
+    assert query._state.codegen() == "avg(last_5m):sum:aws.elb.requests{*} >= 4.13"
+
+
+def test__operator_eq() -> None:
+    query = metric_query(Query("aws.elb.requests").agg("sum")) == -2
+
+    assert query._state.codegen() == "avg(last_5m):sum:aws.elb.requests{*} == -2"
+
+
+def test__operator_ne() -> None:
+    query = metric_query(Query("aws.elb.requests").agg("sum")) != -1
+
+    assert query._state.codegen() == "avg(last_5m):sum:aws.elb.requests{*} != -1"
diff --git a/tests_unit/monitors/test_monitor_options.py b/tests_unit/monitors/test_monitor_options.py
new file mode 100644
index 0000000..06f22b7
--- /dev/null
+++ b/tests_unit/monitors/test_monitor_options.py
@@ -0,0 +1,126 @@
+from libddog.metrics import Query, count_nonzero
+from libddog.monitors import (
+    Monitor,
+    MonitorOptions,
+    MonitorThresholds,
+    RenotifyStatus,
+    event_query,
+    last,
+    metric_query,
+    min,
+    pct_change,
+)
+
+
+def test__minimal() -> None:
+    monitor = Monitor(
+        name="minimal monitor options",
+        message="minimal monitor options",
+        query=event_query("*error*"),
+    )
+
+    assert monitor.as_dict() == {
+        "id": None,
+        "message": "minimal monitor options",
+        "name": "minimal monitor options",
+        "options": {
+            "enable_logs_sample": None,
+            "escalation_message": None,
+            "evaluation_delay": None,
+            "groupby_simple_monitor": False,
+            "include_tags": True,
+            "new_group_delay": None,
+            "no_data_timeframe": None,
+            "notify_audit": False,
+            "notify_no_data": False,
+            "renotify_interval": None,
+            "renotify_occurrences": None,
+            "renotify_statuses": None,
+            "require_full_window": False,
+            "threshold_windows": None,
+            "thresholds": None,
+            "timeout_h": None,
+        },
+        "priority": None,
+        "query": 'events("*error*").rollup("count").last("5m") >= 1',
+        "restricted_roles": None,
+        "tags": [],
+        "type": "event-v2 alert",
+    }
+
+
+def test__exhaustive() -> None:
+    query = (
+        metric_query(
+            count_nonzero(Query("aws.elb.requests").agg("avg").by("region").by("az")),
+            pct_change(min(last("5m")), last("10m")),
+        )
+        > 43
+    )
+
+    monitor = Monitor(
+        name="exhaustive monitor options",
+        message="exhaustive monitor options",
+        query=query,
+        options=MonitorOptions(
+            enable_logs_sample=True,
+            escalation_message="this is the escalation message",
+            evaluation_delay=901,
+            groupby_simple_monitor=False,
+            include_tags=True,
+            new_group_delay=65,
+            no_data_timeframe=10,
+            notify_audit=True,
+            notify_no_data=False,
+            renotify_interval=1200,
+            renotify_occurrences=3,
+            renotify_statuses=[RenotifyStatus.WARN],
+            require_full_window=True,
+            thresholds=MonitorThresholds(
+                critical=43,
+                critical_recovery=40,
+                ok=10,
+                warning=35,
+                warning_recovery=30,
+            ),
+            timeout_h=2,
+        ),
+        priority=5,
+        tags=["owner:libddog"],
+    )
+
+    assert monitor.as_dict() == {
+        "id": None,
+        "message": "exhaustive monitor options",
+        "name": "exhaustive monitor options",
+        "options": {
+            "enable_logs_sample": True,
+            "escalation_message": "this is the escalation message",
+            "evaluation_delay": 901,
+            "groupby_simple_monitor": False,
+            "include_tags": True,
+            "new_group_delay": 65,
+            "no_data_timeframe": 10,
+            "notify_audit": True,
+            "notify_no_data": False,
+            "renotify_interval": 1200,
+            "renotify_occurrences": 3,
+            "renotify_statuses": ["warn"],
+            "require_full_window": True,
+            "threshold_windows": None,
+            "thresholds": {
+                "critical": 43,
+                "critical_recovery": 40,
+                "ok": 10,
+                "warning": 35,
+                "warning_recovery": 30,
+            },
+            "timeout_h": 2,
+        },
+        "priority": 5,
+        "query": "pct_change(min(last_5m),last_10m):count_nonzero(avg:aws.elb.requests{*} "
+        "by {region, az}) > 43",
+        "restricted_roles": None,
+        "tags": ["owner:libddog"],
+        "type": "query alert",
+    }