mirror of
https://github.com/hanzoai/o11y.git
synced 2026-10-15 12:58:33 +00:00
339 lines
12 KiB
Python
339 lines
12 KiB
Python
import os
|
|
from collections.abc import Callable, Generator
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
import clickhouse_connect
|
|
import clickhouse_connect.driver
|
|
import clickhouse_connect.driver.client
|
|
import docker
|
|
import docker.errors
|
|
import pytest
|
|
from testcontainers.clickhouse import ClickHouseContainer
|
|
from testcontainers.core.container import Network
|
|
|
|
from fixtures import reuse, types
|
|
from fixtures.logger import setup_logger
|
|
|
|
logger = setup_logger(__name__)
|
|
|
|
|
|
@pytest.fixture(name="clickhouse", scope="package")
|
|
def clickhouse(
|
|
tmpfs: Generator[types.LegacyPath, Any],
|
|
network: Network,
|
|
zookeeper: types.TestContainerDocker,
|
|
request: pytest.FixtureRequest,
|
|
pytestconfig: pytest.Config,
|
|
) -> types.TestContainerDatastore:
|
|
"""
|
|
Package-scoped fixture for Clickhouse TestContainer.
|
|
"""
|
|
|
|
def create() -> types.TestContainerDatastore:
|
|
version = request.config.getoption("--datastore-version")
|
|
|
|
container = ClickHouseContainer(
|
|
image=f"clickhouse/clickhouse-server:{version}",
|
|
port=9000,
|
|
username="o11y",
|
|
password="password",
|
|
)
|
|
|
|
cluster_config = f"""
|
|
<clickhouse>
|
|
<logger>
|
|
<level>information</level>
|
|
<formatting>
|
|
<type>json</type>
|
|
</formatting>
|
|
<log>/var/log/clickhouse-server/clickhouse-server.log</log>
|
|
<errorlog>/var/log/clickhouse-server/clickhouse-server.err.log</errorlog>
|
|
<size>1000M</size>
|
|
<count>3</count>
|
|
<console>1</console>
|
|
</logger>
|
|
|
|
<macros>
|
|
<shard>01</shard>
|
|
<replica>01</replica>
|
|
</macros>
|
|
|
|
<zookeeper>
|
|
<node>
|
|
<host>{zookeeper.container_configs["2181"].address}</host>
|
|
<port>{zookeeper.container_configs["2181"].port}</port>
|
|
</node>
|
|
</zookeeper>
|
|
|
|
<remote_servers>
|
|
<cluster>
|
|
<shard>
|
|
<replica>
|
|
<host>127.0.0.1</host>
|
|
<port>9000</port>
|
|
</replica>
|
|
</shard>
|
|
</cluster>
|
|
</remote_servers>
|
|
|
|
<user_defined_executable_functions_config>*function.xml</user_defined_executable_functions_config>
|
|
<user_scripts_path>/var/lib/clickhouse/user_scripts/</user_scripts_path>
|
|
|
|
<distributed_ddl>
|
|
<path>/clickhouse/task_queue/ddl</path>
|
|
<profile>default</profile>
|
|
</distributed_ddl>
|
|
|
|
<storage_configuration>
|
|
<disks>
|
|
<default>
|
|
<keep_free_space_bytes>1024</keep_free_space_bytes>
|
|
</default>
|
|
<cold>
|
|
<type>local</type>
|
|
<path>/var/lib/clickhouse/cold/</path>
|
|
<keep_free_space_bytes>1024</keep_free_space_bytes>
|
|
</cold>
|
|
</disks>
|
|
<policies>
|
|
<default>
|
|
<volumes>
|
|
<default>
|
|
<disk>default</disk>
|
|
</default>
|
|
<cold>
|
|
<disk>cold</disk>
|
|
</cold>
|
|
</volumes>
|
|
</default>
|
|
<tiered>
|
|
<volumes>
|
|
<default>
|
|
<disk>default</disk>
|
|
</default>
|
|
<cold>
|
|
<disk>cold</disk>
|
|
</cold>
|
|
</volumes>
|
|
</tiered>
|
|
</policies>
|
|
</storage_configuration>
|
|
</clickhouse>
|
|
"""
|
|
|
|
custom_function_config = """
|
|
<functions>
|
|
<function>
|
|
<type>executable</type>
|
|
<name>histogramQuantile</name>
|
|
<return_type>Float64</return_type>
|
|
<argument>
|
|
<type>Array(Float64)</type>
|
|
<name>buckets</name>
|
|
</argument>
|
|
<argument>
|
|
<type>Array(Float64)</type>
|
|
<name>counts</name>
|
|
</argument>
|
|
<argument>
|
|
<type>Float64</type>
|
|
<name>quantile</name>
|
|
</argument>
|
|
<format>CSV</format>
|
|
<command>./histogramQuantile</command>
|
|
</function>
|
|
</functions>
|
|
"""
|
|
|
|
tmp_dir = tmpfs("clickhouse")
|
|
cluster_config_file_path = os.path.join(tmp_dir, "cluster.xml")
|
|
with open(cluster_config_file_path, "w", encoding="utf-8") as f:
|
|
f.write(cluster_config)
|
|
|
|
custom_function_file_path = os.path.join(tmp_dir, "custom-function.xml")
|
|
with open(custom_function_file_path, "w", encoding="utf-8") as f:
|
|
f.write(custom_function_config)
|
|
|
|
container.with_volume_mapping(cluster_config_file_path, "/etc/clickhouse-server/config.d/cluster.xml")
|
|
container.with_volume_mapping(
|
|
custom_function_file_path,
|
|
"/etc/clickhouse-server/custom-function.xml",
|
|
)
|
|
container.with_network(network)
|
|
container.start()
|
|
|
|
# Download and install the histogramQuantile binary
|
|
wrapped = container.get_wrapped_container()
|
|
exit_code, output = wrapped.exec_run(
|
|
[
|
|
"bash",
|
|
"-c",
|
|
(
|
|
'version="v0.0.1" && '
|
|
'node_os=$(uname -s | tr "[:upper:]" "[:lower:]") && '
|
|
"node_arch=$(uname -m | sed s/aarch64/arm64/ | sed s/x86_64/amd64/) && "
|
|
"cd /tmp && "
|
|
'wget -O histogram-quantile.tar.gz "https://github.com/SigNoz/signoz/releases/download/histogram-quantile%2F${version}/histogram-quantile_${node_os}_${node_arch}.tar.gz" && '
|
|
"tar -xzf histogram-quantile.tar.gz && "
|
|
"mkdir -p /var/lib/clickhouse/user_scripts && "
|
|
"mv histogram-quantile /var/lib/clickhouse/user_scripts/histogramQuantile && "
|
|
"chmod +x /var/lib/clickhouse/user_scripts/histogramQuantile"
|
|
),
|
|
],
|
|
)
|
|
if exit_code != 0:
|
|
raise RuntimeError(f"Failed to install histogramQuantile binary: {output.decode()}")
|
|
|
|
connection = clickhouse_connect.get_client(
|
|
user=container.username,
|
|
password=container.password,
|
|
host=container.get_container_host_ip(),
|
|
port=container.get_exposed_port(8123),
|
|
)
|
|
|
|
return types.TestContainerDatastore(
|
|
container=types.TestContainerDocker(
|
|
id=container.get_wrapped_container().id,
|
|
host_configs={
|
|
"9000": types.TestContainerUrlConfig(
|
|
"tcp",
|
|
container.get_container_host_ip(),
|
|
container.get_exposed_port(9000),
|
|
),
|
|
"8123": types.TestContainerUrlConfig(
|
|
"tcp",
|
|
container.get_container_host_ip(),
|
|
container.get_exposed_port(8123),
|
|
),
|
|
},
|
|
container_configs={
|
|
"9000": types.TestContainerUrlConfig("tcp", container.get_wrapped_container().name, 9000),
|
|
"8123": types.TestContainerUrlConfig("tcp", container.get_wrapped_container().name, 8123),
|
|
},
|
|
),
|
|
conn=connection,
|
|
env={
|
|
"O11Y_TELEMETRYSTORE_DATASTORE_DSN": f"tcp://{container.username}:{container.password}@{container.get_wrapped_container().name}:{9000}",
|
|
"O11Y_TELEMETRYSTORE_DATASTORE_USERNAME": container.username,
|
|
"O11Y_TELEMETRYSTORE_DATASTORE_PASSWORD": container.password,
|
|
"O11Y_TELEMETRYSTORE_DATASTORE_CLUSTER": "cluster",
|
|
},
|
|
)
|
|
|
|
def delete(container: types.TestContainerDatastore) -> None:
|
|
client = docker.from_env()
|
|
try:
|
|
client.containers.get(container_id=container.container.id).stop()
|
|
client.containers.get(container_id=container.container.id).remove(v=True)
|
|
except docker.errors.NotFound:
|
|
logger.info(
|
|
"Skipping removal of Clickhouse, Clickhouse(%s) not found. Maybe it was manually removed?",
|
|
{"id": container.id},
|
|
)
|
|
|
|
def restore(cache: dict) -> types.TestContainerDatastore:
|
|
container = types.TestContainerDocker.from_cache(cache["container"])
|
|
host_config = container.host_configs["8123"]
|
|
env = cache["env"]
|
|
|
|
conn = clickhouse_connect.get_client(
|
|
user=env["O11Y_TELEMETRYSTORE_DATASTORE_USERNAME"],
|
|
password=env["O11Y_TELEMETRYSTORE_DATASTORE_PASSWORD"],
|
|
host=host_config.address,
|
|
port=host_config.port,
|
|
)
|
|
|
|
return types.TestContainerDatastore(
|
|
container=container,
|
|
conn=conn,
|
|
env=env,
|
|
)
|
|
|
|
return reuse.wrap(
|
|
request,
|
|
pytestconfig,
|
|
"clickhouse",
|
|
empty=lambda: types.TestContainerSQL(
|
|
container=types.TestContainerDocker(id="", host_configs={}, container_configs={}),
|
|
conn=None,
|
|
env={},
|
|
),
|
|
create=create,
|
|
delete=delete,
|
|
restore=restore,
|
|
)
|
|
|
|
|
|
@pytest.fixture(name="check_query_log")
|
|
def check_query_log(
|
|
o11y: types.O11y,
|
|
) -> Callable[..., None]:
|
|
"""
|
|
Returns a callable that flushes system.query_log and asserts that at
|
|
least one recent SELECT satisfies check_fn.
|
|
|
|
Args:
|
|
after_ts: Only consider queries logged after this timestamp.
|
|
case_name: Label used in assertion failure messages.
|
|
check_fn: Predicate run against each candidate query string.
|
|
tables: Filter to queries that touched all of these tables, as
|
|
'db.table' strings (uses hasAll(tables, [...])).
|
|
must_contain: Substrings that must appear in the query text (AND-ed).
|
|
must_not_contain: Substrings that must not appear in the query text (AND-ed).
|
|
limit: How many most-recent queries to examine (default 10).
|
|
|
|
Usage:
|
|
before = datetime.now(tz=timezone.utc)
|
|
# ... trigger the query under test ...
|
|
check_query_log(
|
|
before, "my.case",
|
|
lambda q: "assumeNotNull" in q,
|
|
tables=["o11y_logs.distributed_logs_v2"],
|
|
)
|
|
"""
|
|
|
|
def _check(
|
|
after_ts: datetime,
|
|
case_name: str,
|
|
check_fn: Callable[[str], bool],
|
|
*,
|
|
tables: list[str] | None = None,
|
|
must_contain: list[str] | None = None,
|
|
must_not_contain: list[str] | None = None,
|
|
limit: int = 10,
|
|
) -> None:
|
|
conn = o11y.telemetrystore.conn
|
|
conn.command("SYSTEM FLUSH LOGS")
|
|
|
|
# Use millisecond precision to avoid timestamp collisions between
|
|
# adjacent test cases (second-level precision causes bleed-through).
|
|
params: dict = {"after_ms": int(after_ts.timestamp() * 1000)}
|
|
conditions = [
|
|
"type = 'QueryFinish'",
|
|
"query_kind = 'Select'",
|
|
"toUnixTimestamp64Milli(event_time_microseconds) >= %(after_ms)s",
|
|
]
|
|
if tables:
|
|
params["tables"] = tables
|
|
conditions.append("hasAll(tables, %(tables)s)")
|
|
for i, pattern in enumerate(must_contain or []):
|
|
key = f"mc_{i}"
|
|
params[key] = pattern
|
|
conditions.append(f"position(query, %({key})s) > 0")
|
|
for i, pattern in enumerate(must_not_contain or []):
|
|
key = f"mnc_{i}"
|
|
params[key] = pattern
|
|
conditions.append(f"position(query, %({key})s) = 0")
|
|
|
|
where = " AND ".join(conditions)
|
|
result = conn.query(
|
|
f"SELECT query FROM system.query_log WHERE {where} ORDER BY event_time_microseconds DESC LIMIT {limit}",
|
|
parameters=params,
|
|
)
|
|
queries = [row[0] for row in result.result_rows]
|
|
assert queries, f"No matching SELECT in system.query_log for case '{case_name}'"
|
|
assert all(check_fn(q) for q in queries), f"query_log check failed for case '{case_name}'.\n" + "Queries:\n" + "\n---\n".join(queries)
|
|
|
|
return _check
|