chmonitor

Contributing a config / check

How to add a declarative monitoring check to the chmonitor catalog.

A declarative query config is a monitoring check expressed as data — a plain TypeScript object that the dashboard validates against a Zod schema and renders as a table or card view. No JSX, no functions, no component imports. This page walks through the contract, the safety rules, and the step-by-step flow for adding one.

Current status: opt-in

The declarative catalog is opt-in. Set CHM_CONFIG_SOURCE=declarative to route runtime lookups to it. The default (CHM_CONFIG_SOURCE=ts) still uses the TypeScript query configs. A drop-in community catalog directory (where contributions live outside the repo) is planned but not yet built. For now, contributing a check means adding a file to the repo's catalog directory and opening a PR.

The contract

Every declarative config must satisfy the schema in:

apps/dashboard/src/lib/query-config/declarative/schema.ts

Required fields:

FieldTypeNotes
namestringUnique across the catalog. Used as the lookup key.
sqlstring | VersionedSql[]A plain SQL string, or an array of { since, sql } entries for version-aware queries.
columnsstring[]Column names returned by the query, in display order. Must have at least one entry.

Commonly used optional fields:

FieldTypeNotes
descriptionstringShort human-readable description shown in the dashboard.
columnFormatsRecord<string, ColumnFormat>Display format per column. Allowed values: badge, duration, number, number-short, background-bar, colored-badge, boolean, code, text, none, and others — see schema for the full list.
columnDescriptionsRecord<string, string>Tooltip text per column.
defaultParamsRecord<string, string | number | boolean>Default values for {param: Type} placeholders in the SQL.
optionalbooleanWhen true, the dashboard skips gracefully if the target table does not exist (default: false).
tableCheckstring | string[]Table(s) to check for existence before running the query. Useful with optional: true.
refreshIntervalnumberAuto-refresh interval in milliseconds.
relatedCharts(string | [string, Record])[]Chart names (or [name, params] tuples) to render above the table.
card{ primary?, badges?, metrics?, hidden? }Card view layout hints.
defaultView'table' | 'cards' | 'auto'Default display mode.
docsstringHelp text shown when the table is missing (a sentence with links — not required to be a bare URL).
suggestionstringA short suggestion/tip rendered with the view.
columnSizingRecord<string, { size?, minSize?, maxSize? }>Per-column width hints (pixels).
tableBehavior{ enableColumnResizing?, enableSorting?, … }Table interaction toggles.
sortingFnsRecord<string, SortingFn>Named sort function per column. Allowed: sort_column_using_pct, sort_column_using_pct_inverted, sort_column_using_actual_value.
clickhouseSettingsRecord<string, string | number | boolean>Execution-time ClickHouse settings applied per query (e.g. { allow_introspection_functions: 1 }).
rowStyle{ rules: [{ when, className }], default? }Conditional row CSS classes — the declarative replacement for rowClassName. See Conditional row styling.
permission{ feature, defaultAccess?, operation? }Feature-permission gate. See Feature permissions.
bulkActions / bulkActionKeystring[] / stringBulk row actions and the row key they operate on.

Fields intentionally absent from the declarative format (runtime-only, cannot be serialized): expandable, columnIcons, filterSchema. If your check needs any of these, it must stay as a TypeScript config for now.

Two former exclusions are now data

rowClassName is now expressible via rowStyle rules, and permission via the permission field.

Safety rule: SELECT only

All checks must be read-only. The schema validator and the SQL validator in apps/dashboard/src/lib/api/shared/validators/sql.ts reject DDL and DML (CREATE, INSERT, DROP, ALTER, TRUNCATE, etc.). Do not set disableSqlValidation: true on contributed checks.

Worked example

A minimal check that surfaces the last 100 slow queries. Based on the real query-count-baseline pattern in the anomaly catalog:

import type { DeclarativeQueryConfig } from '../../schema'

export const slowQueriesRecentDeclarative: DeclarativeQueryConfig = {
  name: 'slow-queries-recent',
  description: 'Queries that took longer than 5 seconds in the last hour',
  sql: [
    {
      since: '23.8',
      sql: `
        SELECT
          query_id,
          user,
          query_duration_ms,
          formatReadableTimeDelta(query_duration_ms / 1000) AS readable_duration,
          round(memory_usage / 1048576, 1) AS memory_mb,
          left(query, 200) AS query_snippet,
          event_time
        FROM system.query_log
        WHERE type = 'QueryFinish'
          AND query_duration_ms > 5000
          AND event_time >= now() - INTERVAL 1 HOUR
        ORDER BY query_duration_ms DESC
        LIMIT 100
      `,
    },
  ],
  columns: [
    'query_id',
    'user',
    'query_duration_ms',
    'readable_duration',
    'memory_mb',
    'query_snippet',
    'event_time',
  ],
  columnFormats: {
    query_duration_ms: 'number',
    memory_mb: 'number',
    query_snippet: 'code',
  },
  columnDescriptions: {
    query_duration_ms: 'Wall-clock duration in milliseconds',
    memory_mb: 'Peak memory usage in MiB',
  },
  defaultParams: {},
  optional: false,
}

The field names above (name, sql[].since, sql[].sql, columns, columnFormats, columnDescriptions, optional) map directly to declarativeQueryConfigSchema in schema.ts.

