Skip to content

Commit fce4fd0

Browse files
[DBMON-6432] ClickHouse Parts & Merges support (DataDog#23361)
* Adding parts and merges support * Cleanup comments * Address codex comments * Fixing CI * Make stalled-merge and stuck-replication thresholds configurable Expose STALLED_MERGE_ELAPSED_THRESHOLD_SECONDS and STUCK_REPLICATION_NUM_TRIES as parts_and_merges config options so operators can tune them to match their cluster behaviour without rebuilding the integration. parts_and_merges: stalled_merge_elapsed_threshold_seconds: 3600 # default: 1 h stuck_replication_num_tries: 3 # default: 3 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Address comment around payload schema * Adding two new thresholds for merge times --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 997db3f commit fce4fd0

13 files changed

Lines changed: 2343 additions & 0 deletions

File tree

clickhouse/assets/configuration/spec.yaml

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,99 @@ files:
296296
value:
297297
type: boolean
298298
example: false
299+
- name: parts_and_merges
300+
description: Configure parts and merges monitoring
301+
options:
302+
- name: enabled
303+
description: |
304+
Enable collection of parts and merges monitoring. Requires `dbm: true`.
305+
Emits per-table gauge metrics (clickhouse.table.parts.*, clickhouse.merges.*, clickhouse.mutations.*,
306+
clickhouse.replication.*) for trend dashboards and alerting, plus a row-level event payload per
307+
collection cycle consumed by the DBM Storage Health timeline view.
308+
value:
309+
type: boolean
310+
example: true
311+
- name: collection_interval
312+
description: |
313+
Set the parts and merges collection interval (in seconds). All four system tables
314+
(parts, merges, mutations, replication_queue) are queried on each run.
315+
value:
316+
type: number
317+
example: 60
318+
- name: max_parts_rows
319+
description: |
320+
Maximum number of rows to include in the per-cycle event payload from system.parts,
321+
ordered by active_part_count descending. With `table_metrics_include_partition_tag`
322+
disabled (default) rows are aggregated per-table server-side, so this caps tables.
323+
When partition tagging is enabled, rows are per-partition and this caps partitions.
324+
value:
325+
type: integer
326+
example: 500
327+
- name: max_mutations_rows
328+
description: |
329+
Maximum number of rows to include in the per-cycle event payload from system.mutations,
330+
ordered by create_time ascending. Caps payload size when many mutations are pending.
331+
value:
332+
type: integer
333+
example: 200
334+
- name: max_detached_parts_rows
335+
description: |
336+
Maximum number of rows returned from system.detached_parts per cycle.
337+
Applied as a raw SQL LIMIT; increase only if your cluster has a pathological number
338+
of (database, table, reason) combinations in detached storage.
339+
value:
340+
type: integer
341+
example: 1000
342+
- name: max_replication_queue_rows
343+
description: |
344+
Maximum number of rows returned from system.replication_queue per cycle,
345+
ordered by position ascending. Busy replicated clusters can exceed the default
346+
during incidents; raise this if replication queue backlogs are being truncated.
347+
value:
348+
type: integer
349+
example: 1000
350+
- name: table_metrics_include_partition_tag
351+
description: |
352+
Add a partition tag to parts gauges (clickhouse.table.parts.*).
353+
Disabled by default to avoid high cardinality: a table partitioned by day over two years
354+
yields ~730 partitions and hundreds of additional metric series per table.
355+
Enable only when per-partition alerting is explicitly needed.
356+
value:
357+
type: boolean
358+
example: false
359+
- name: table_metrics_max_tables
360+
description: |
361+
Cap per-table gauge emission to the top N tables by active part count.
362+
Guards against pathological schemas with very large numbers of tables or partitions.
363+
value:
364+
type: integer
365+
example: 200
366+
- name: stalled_merge_elapsed_threshold_seconds
367+
description: |
368+
Number of seconds a merge must be running before it is counted as stalled
369+
in the `clickhouse.merges.stalled` gauge. Raise this threshold if long-running
370+
merges are expected in your environment (e.g. large Wide-part merges on spinning
371+
disks) to avoid false-positive stall alerts.
372+
value:
373+
type: integer
374+
example: 3600
375+
- name: stuck_replication_num_tries
376+
description: |
377+
Minimum number of failed attempts (num_tries) before a replication queue entry
378+
is counted as stuck in the `clickhouse.replication.stuck` gauge. Entries below
379+
this threshold are still counted in `clickhouse.replication.queue.depth`.
380+
Increase this value if transient failures routinely exceed the default before
381+
self-healing.
382+
value:
383+
type: integer
384+
example: 3
385+
- name: run_sync
386+
hidden: true
387+
description: |
388+
Run the parts and merges collection synchronously. For testing only.
389+
value:
390+
type: boolean
391+
example: false
299392
- template: instances/db
300393
overrides:
301394
custom_queries.value.example:

clickhouse/changelog.d/23361.added

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add parts and merges monitoring (DBM): per-table gauges for parts, merges, mutations, and replication queue health, plus a per-cycle row-level event payload for the DBM Storage Health timeline view.

clickhouse/datadog_checks/clickhouse/clickhouse.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from .__about__ import __version__
1818
from .config import build_config, sanitize
1919
from .health import ClickhouseHealth, HealthEvent, HealthStatus
20+
from .parts_and_merges import ClickhousePartsAndMerges
2021
from .query_completions import ClickhouseQueryCompletions
2122
from .query_errors import ClickhouseQueryErrors
2223
from .statement_samples import ClickhouseStatementSamples
@@ -127,6 +128,12 @@ def _init_dbm_components(self):
127128
else:
128129
self.query_errors = None
129130

131+
# Initialize parts and merges monitoring (from system.parts, merges, mutations, replication_queue)
132+
if self._config.dbm and self._config.parts_and_merges.enabled:
133+
self.parts_and_merges = ClickhousePartsAndMerges(self, self._config.parts_and_merges)
134+
else:
135+
self.parts_and_merges = None
136+
130137
@property
131138
def tags(self) -> list[str]:
132139
"""Return the current list of tags from the TagManager."""
@@ -255,6 +262,10 @@ def check(self, _):
255262
if self.query_errors:
256263
self.query_errors.run_job_loop(self.tags)
257264

265+
# Run parts and merges monitoring if enabled
266+
if self.parts_and_merges:
267+
self.parts_and_merges.run_job_loop(self.tags)
268+
258269
@AgentCheck.metadata_entrypoint
259270
def collect_version(self):
260271
version = list(self.execute_query_raw('SELECT version()'))[0][0]
@@ -474,6 +485,8 @@ def cancel(self):
474485
self.query_completions.cancel()
475486
if self.query_errors:
476487
self.query_errors.cancel()
488+
if self.parts_and_merges:
489+
self.parts_and_merges.cancel()
477490

478491
# Wait for job loops to finish
479492
if self.statement_metrics and self.statement_metrics._job_loop_future:
@@ -484,6 +497,8 @@ def cancel(self):
484497
self.query_completions._job_loop_future.result()
485498
if self.query_errors and self.query_errors._job_loop_future:
486499
self.query_errors._job_loop_future.result()
500+
if self.parts_and_merges and self.parts_and_merges._job_loop_future:
501+
self.parts_and_merges._job_loop_future.result()
487502

488503
# Close main client
489504
if self._client:

clickhouse/datadog_checks/clickhouse/config.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,10 @@ def build_config(check: ClickhouseCheck) -> Tuple[InstanceConfig, ValidationResu
124124
**dict_defaults.instance_query_errors().model_dump(),
125125
**(instance.get('query_errors', {})),
126126
},
127+
"parts_and_merges": {
128+
**dict_defaults.instance_parts_and_merges().model_dump(),
129+
**(instance.get('parts_and_merges', {})),
130+
},
127131
# Tags - ensure we have a list, not None
128132
"tags": list(instance.get('tags', [])),
129133
# Other settings
@@ -213,6 +217,30 @@ def _apply_validated_defaults(args: dict, instance: dict, validation_result: Val
213217
f"query_errors.samples_per_hour_per_query must be greater than 0, defaulting to {default_value}."
214218
)
215219

220+
if _safefloat(args.get('parts_and_merges', {}).get('collection_interval')) <= 0:
221+
default_value = dict_defaults.instance_parts_and_merges().collection_interval
222+
args['parts_and_merges']['collection_interval'] = default_value
223+
validation_result.add_warning(
224+
f"parts_and_merges.collection_interval must be greater than 0, defaulting to {default_value} seconds."
225+
)
226+
227+
_pm_defaults = dict_defaults.instance_parts_and_merges()
228+
for _field in (
229+
'max_parts_rows',
230+
'max_mutations_rows',
231+
'max_detached_parts_rows',
232+
'max_replication_queue_rows',
233+
'table_metrics_max_tables',
234+
'stalled_merge_elapsed_threshold_seconds',
235+
'stuck_replication_num_tries',
236+
):
237+
if _safefloat(args.get('parts_and_merges', {}).get(_field)) <= 0:
238+
default_value = getattr(_pm_defaults, _field)
239+
args['parts_and_merges'][_field] = default_value
240+
validation_result.add_warning(
241+
f"parts_and_merges.{_field} must be greater than 0, defaulting to {default_value}."
242+
)
243+
216244

217245
def _validate_config(config: InstanceConfig, instance: dict, validation_result: ValidationResult):
218246
"""Validate the configuration and add warnings/errors."""
@@ -229,6 +257,7 @@ def _validate_config(config: InstanceConfig, instance: dict, validation_result:
229257
config.query_completions.enabled if config.query_completions else False,
230258
),
231259
('query_errors', config.query_errors.enabled if config.query_errors else False),
260+
('parts_and_merges', config.parts_and_merges.enabled if config.parts_and_merges else False),
232261
]
233262
for feature_name, _is_enabled in dbm_features:
234263
if instance.get(feature_name, {}).get('enabled') and not config.dbm:
@@ -270,6 +299,11 @@ def _apply_features(config: InstanceConfig, validation_result: ValidationResult)
270299
config.query_errors.enabled and config.dbm,
271300
None if config.dbm else "Requires `dbm: true`",
272301
)
302+
validation_result.add_feature(
303+
FeatureKey.PARTS_AND_MERGES,
304+
config.parts_and_merges.enabled and config.dbm,
305+
None if config.dbm else "Requires `dbm: true`",
306+
)
273307
validation_result.add_feature(FeatureKey.SINGLE_ENDPOINT_MODE, config.single_endpoint_mode)
274308

