Skip to content

Commit 7010528

Browse files
Add a hosting_type tag distinguishing ClickHouse Cloud from self-hosted (DataDog#24736)
* Add a hosting_type tag distinguishing ClickHouse Cloud from self-hosted DBM had no way to tell a ClickHouse Cloud service apart from a self-hosted deployment. The only Cloud signal was single_endpoint_mode, a flag the operator sets by hand, so slicing metrics, samples, or instance metadata by hosting model was not possible even though the two behave differently: SharedMergeTree vs ReplicatedMergeTree, clusterAllReplicas topology, per-node checkpointing, and datetime vs integer types in system.query_log. Resolves the hosting model from two independent server-side signals and attaches it as an instance-level tag, following the same shape as clickhouse_cluster: resolved once per check instance, set on the tag manager before the query manager is built, so it flows through the existing payload tags/ddtags plumbing to query metrics, samples, completions, errors, parts and merges, FQT and instance metadata. The tag key and value vocabulary follow the mongo integration, the other DBM check that reports a hosting model. Both signals must agree before reporting cloud. cloud_mode is read from system.settings rather than via getSetting('cloud_mode'), which raises on the versions predating the setting and would increment FailedQuery/FailedSelectQuery. SharedMergeTree is looked up in system.table_engines rather than system.tables so a freshly provisioned Cloud service with no user tables still resolves, and both filter with exact equality rather than LIKE, which makes ClickHouse compile a re2 regex and bump RegexpCreated. Both lessons come from the clickhouse_cluster work. A probe that succeeds without finding its marker is a definite negative and settles the conjunction on its own. A probe that raises is indeterminate and yields 'unknown' rather than a self-hosted verdict built on nothing more than a permission error or a system table the server is too old to have. Verified against a live ClickHouse Cloud service, which resolves to clickhouse-cloud, and against self-hosted 18 and 24.8, which resolve to self-hosted through the setting-absent and setting-disabled paths respectively. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Add changelog entry Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Apply ruff format to the hosting type test Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Describe the hosting type probes as unconditional The docstring said a negative signal 'short-circuits to self-hosted', which reads as control flow that does not exist: both probes are always issued and the conjunction is evaluated afterwards. Say so, and record why neither probe is skipped. Also correct the stated failure mode. A lack of privileges was listed as a reason a probe might raise, but neither system.settings nor system.table_engines is gated behind grants: a user granted only SELECT on one database still reads all 1091 settings rows and all 64 table engines, verified against a live 24.8 cluster. The indeterminate case is a transport error or a server too old to have the table, not a permissions gap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Trim verbose comments and docstrings on the hosting_type change * Remove two more redundant comments on the hosting_type change * Drop redundant _hosting_type_resolved flag hosting_type always resolves to a non-empty string, so None alone distinguishes "not yet resolved" without a second boolean. * Move hosting_type tag into the expected_tags literal It's unconditional, unlike the cluster tag, so it belongs with the other always-present tags rather than as a trailing append. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 2009de6 commit 7010528

5 files changed

Lines changed: 138 additions & 7 deletions

File tree

clickhouse/changelog.d/24736.added

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add a `hosting_type` tag identifying whether the ClickHouse instance is ClickHouse Cloud or self-hosted.

clickhouse/datadog_checks/clickhouse/clickhouse.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,14 @@
2424
from .statements import ClickhouseStatementMetrics
2525
from .table_metrics import ClickhouseTableMetrics
2626
from .utils import (
27+
CLOUD_MODE_QUERY,
2728
CLUSTER_MACRO_QUERY,
2829
CLUSTER_NAME_QUERY,
2930
CLUSTER_TAG,
31+
HOSTING_TYPE_TAG,
32+
SHARED_MERGE_TREE_QUERY,
3033
ErrorSanitizer,
34+
HostingType,
3135
cluster_aware_query,
3236
)
3337

@@ -68,6 +72,7 @@ def __init__(self, name, init_config, instances):
6872
self._dbms_version = None
6973
self._cluster_name = None
7074
self._cluster_name_resolved = False
75+
self._hosting_type = None
7176

7277
# Track last emission time for database instance metadata (rate limiting)
7378
self._database_instance_last_emitted = 0
@@ -255,6 +260,7 @@ def check(self, _):
255260
# self.tags below, since both snapshot the tag list.
256261
if self.cluster_name:
257262
self.tag_manager.set_tag(CLUSTER_TAG, self.cluster_name, replace=True)
263+
self.tag_manager.set_tag(HOSTING_TYPE_TAG, self.hosting_type, replace=True)
258264

259265
if self._query_manager is None or self._query_manager_version != self._server_version:
260266
self._query_manager = self._build_query_manager()
@@ -397,6 +403,45 @@ def _resolve_cluster_name(self) -> str | None:
397403
self.log.debug('No ClickHouse cluster name found; %s tag will not be emitted', CLUSTER_TAG)
398404
return None
399405

406+
@property
407+
def hosting_type(self) -> str:
408+
"""Whether this instance is ClickHouse Cloud or self-hosted, cached after the first check run."""
409+
if self._hosting_type is None:
410+
self._hosting_type = self._resolve_hosting_type()
411+
return self._hosting_type
412+
413+
def _resolve_hosting_type(self) -> str:
414+
"""Combine two independent Cloud signals; both must agree to report cloud, either can veto it."""
415+
cloud_mode = self._probe_cloud_mode()
416+
shared_merge_tree = self._probe_shared_merge_tree()
417+
self.log.debug('Hosting type signals: cloud_mode=%s, shared_merge_tree=%s', cloud_mode, shared_merge_tree)
418+
419+
if cloud_mode is False or shared_merge_tree is False:
420+
return HostingType.SELF_HOSTED
421+
if cloud_mode and shared_merge_tree:
422+
return HostingType.CLOUD
423+
return HostingType.UNKNOWN
424+
425+
def _probe_cloud_mode(self) -> bool | None:
426+
"""Whether the server reports cloud_mode enabled, or None when the probe failed."""
427+
try:
428+
rows = self.execute_query_raw(CLOUD_MODE_QUERY)
429+
if not rows or not rows[0]:
430+
return False
431+
return str(rows[0][0]) not in ('', '0')
432+
except Exception as e:
433+
self.log.debug('Unable to read the cloud_mode setting: %s', e)
434+
return None
435+
436+
def _probe_shared_merge_tree(self) -> bool | None:
437+
"""Whether the Cloud-only SharedMergeTree engine exists, or None when the probe failed."""
438+
try:
439+
rows = self.execute_query_raw(SHARED_MERGE_TREE_QUERY)
440+
return bool(rows and rows[0] and int(rows[0][0]) > 0)
441+
except Exception as e:
442+
self.log.debug('Unable to check for the SharedMergeTree engine: %s', e)
443+
return None
444+
400445
@property
401446
def database_identifier_template(self) -> str:
402447
return self._config.database_identifier.template

clickhouse/datadog_checks/clickhouse/utils.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,22 @@ def compact_query(query):
7474
)
7575