Step-by-step: adding a check

Pick the right domain directory

Existing domains under apps/dashboard/src/lib/query-config/declarative/catalog/: anomaly, explorer, keeper, logs, merges, more, queries, security, system, tables. Add a new file to the closest domain, or create a new subdirectory if the check doesn't fit any existing one.

Write the config

Export a const typed as DeclarativeQueryConfig. Use sql: [{ since: 'X.Y', sql: '...' }] when querying a system column that was added in a specific ClickHouse version (check docs/clickhouse-schemas/tables/ for column availability). Use a plain sql: '...' string when the query works across all supported versions.

Register it in the catalog index

Import your export in apps/dashboard/src/lib/query-config/declarative/catalog/index.ts and add it to the ALL_DECLARATIVE array. The index enforces unique names at module load time and throws if a duplicate is detected.

Validate locally

pnpm run test:query-config

# Biome lint (must pass before PR)
pnpm run check

The test:query-config suite runs the schema validator against every catalog entry and compares serializable fields against the legacy TS configs for migrated checks.

Note the opt-in flag

Your check is live in the catalog but the dashboard only reads from it when CHM_CONFIG_SOURCE=declarative is set in the environment. The default is ts. Document this in your PR if relevant.

Open a PR

Open a PR against main. The CI unit-tests job runs the full bun test suite including the declarative catalog tests.

Version-aware SQL

When a system table column was introduced in a specific ClickHouse release, use the sql array instead of a plain string. The loader picks the entry whose since version is the highest that is still ≤ the connected cluster's version.

sql: [
  {
    since: '23.8',
    sql: `SELECT col1 FROM system.query_log LIMIT 10`,
  },
  {
    since: '24.1',
    sql: `SELECT col1, new_col FROM system.query_log LIMIT 10`,
    description: 'Added new_col (available from 24.1)',
  },
],

Version strings must match X.Y or X.Y.Z (e.g. "23.8", "24.1", "25.6.0"). The description field on each entry is optional but encouraged.

Optional tables

If your check queries a table that may not exist (e.g. system.backup_log, system.zookeeper, system.error_log), set optional: true and provide a tableCheck:

optional: true,
tableCheck: 'system.backup_log',

The dashboard will surface a graceful "table not available" message instead of an error.

Conditional row styling

To highlight rows based on their data (e.g. errored or stuck rows), use rowStyle instead of a rowClassName function. It is an ordered list of { when, className } rules; the first matching rule's className wins, and default (or nothing) applies when no rule matches.

rowStyle: {
  rules: [
    // first match wins — put the most severe condition first
    { when: { column: 'is_stuck', op: 'truthy' }, className: 'bg-red-50 dark:bg-red-950/20' },
    {
      when: {
        all: [
          { column: 'is_done', op: 'falsy' },
          { column: 'elapsed', op: 'gt', value: 300 },
        ],
      },
      className: 'bg-amber-50 dark:bg-amber-950/20',
    },
  ],
  default: '',
},

Condition operators:

OperatorMeaningCoercion
gt / gte / lt / ltenumeric comparison against valuecell coerced with Number()
truthy / falsynumeric truthiness (non-zero / zero)cell coerced with Number()
nonempty / emptystring is non-empty / emptycell coerced with String()
all / anyAND / OR of nested conditions

Comparison operators (gt/gte/lt/lte) require a numeric value. className is any Tailwind class string.

Feature permissions

To gate a view behind a feature, set permission. This is plain data (no import needed) mirroring the app's FeaturePermission:

permission: { feature: 'queries' },
// or, with explicit access/operation:
permission: { feature: 'metrics', defaultAccess: 'authenticated', operation: 'read' },

feature must be one of the known feature ids (overview, agent, insights, health, queries, tables, metrics, dashboard, billing, security, logs, settings, cluster, operations, actions, mcp, peerdb, docs, about). defaultAccess is public | authenticated; operation is read | write. Both are optional.

Local overrides (self-hosted only)

Everything above describes contributing a check to the repo's own catalog via a PR. There's a second, lower-friction path for a self-hosted operator who wants a custom check on their own instance without forking the repo: mount a directory of *.yaml files and point CHM_CONFIG_DIRECTORY at it (default /etc/chmonitor/queries.d).

At startup, every *.yaml file directly inside that directory is validated against the same schema described above. A file that fails validation (bad YAML syntax, a schema violation, or a name that collides with another file in the directory) is skipped with a warning in the logs — it never blocks boot. This is checked before CHM_CONFIG_SOURCE is even consulted, so a local file with the same name as a built-in ts or declarative check overrides it regardless of that setting; a name that doesn't match any built-in check adds a new one.

# /etc/chmonitor/queries.d/my-check.yaml
name: my-custom-check
description: Something specific to my cluster
sql: "SELECT hostName(), uptime()"
columns: [hostname, uptime_seconds]

Requires a real filesystem, so it works on Docker and Kubernetes but not the Cloudflare Worker deploy. docker-compose.yml ships a ready-made, commented-out example:

volumes:
  - ./queries.d:/etc/chmonitor/queries.d:ro

The directory is scanned once per process — restart the container/pod to pick up added, changed, or removed files.

On this page