275309

clickhouse/datadog_checks/clickhouse/config_models/dict_defaults.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,19 @@ def instance_query_errors():
5555
max_samples_per_collection=1000,
5656
run_sync=False,
5757
)
58+
59+
60+
def instance_parts_and_merges():
61+
return instance.PartsAndMerges(
62+
enabled=True,
63+
collection_interval=60,
64+
max_parts_rows=500,
65+
max_mutations_rows=200,
66+
max_detached_parts_rows=1000,
67+
max_replication_queue_rows=1000,
68+
run_sync=False,
69+
table_metrics_include_partition_tag=False,
70+
table_metrics_max_tables=200,
71+
stalled_merge_elapsed_threshold_seconds=3600,
72+
stuck_replication_num_tries=3,
73+
)

clickhouse/datadog_checks/clickhouse/config_models/instance.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,24 @@ class MetricPatterns(BaseModel):
5252
include: Optional[tuple[str, ...]] = None
5353

5454

55+
class PartsAndMerges(BaseModel):
56+
model_config = ConfigDict(
57+
arbitrary_types_allowed=True,
58+
frozen=True,
59+
)
60+
collection_interval: Optional[float] = None
61+
enabled: Optional[bool] = None
62+
max_detached_parts_rows: Optional[int] = None
63+
max_mutations_rows: Optional[int] = None
64+
max_parts_rows: Optional[int] = None
65+
max_replication_queue_rows: Optional[int] = None
66+
run_sync: Optional[bool] = None
67+
stalled_merge_elapsed_threshold_seconds: Optional[int] = None
68+
stuck_replication_num_tries: Optional[int] = None
69+
table_metrics_include_partition_tag: Optional[bool] = None
70+
table_metrics_max_tables: Optional[int] = None
71+
72+
5573
class QueryCompletions(BaseModel):
5674
model_config = ConfigDict(
5775
arbitrary_types_allowed=True,
@@ -121,6 +139,7 @@ class InstanceConfig(BaseModel):
121139
metric_patterns: Optional[MetricPatterns] = None
122140
min_collection_interval: Optional[float] = None
123141
only_custom_queries: Optional[bool] = None
142+
parts_and_merges: Optional[PartsAndMerges] = None
124143
password: Optional[str] = None
125144
port: Optional[int] = None
126145
query_completions: Optional[QueryCompletions] = None

clickhouse/datadog_checks/clickhouse/data/conf.yaml.example

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,83 @@ instances:
197197
#
198198
# samples_per_hour_per_query: 60
199199

200+
## Configure parts and merges monitoring
201+
#
202+
# parts_and_merges:
203+
204+
## @param enabled - boolean - optional - default: true
205+
## Enable collection of parts and merges monitoring. Requires `dbm: true`.
206+
## Emits per-table gauge metrics (clickhouse.table.parts.*, clickhouse.merges.*, clickhouse.mutations.*,
207+
## clickhouse.replication.*) for trend dashboards and alerting, plus a row-level event payload per
208+
## collection cycle consumed by the DBM Storage Health timeline view.
209+
#
210+
# enabled: true
211+
212+
## @param collection_interval - number - optional - default: 60
213+
## Set the parts and merges collection interval (in seconds). All four system tables
214+
## (parts, merges, mutations, replication_queue) are queried on each run.
215+
#
216+
# collection_interval: 60
217+
218+
## @param max_parts_rows - integer - optional - default: 500
219+
## Maximum number of rows to include in the per-cycle event payload from system.parts,
220+
## ordered by active_part_count descending. With `table_metrics_include_partition_tag`
221+
## disabled (default) rows are aggregated per-table server-side, so this caps tables.
222+
## When partition tagging is enabled, rows are per-partition and this caps partitions.
223+
#
224+
# max_parts_rows: 500
225+
226+
## @param max_mutations_rows - integer - optional - default: 200
227+
## Maximum number of rows to include in the per-cycle event payload from system.mutations,
228+
## ordered by create_time ascending. Caps payload size when many mutations are pending.
229+
#
230+
# max_mutations_rows: 200
231+
232+
## @param max_detached_parts_rows - integer - optional - default: 1000
233+
## Maximum number of rows returned from system.detached_parts per cycle.
234+
## Applied as a raw SQL LIMIT; increase only if your cluster has a pathological number
235+
## of (database, table, reason) combinations in detached storage.
236+
#
237+
# max_detached_parts_rows: 1000
238+
239+
## @param max_replication_queue_rows - integer - optional - default: 1000
240+
## Maximum number of rows returned from system.replication_queue per cycle,
241+
## ordered by position ascending. Busy replicated clusters can exceed the default
242+
## during incidents; raise this if replication queue backlogs are being truncated.
243+
#
244+
# max_replication_queue_rows: 1000
245+
246+
## @param table_metrics_include_partition_tag - boolean - optional - default: false
247+
## Add a partition tag to parts gauges (clickhouse.table.parts.*).
248+
## Disabled by default to avoid high cardinality: a table partitioned by day over two years
249+
## yields ~730 partitions and hundreds of additional metric series per table.
250+
## Enable only when per-partition alerting is explicitly needed.
251+
#
252+
# table_metrics_include_partition_tag: false
253+
254+
## @param table_metrics_max_tables - integer - optional - default: 200
255+
## Cap per-table gauge emission to the top N tables by active part count.
256+
## Guards against pathological schemas with very large numbers of tables or partitions.
257+
#
258+
# table_metrics_max_tables: 200
259+
260+
## @param stalled_merge_elapsed_threshold_seconds - integer - optional - default: 3600
261+
## Number of seconds a merge must be running before it is counted as stalled
262+
## in the `clickhouse.merges.stalled` gauge. Raise this threshold if long-running
263+
## merges are expected in your environment (e.g. large Wide-part merges on spinning
264+
## disks) to avoid false-positive stall alerts.
265+
#
266+
# stalled_merge_elapsed_threshold_seconds: 3600
267+
268+
## @param stuck_replication_num_tries - integer - optional - default: 3
269+
## Minimum number of failed attempts (num_tries) before a replication queue entry
270+
## is counted as stuck in the `clickhouse.replication.stuck` gauge. Entries below
271+
## this threshold are still counted in `clickhouse.replication.queue.depth`.
272+
## Increase this value if transient failures routinely exceed the default before
273+
## self-healing.
274+
#
275+
# stuck_replication_num_tries: 3
276+
200277
## @param only_custom_queries - boolean - optional - default: false
201278
## Set this parameter to `true` if you want to skip the integration's default metrics collection.
202279
## Only metrics specified in `custom_queries` will be collected.

clickhouse/datadog_checks/clickhouse/features.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class FeatureKey(Enum):
2323
QUERY_COMPLETIONS = "query_completions"
2424
EXPLAIN_PLANS = "explain_plans"
2525
QUERY_ERRORS = "query_errors"
26+
PARTS_AND_MERGES = "parts_and_merges"
2627
SINGLE_ENDPOINT_MODE = "single_endpoint_mode"
2728

2829

@@ -33,6 +34,7 @@ class FeatureKey(Enum):
3334
FeatureKey.QUERY_COMPLETIONS: 'Query Completions',
3435
FeatureKey.QUERY_ERRORS: 'Query Errors',
3536
FeatureKey.EXPLAIN_PLANS: 'Explain Plans',
37+
FeatureKey.PARTS_AND_MERGES: 'Parts and Merges',
3638
FeatureKey.SINGLE_ENDPOINT_MODE: 'Single Endpoint Mode',
3739
}
3840

0 commit comments

Comments
 (0)