7676

77+
HOSTING_TYPE_TAG = 'hosting_type'
78+
79+
80+
class HostingType:
81+
CLOUD = 'clickhouse-cloud'
82+
SELF_HOSTED = 'self-hosted'
83+
UNKNOWN = 'unknown'
84+
85+
86+
# system.settings avoids raising on versions predating cloud_mode (before 23.x).
87+
CLOUD_MODE_QUERY = "SELECT value FROM system.settings WHERE name = 'cloud_mode'"
88+
89+
# table_engines lists supported engines even before any tables exist; exact match avoids a LIKE regex compile.
90+
SHARED_MERGE_TREE_QUERY = "SELECT count() FROM system.table_engines WHERE name = 'SharedMergeTree'"
91+
92+
7793
def cluster_aware_query(base: dict) -> dict:
7894
"""Build a cluster-aware variant that reads all replicas and tags each row per node.
7995

clickhouse/tests/test_clickhouse.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import pytest
55

66
from datadog_checks.clickhouse import ClickhouseCheck
7-
from datadog_checks.clickhouse.utils import CLUSTER_TAG
7+
from datadog_checks.clickhouse.utils import CLUSTER_TAG, HOSTING_TYPE_TAG
88
from datadog_checks.dev.utils import get_metadata_metrics
99

1010
from . import common
@@ -55,6 +55,7 @@ def test_custom_queries(aggregator, instance, dd_run_check):
5555
'test:clickhouse',
5656
'database_hostname:{}'.format(check.database_hostname),
5757
'database_instance:{}:{}:default'.format(instance['server'], instance['port']),
58+
'{}:{}'.format(HOSTING_TYPE_TAG, check.hosting_type),
5859
]
5960
# ClickHouse ships a built-in is_local `default` cluster on some versions (and Cloud reports one
6061
# too), so every metric carries the clickhouse_cluster tag when a cluster resolves.

clickhouse/tests/test_unit.py

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,14 @@
99
from datadog_checks.clickhouse import ClickhouseCheck, advanced_queries, queries
1010
from datadog_checks.clickhouse.utils import (
1111
BUILTIN_SAMPLE_CLUSTERS,
12+
CLOUD_MODE_QUERY,
1213
CLUSTER_GROUP_PREFIX,
1314
CLUSTER_MACRO_QUERY,
1415
CLUSTER_NAME_QUERY,
1516
CLUSTER_TAG,
17+
HOSTING_TYPE_TAG,
18+
SHARED_MERGE_TREE_QUERY,
19+
HostingType,
1620
cluster_aware_query,
1721
)
1822

@@ -464,8 +468,11 @@ def test_get_queries_uses_base_queries_for_direct_connection(instance, use_advan
464468
assert all('clusterAllReplicas' not in q['query'] for q in check.get_queries())
465469

466470

467-
def make_cluster_name_check(query_results):
468-
"""Build a check whose execute_query_raw replays query_results keyed by SQL."""
471+
def make_query_replaying_check(query_results):
472+
"""Build a check whose execute_query_raw replays query_results keyed by SQL.
473+
474+
An Exception value is raised instead of returned, to simulate a failed probe.
475+
"""
469476
check = ClickhouseCheck('clickhouse', {}, [BASE_INSTANCE])
470477

471478
def execute(query):
@@ -479,7 +486,7 @@ def execute(query):
479486

480487

481488
def test_cluster_name_prefers_the_macro():
482-
check = make_cluster_name_check({CLUSTER_MACRO_QUERY: [['macro_cluster']]})
489+
check = make_query_replaying_check({CLUSTER_MACRO_QUERY: [['macro_cluster']]})
483490

484491
assert check.cluster_name == 'macro_cluster'
485492
# system.clusters must not be consulted once the macro answers.
@@ -495,7 +502,7 @@ def test_cluster_name_prefers_the_macro():
495502
],
496503
)
497504
def test_cluster_name_falls_back_to_system_clusters(macro_result):
498-
check = make_cluster_name_check({CLUSTER_MACRO_QUERY: macro_result, CLUSTER_NAME_QUERY: [['prod_cluster']]})
505+
check = make_query_replaying_check({CLUSTER_MACRO_QUERY: macro_result, CLUSTER_NAME_QUERY: [['prod_cluster']]})
499506

500507
assert check.cluster_name == 'prod_cluster'
501508

@@ -524,7 +531,7 @@ def test_cluster_name_query_excludes_cloud_group_pseudo_cluster():
524531

525532

526533
def test_cluster_name_absent_when_both_sources_fail():
527-
check = make_cluster_name_check(
534+
check = make_query_replaying_check(
528535
{
529536
CLUSTER_MACRO_QUERY: Error('query failed'),
530537
CLUSTER_NAME_QUERY: [],
@@ -536,7 +543,7 @@ def test_cluster_name_absent_when_both_sources_fail():
536543

537544

538545
def test_cluster_name_is_cached_including_the_absent_case():
539-
check = make_cluster_name_check({CLUSTER_MACRO_QUERY: [], CLUSTER_NAME_QUERY: []})
546+
check = make_query_replaying_check({CLUSTER_MACRO_QUERY: [], CLUSTER_NAME_QUERY: []})
540547

541548
assert check.cluster_name is None
542549
assert check.cluster_name is None
@@ -582,3 +589,64 @@ def test_check_omits_cluster_tag_when_unresolved(instance):
582589
check.check({})
583590

584591
assert not any(tag.startswith(f'{CLUSTER_TAG}:') for tag in check.tags)
592+
593+
594+
PROBE_FAILED = Error('Not enough privileges')
595+
596+
597+
@pytest.mark.parametrize(
598+
'cloud_mode, shared_merge_tree, expected',
599+
[
600+
pytest.param([['1']], [[1]], HostingType.CLOUD, id='cloud'),
601+
# A negative signal decides on its own, even if the other never answered.
602+
pytest.param([], [[1]], HostingType.SELF_HOSTED, id='self-hosted-setting-absent'),
603+
pytest.param([['0']], [[1]], HostingType.SELF_HOSTED, id='self-hosted-cloud-mode-off'),
604+
pytest.param([['']], [[1]], HostingType.SELF_HOSTED, id='self-hosted-cloud-mode-empty'),
605+
pytest.param([['1']], [[0]], HostingType.SELF_HOSTED, id='self-hosted-no-shared-merge-tree'),
606+
pytest.param(PROBE_FAILED, [[0]], HostingType.SELF_HOSTED, id='self-hosted-despite-failed-probe'),
607+
pytest.param([['0']], PROBE_FAILED, HostingType.SELF_HOSTED, id='self-hosted-despite-failed-engine-probe'),
608+
# A failed probe is indeterminate, not a negative.
609+
pytest.param(PROBE_FAILED, [[1]], HostingType.UNKNOWN, id='unknown-cloud-mode-unreadable'),
610+
pytest.param([['1']], PROBE_FAILED, HostingType.UNKNOWN, id='unknown-engines-unreadable'),
611+
pytest.param(PROBE_FAILED, PROBE_FAILED, HostingType.UNKNOWN, id='unknown-both-unreadable'),
612+
],
613+
)
614+
def test_hosting_type_resolution(cloud_mode, shared_merge_tree, expected):
615+
check = make_query_replaying_check({CLOUD_MODE_QUERY: cloud_mode, SHARED_MERGE_TREE_QUERY: shared_merge_tree})
616+
617+
assert check.hosting_type == expected
618+
619+
620+
def test_hosting_type_is_cached_including_the_unknown_case():
621+
check = make_query_replaying_check({CLOUD_MODE_QUERY: PROBE_FAILED, SHARED_MERGE_TREE_QUERY: PROBE_FAILED})
622+
623+
assert check.hosting_type == HostingType.UNKNOWN
624+
assert check.hosting_type == HostingType.UNKNOWN
625+
626+
# One attempt per signal, not per access: a server that cannot answer is not re-asked.
627+
assert check.execute_query_raw.call_count == 2
628+
629+
630+
def test_check_tags_with_hosting_type(instance):
631+
check = ClickhouseCheck('clickhouse', {}, [instance])
632+
with mock.patch.object(ClickhouseCheck, 'cluster_name', new_callable=mock.PropertyMock) as cluster_name:
633+
cluster_name.return_value = None
634+
with mock.patch.object(ClickhouseCheck, 'hosting_type', new_callable=mock.PropertyMock) as hosting_type:
635+
hosting_type.return_value = HostingType.CLOUD
636+
with mock.patch('clickhouse_connect.get_client'):
637+
check.check({})
638+
639+
assert f'{HOSTING_TYPE_TAG}:{HostingType.CLOUD}' in check.tags
640+
641+
642+
def test_check_always_emits_a_hosting_type_tag(instance):
643+
"""Unlike the cluster tag, this one has a value for every outcome, so it is never omitted."""
644+
check = ClickhouseCheck('clickhouse', {}, [instance])
645+
with mock.patch.object(ClickhouseCheck, 'cluster_name', new_callable=mock.PropertyMock) as cluster_name:
646+
cluster_name.return_value = None
647+
with mock.patch.object(ClickhouseCheck, 'hosting_type', new_callable=mock.PropertyMock) as hosting_type:
648+
hosting_type.return_value = HostingType.UNKNOWN
649+
with mock.patch('clickhouse_connect.get_client'):
650+
check.check({})
651+
652+
assert f'{HOSTING_TYPE_TAG}:{HostingType.UNKNOWN}' in check.tags

0 commit comments

Comments
 (0)