diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index 682f2a6..8c4fe61 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -15,6 +15,18 @@ permissions: jobs: test: services: + mongodb: + image: mongo:8.0 + env: + MONGO_INITDB_ROOT_USERNAME: test_user + MONGO_INITDB_ROOT_PASSWORD: test_pass + ports: + - 27017:27017 + options: >- + --health-cmd="mongosh --quiet --eval 'db.adminCommand({ping: 1})'" + --health-interval=5s + --health-timeout=5s + --health-retries=10 db: image: postgres:12 ports: ["5432:5432"] @@ -123,6 +135,7 @@ jobs: - name: Seed adapter test databases run: | + docker exec -i ${{ job.services.mongodb.id }} mongosh --quiet -u test_user -p test_pass --authenticationDatabase admin < test/support/adapters/mongodb-seed.js PGPASSWORD=postgres psql -h localhost -U postgres -d postgres -c "CREATE DATABASE test_db;" PGPASSWORD=postgres psql -h localhost -U postgres -d test_db -f test/support/adapters/postgresql-seed.sql mysql -h 127.0.0.1 -uroot -proot_pass -e "CREATE DATABASE IF NOT EXISTS test_db;" diff --git a/README.md b/README.md index a581e01..ad39e70 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ QueryCanary is a lightweight tool for monitoring your production data using SQL. ## What It Does - ✅ Run SQL checks against your production database +- ✅ Query MongoDB with JSON find, count, distinct and aggregation commands - ✅ Schedule checks with simple choices, local times, or custom cron expressions - ✅ Get alerts via email or Slack when values drift or break - ✅ See historical trends and chart results over time diff --git a/assets/js/hooks/sql_editor.js b/assets/js/hooks/sql_editor.js index 5aa0df3..8596c1f 100644 --- a/assets/js/hooks/sql_editor.js +++ b/assets/js/hooks/sql_editor.js @@ -2,6 +2,7 @@ import { EditorView, basicSetup } from 'codemirror'; import { EditorState } from '@codemirror/state'; import { keymap } from "@codemirror/view" import { sql, PostgreSQL, MySQL } from '@codemirror/lang-sql'; +import { json } from '@codemirror/lang-json'; import { indentWithTab } from "@codemirror/commands"; import { acceptCompletion @@ -47,7 +48,7 @@ const SQLEditor = { } // Configure SQL extension with properly formatted schema - const sqlLang = sql({ + const sqlLang = this.el.dataset.dialect === 'mongodb' ? json() : sql({ dialect: dialect, schema: schemaData || [] }); diff --git a/assets/package-lock.json b/assets/package-lock.json index ace87a9..07a65e1 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -7,6 +7,7 @@ "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", + "@codemirror/lang-json": "^6.0.2", "@codemirror/lang-sql": "^6.8.0", "@codemirror/language": "^6.10.6", "@codemirror/lint": "^6.8.4", @@ -43,6 +44,16 @@ "@lezer/common": "^1.1.0" } }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, "node_modules/@codemirror/lang-sql": { "version": "6.8.0", "resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.8.0.tgz", @@ -134,6 +145,17 @@ "@lezer/common": "^1.0.0" } }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, "node_modules/@lezer/lr": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz", diff --git a/assets/package.json b/assets/package.json index 182d57c..1214941 100644 --- a/assets/package.json +++ b/assets/package.json @@ -2,6 +2,7 @@ "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", + "@codemirror/lang-json": "^6.0.2", "@codemirror/lang-sql": "^6.8.0", "@codemirror/language": "^6.10.6", "@codemirror/lint": "^6.8.4", diff --git a/docker-compose.adapters.yml b/docker-compose.adapters.yml index 2e13817..872e8e8 100644 --- a/docker-compose.adapters.yml +++ b/docker-compose.adapters.yml @@ -1,5 +1,15 @@ version: "3.9" services: + mongodb: + image: mongo:8.0 + environment: + MONGO_INITDB_ROOT_USERNAME: test_user + MONGO_INITDB_ROOT_PASSWORD: test_pass + ports: + - "27017:27017" + volumes: + - ./test/support/adapters/mongodb-seed.js:/docker-entrypoint-initdb.d/seed.js + postgres: image: postgres:15 restart: always diff --git a/lib/query_canary/connections/adapters/mongodb.ex b/lib/query_canary/connections/adapters/mongodb.ex new file mode 100644 index 0000000..1b1b9a3 --- /dev/null +++ b/lib/query_canary/connections/adapters/mongodb.ex @@ -0,0 +1,329 @@ +defmodule QueryCanary.Connections.Adapters.MongoDB do + @moduledoc """ + MongoDB queries expressed as JSON find, count, distinct or aggregate commands. + Cursors are fully consumed and BSON values are converted for result storage. + """ + @behaviour QueryCanary.Connections.Adapter + + @query_options %{ + "projection" => :projection, + "sort" => :sort, + "limit" => :limit, + "skip" => :skip, + "batchSize" => :batch_size, + "maxTimeMS" => :max_time, + "allowDiskUse" => :allow_disk_use, + "collation" => :collation, + "hint" => :hint, + "comment" => :comment + } + + def connect(details) do + opts = [ + hostname: details.hostname, + port: details.port || 27017, + database: details.database, + username: details.username, + password: details.password, + auth_source: details[:auth_source] || details.database, + direct_connection: details[:direct_connection] || false, + socket_options: details[:socket_options] || [], + pool_size: 1, + connect_timeout: 3_000, + timeout: 3_000 + ] + + safely(fn -> + with {:ok, pid} <- Mongo.start_link(opts ++ ssl_options(details)) do + case safely(fn -> + Mongo.command(pid, [ping: 1], timeout: 3_000, checkout_timeout: 3_000) + end) do + {:ok, _} -> + {:ok, pid} + + {:error, reason} -> + disconnect(pid) + {:error, reason} + end + end + end) + end + + def query(conn, query, params \\ [], opts \\ []) do + safely(fn -> + with {:ok, command} <- parse_query(query, params) do + execute(conn, command, query_timeout_options(opts)) + end + end) + end + + @doc "Parses JSON and substitutes whole-string positional parameters and Extended JSON values." + def parse_query(query, params \\ []) do + safely(fn -> + case Jason.decode(query) do + {:ok, command} when is_map(command) -> {:ok, decode_values(command, params)} + {:ok, _} -> {:error, "MongoDB queries must be a JSON object"} + {:error, _} -> {:error, "Invalid MongoDB JSON query"} + end + end) + end + + defp execute(conn, %{"find" => collection} = command, opts) when is_binary(collection) do + conn + |> Mongo.find(collection, command["filter"] || %{}, query_options(command, opts)) + |> format_cursor() + end + + defp execute(conn, %{"aggregate" => collection, "pipeline" => pipeline} = command, opts) + when is_binary(collection) and is_list(pipeline) do + conn + |> Mongo.aggregate(collection, pipeline, query_options(command, opts)) + |> format_cursor() + end + + defp execute(conn, %{"count" => collection} = command, opts) when is_binary(collection) do + case Mongo.count_documents( + conn, + collection, + command["query"] || %{}, + query_options(command, opts) + ) do + {:ok, count} -> {:ok, format_results([%{"value" => count}])} + {:error, reason} -> {:error, reason} + end + end + + defp execute(conn, %{"distinct" => collection, "key" => key} = command, opts) + when is_binary(collection) and is_binary(key) do + case Mongo.distinct( + conn, + collection, + key, + command["query"] || %{}, + query_options(command, opts) + ) do + {:ok, values} -> {:ok, format_results(Enum.map(values, &%{"value" => &1}))} + {:error, reason} -> {:error, reason} + end + end + + defp execute(conn, %{"ping" => 1}, opts), do: run_command(conn, [ping: 1], opts) + defp execute(conn, %{"buildInfo" => 1}, opts), do: run_command(conn, [buildInfo: 1], opts) + + defp execute(_, _, _), + do: {:error, "Use a MongoDB find, count, distinct or aggregate JSON command"} + + defp run_command(conn, command, opts) do + case Mongo.command(conn, command, opts) do + {:ok, result} -> + {:ok, format_results([Map.drop(result, ["ok", "$clusterTime", "operationTime"])])} + + {:error, reason} -> + {:error, reason} + end + end + + def list_tables(conn) do + safely(fn -> + {:ok, conn |> Mongo.show_collections(query_timeout_options([])) |> Enum.sort()} + end) + end + + def get_table_schema(conn, collection) do + safely(fn -> + case Mongo.find(conn, collection, %{}, [limit: 100] ++ query_timeout_options([])) do + {:error, reason} -> + {:error, reason} + + cursor -> + fields = + cursor + |> Enum.flat_map(fn document -> + Enum.map(document, fn {key, value} -> {key, bson_type(value)} end) + end) + |> Enum.group_by(&elem(&1, 0), &elem(&1, 1)) + |> Enum.sort() + |> Enum.map(fn {field, types} -> + %{ + label: field, + detail: types |> Enum.uniq() |> Enum.sort() |> Enum.join(" | "), + section: collection, + type: "keyword" + } + end) + + {:ok, fields} + end + end) + end + + def get_database_schema(conn, _database) do + with {:ok, collections} <- list_tables(conn) do + Enum.reduce_while(collections, {:ok, %{}}, fn collection, {:ok, schema} -> + case get_table_schema(conn, collection) do + {:ok, fields} -> {:cont, {:ok, Map.put(schema, collection, fields)}} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end + end + + def disconnect(pid) do + if Process.alive?(pid), do: GenServer.stop(pid, :normal) + :ok + catch + :exit, _ -> :ok + end + + defp query_options(command, opts) do + options = + Enum.flat_map(@query_options, fn {key, option} -> + if Map.has_key?(command, key), do: [{option, command[key]}], else: [] + end) + + options = + case command["cursor"] do + %{"batchSize" => size} -> Keyword.put(options, :batch_size, size) + _ -> options + end + + Keyword.merge(options, opts) + end + + defp query_timeout_options(opts) do + opts = Keyword.put_new(opts, :timeout, 4_000) + Keyword.put_new(opts, :checkout_timeout, opts[:timeout]) + end + + defp format_cursor({:error, reason}), do: {:error, reason} + defp format_cursor(cursor), do: {:ok, cursor |> Enum.to_list() |> format_results()} + + @doc "Converts query documents to the shared result shape with JSON-safe BSON values." + def format_results(documents) do + rows = Enum.map(documents, &normalize_value/1) + columns = rows |> Enum.flat_map(&Map.keys/1) |> Enum.uniq() |> Enum.sort() + # Scalar metrics prefer an explicitly projected value over an aggregation group id. + columns = if "value" in columns, do: ["value" | List.delete(columns, "value")], else: columns + %{rows: rows, columns: columns, original_columns: columns, num_rows: length(rows), raw: rows} + end + + defp decode_values(%{"$oid" => value} = doc, _) when map_size(doc) == 1, + do: BSON.ObjectId.decode!(value) + + defp decode_values(%{"$date" => value} = doc, _) when map_size(doc) == 1 do + case value do + value when is_integer(value) -> + DateTime.from_unix!(value, :millisecond) + + value when is_binary(value) -> + {:ok, datetime, _} = DateTime.from_iso8601(value) + datetime + end + end + + defp decode_values(%{"$numberDecimal" => value} = doc, _) when map_size(doc) == 1, + do: Decimal.new(value) + + defp decode_values(%{"$numberLong" => value} = doc, _) when map_size(doc) == 1, + do: String.to_integer(value) + + defp decode_values(doc, params) when is_map(doc), + do: Map.new(doc, fn {key, value} -> {key, decode_values(value, params)} end) + + defp decode_values(values, params) when is_list(values), + do: Enum.map(values, &decode_values(&1, params)) + + defp decode_values(value, params) when is_binary(value) do + case Regex.run(~r/^\$([1-9]\d*)$/, value) do + [_, index] -> + case Enum.fetch(params, String.to_integer(index) - 1) do + {:ok, parameter} -> parameter + :error -> raise ArgumentError, "Missing MongoDB query parameter #{value}" + end + + _ -> + value + end + end + + defp decode_values(value, _params), do: value + + defp normalize_value(%BSON.ObjectId{} = value), do: to_string(value) + defp normalize_value(%BSON.Binary{binary: value}), do: Base.encode64(value) + defp normalize_value(%DateTime{} = value), do: DateTime.to_iso8601(value) + defp normalize_value(%Decimal{} = value), do: Decimal.to_string(value) + defp normalize_value(%BSON.LongNumber{value: value}), do: value + defp normalize_value(%_{} = value), do: value |> Map.from_struct() |> normalize_value() + + defp normalize_value(value) when is_map(value), + do: Map.new(value, fn {key, value} -> {key, normalize_value(value)} end) + + defp normalize_value(value) when is_list(value), do: Enum.map(value, &normalize_value/1) + defp normalize_value(value), do: value + + defp bson_type(%BSON.ObjectId{}), do: "objectId" + defp bson_type(%DateTime{}), do: "date" + defp bson_type(%Decimal{}), do: "decimal" + defp bson_type(nil), do: "null" + defp bson_type(value) when is_boolean(value), do: "boolean" + defp bson_type(value) when is_integer(value), do: "integer" + defp bson_type(value) when is_float(value), do: "double" + defp bson_type(value) when is_binary(value), do: "string" + defp bson_type(value) when is_list(value), do: "array" + defp bson_type(_), do: "object" + + defp ssl_options(%{ssl_mode: mode} = details) + when mode in ["require", "verify-ca", "verify-full"] do + ssl_opts = + if mode == "require" do + [verify: :verify_none] + else + [ + verify: :verify_peer, + cacerts: :public_key.cacerts_get(), + server_name_indication: to_charlist(details[:tls_hostname] || details.hostname) + ] + end + + ssl_opts = + if mode == "verify-full" do + Keyword.put(ssl_opts, :customize_hostname_check, + match_fun: :public_key.pkix_verify_hostname_match_fun(:https) + ) + else + ssl_opts + end + + ssl_opts = + case details[:ssl_ca_cert] do + "-----BEGIN" <> _ = pem -> + certs = for {:Certificate, der, :not_encrypted} <- :public_key.pem_decode(pem), do: der + Keyword.put(ssl_opts, :cacerts, certs) + + path when is_binary(path) and path != "" -> + ssl_opts |> Keyword.delete(:cacerts) |> Keyword.put(:cacertfile, to_charlist(path)) + + _ -> + ssl_opts + end + + [ssl: true, ssl_opts: ssl_opts] + end + + defp ssl_options(_), do: [ssl: false] + + defp safely(fun) do + case fun.() do + {:error, reason} -> {:error, error_message(reason)} + result -> result + end + rescue + error -> {:error, Exception.message(error)} + catch + :exit, reason -> {:error, "MongoDB connection error: #{inspect(reason)}"} + end + + defp error_message(reason) when is_binary(reason), do: reason + defp error_message(%{__exception__: true} = reason), do: Exception.message(reason) + defp error_message(reason), do: inspect(reason) +end diff --git a/lib/query_canary/connections/connection_manager.ex b/lib/query_canary/connections/connection_manager.ex index edd6796..6b72c89 100644 --- a/lib/query_canary/connections/connection_manager.ex +++ b/lib/query_canary/connections/connection_manager.ex @@ -85,5 +85,7 @@ defmodule QueryCanary.Connections.ConnectionManager do defp connection_test_query(%Server{db_engine: "prometheus"}), do: QueryCanary.Connections.Adapters.Prometheus.version_query() + defp connection_test_query(%Server{db_engine: "mongodb"}), do: ~s({"ping": 1}) + defp connection_test_query(_server), do: "SELECT now()" end diff --git a/lib/query_canary/connections/connection_server.ex b/lib/query_canary/connections/connection_server.ex index 03f6ec4..2ae8e72 100644 --- a/lib/query_canary/connections/connection_server.ex +++ b/lib/query_canary/connections/connection_server.ex @@ -210,6 +210,9 @@ defmodule QueryCanary.Connections.ConnectionServer do username: server.db_username, password: server.db_password, database: server.db_name, + auth_source: server.db_auth_source, + direct_connection: server.ssh_tunnel == true, + tls_hostname: server.db_hostname, ssl_mode: server.db_ssl_mode, ssl_cert: server.db_ssl_cert, ssl_key: server.db_ssl_key, @@ -272,6 +275,9 @@ defmodule QueryCanary.Connections.ConnectionServer do defp adapter_for(%Server{db_engine: "clickhouse"}), do: QueryCanary.Connections.Adapters.ClickHouse + defp adapter_for(%Server{db_engine: "mongodb"}), + do: QueryCanary.Connections.Adapters.MongoDB + defp adapter_for(%Server{db_engine: "prometheus"}), do: QueryCanary.Connections.Adapters.Prometheus diff --git a/lib/query_canary/connections/connection_tester.ex b/lib/query_canary/connections/connection_tester.ex index f8e8298..01a87f1 100644 --- a/lib/query_canary/connections/connection_tester.ex +++ b/lib/query_canary/connections/connection_tester.ex @@ -148,6 +148,7 @@ defmodule QueryCanary.Connections.ConnectionTester do # Get appropriate version query based on DB engine defp get_version_query(%Server{db_engine: "postgresql"}), do: "SELECT version();" defp get_version_query(%Server{db_engine: "mysql"}), do: "SELECT VERSION();" + defp get_version_query(%Server{db_engine: "mongodb"}), do: ~s({"buildInfo": 1}) defp get_version_query(%Server{db_engine: "prometheus"}), do: QueryCanary.Connections.Adapters.Prometheus.version_query() diff --git a/lib/query_canary/servers/server.ex b/lib/query_canary/servers/server.ex index 1d226fb..82b9ecb 100644 --- a/lib/query_canary/servers/server.ex +++ b/lib/query_canary/servers/server.ex @@ -9,6 +9,7 @@ defmodule QueryCanary.Servers.Server do field :db_hostname, :string field :db_port, :integer field :db_name, :string + field :db_auth_source, :string field :db_username, :string field :db_password, :string, redact: true field :db_password_input, :string, virtual: true @@ -43,6 +44,7 @@ defmodule QueryCanary.Servers.Server do :db_hostname, :db_port, :db_name, + :db_auth_source, :db_username, :db_password_input, :db_ssl_mode, diff --git a/lib/query_canary_web/components/sql_editor.ex b/lib/query_canary_web/components/sql_editor.ex index d4cff9e..9eaedf8 100644 --- a/lib/query_canary_web/components/sql_editor.ex +++ b/lib/query_canary_web/components/sql_editor.ex @@ -11,6 +11,10 @@ defmodule QueryCanaryWeb.Components.SQLEditor do def render(assigns) do ~H"""
+ Enter a JSON find, count, distinct or aggregate command. + MongoDB query examples +
{@check.query}
+ {@check.query}
+ MongoDB queries can use "$1" and "$2" as date parameters for the metric window. + MongoDB examples +
<.input name="metric[granularity]" type="select" diff --git a/lib/query_canary_web/live/quickstart/check_live.ex b/lib/query_canary_web/live/quickstart/check_live.ex index 0062d5b..fb3827a 100644 --- a/lib/query_canary_web/live/quickstart/check_live.ex +++ b/lib/query_canary_web/live/quickstart/check_live.ex @@ -17,7 +17,7 @@ defmodule QueryCanaryWeb.Quickstart.CheckLive do- Write a SQL query that returns a number or boolean. This will be monitored for unexpected values or anomalies. + Write a query that returns a number or boolean. This will be monitored for unexpected values or anomalies.
<.form for={@form} id="check-form" phx-change="validate" phx-submit="save" class="">{metric_sql(@selected_metric.metric)}
+ MongoDB uses plain TCP with allow or prefer. Choose require or verify-full for TLS. +
+ <.input + :if={Phoenix.HTML.Form.input_value(@form, :db_engine) == "mongodb"} + field={@form[:db_ssl_ca_cert]} + type="textarea" + label="CA Certificate (PEM, optional)" />