import json import math import os import time import urllib.parse import urllib.request from collections import defaultdict DEVOPS_BASE_URL = "https://prod.devops.pingcap.com" CLINIC_BASE_URL = "https://clinic.pingcap.com" QPS_DAYS = 30 QPS_STEP_SECONDS = 604800 QPS_RATE_WINDOW = "1h" QPS_CLUSTER_COUNT_THRESHOLD = 10 TRACKED_SERVICE_PLANS = { "starter", "essential", "premium", "dedicated", } def encode_params(params): items = [] for key, value in params: items.append( f"{urllib.parse.quote(str(key), safe='-._~')}=" f"{urllib.parse.quote(str(value), safe='-._~')}" ) return "&".join(items) def get_json(url, headers): req = urllib.request.Request(url, headers=headers, method="GET") with urllib.request.urlopen(req, timeout=60) as resp: return json.loads(resp.read().decode("utf-8")) def normalized(value): return (value or "").strip().lower() def routing_org_type(cluster): cluster_type = normalized(cluster.get("clusterType")) if cluster_type == "cloud": return "cloud" if cluster_type == "tidb-cluster": return "op" deploy_type_v2 = normalized(cluster.get("clusterDeployTypeV2")) deploy_type = deploy_type_v2 or normalized(cluster.get("clusterDeployType")) if deploy_type in { "dedicated", "shared", "starter", "essential", "premium", "byoc", "resource_pool", "premium_resource_pool", "byoc_resource_pool", }: return "cloud" if deploy_type == "tiup-cluster": return "op" return "cloud" def metrics_parent_fallback_label(cluster): parent_id = (cluster.get("parentID") or "").strip() if not parent_id: return None deploy_type_v2 = normalized(cluster.get("clusterDeployTypeV2")) deploy_type = deploy_type_v2 or normalized(cluster.get("clusterDeployType")) if deploy_type in { "premium", "byoc", "resource_pool", "premium_resource_pool", "byoc_resource_pool", }: return parent_id return None def cluster_service_plan(cluster): return ( normalized(cluster.get("service_plan")) or normalized(cluster.get("clusterDeployTypeV2")) or normalized(cluster.get("clusterDeployType")) ) def cluster_service_plan_counts(clusters): counts = {service_plan: 0 for service_plan in TRACKED_SERVICE_PLANS} for cluster in clusters or []: service_plan = cluster_service_plan(cluster) if service_plan in counts: counts[service_plan] += 1 return counts def cluster_qps_query(cluster_id): cluster_id = cluster_id.replace("\\", "\\\\").replace('"', '\\"') return ( "sum by (cluster_id) " f'(rate(tidb_executor_statement_total{{cluster_id="{cluster_id}"}}[{QPS_RATE_WINDOW}]))' ) def qps_metrics_query(query, start, end): return [ ("query", query), ("start", str(start)), ("end", str(end)), ("step", f"{QPS_STEP_SECONDS}s"), ] def metrics_qps_by_timestamp(response): status = response.get("status") if isinstance(status, str) and status.strip() and status.lower() != "success": raise RuntimeError(f"metrics response status is {status}") result = response.get("data", {}).get("result") if not isinstance(result, list): raise RuntimeError("metrics response is missing data.result array") qps_by_timestamp = {} for series in result: values = series.get("values") if not isinstance(values, list): continue for sample in values: if not isinstance(sample, list) or len(sample) < 2: continue ts = str(sample[0]) try: value = float(sample[1]) except (TypeError, ValueError): continue if not math.isfinite(value): continue qps_by_timestamp[ts] = qps_by_timestamp.get(ts, 0.0) + value return qps_by_timestamp def fetch_devops_clusters(devops_token, tenant_id): page = 1 per_page = 100 all_items = [] total = None base_url = os.environ.get("DEVOPS_BASE_URL", DEVOPS_BASE_URL).rstrip("/") while True: params = [ ("page", str(page)), ("per_page", str(per_page)), ("tenant_id", tenant_id), ] url = f"{base_url}/api/v1/devops/clusters?{encode_params(params)}" data = get_json(url, {"X-API-TOKEN": devops_token}) items = data.get("items") or [] raw_total = data.get("total") if total is None and isinstance(raw_total, int): total = raw_total all_items.extend(items) if not items or len(items) < per_page: break if total is not None and total > 0 and len(all_items) >= total: break page += 1 if total is None or total < len(all_items): total = len(all_items) return total, all_items def fetch_clinic_cluster_by_id(clinic_token, cluster_id): params = [ ("cluster_id", cluster_id), ("show_deleted", "false"), ("limit", "10"), ("page", "1"), ] base_url = os.environ.get("CLINIC_BASE_URL", CLINIC_BASE_URL).rstrip("/") url = f"{base_url}/clinic/api/v1/dashboard/clusters?{encode_params(params)}" data = get_json(url, {"Authorization": f"Bearer {clinic_token}"}) items = data.get("items") or [] for item in items: if (item.get("clusterID") or "").strip() == cluster_id: return item raise RuntimeError(f"clinic cluster lookup not found for cluster_id={cluster_id}") def fetch_clinic_metrics(clinic_token, cluster, query, start, end): params = qps_metrics_query(query, start, end) base_url = os.environ.get("CLINIC_BASE_URL", CLINIC_BASE_URL).rstrip("/") url = f"{base_url}/clinic/api/v1/data/metrics?{encode_params(params)}" headers = { "Authorization": f"Bearer {clinic_token}", "X-OrgType": routing_org_type(cluster), "X-OrgID": (cluster.get("orgID") or "").strip(), "X-ClusterID": (cluster.get("clusterID") or "").strip(), } return get_json(url, headers) def cluster_qps_by_timestamp(clinic_token, cluster, start, end): cluster_id = (cluster.get("clusterID") or "").strip() query = cluster_qps_query(cluster_id) qps_by_timestamp = metrics_qps_by_timestamp( fetch_clinic_metrics(clinic_token, cluster, query, start, end) ) if qps_by_timestamp: return qps_by_timestamp parent_id = metrics_parent_fallback_label(cluster) if not parent_id: return qps_by_timestamp fallback_query = query.replace(cluster_id, parent_id) if fallback_query == query: return qps_by_timestamp return metrics_qps_by_timestamp( fetch_clinic_metrics(clinic_token, cluster, fallback_query, start, end) ) def average_qps(samples): if not samples: return 0.0 return sum(samples.values()) / len(samples) def get_tenant_cluster_stats(tenant_id, devops_token, clinic_token, end=None): cluster_total, devops_clusters = fetch_devops_clusters(devops_token, str(tenant_id)) service_plan_counts = cluster_service_plan_counts(devops_clusters) if cluster_total <= 0 or not devops_clusters: return { "cluster_count": cluster_total, "starter_cluster_count": service_plan_counts["starter"], "essential_cluster_count": service_plan_counts["essential"], "premium_cluster_count": service_plan_counts["premium"], "dedicated_cluster_count": service_plan_counts["dedicated"], "tenant_avg_qps": 0.0, "errors": [], } if cluster_total >= QPS_CLUSTER_COUNT_THRESHOLD: return { "cluster_count": cluster_total, "starter_cluster_count": service_plan_counts["starter"], "essential_cluster_count": service_plan_counts["essential"], "premium_cluster_count": service_plan_counts["premium"], "dedicated_cluster_count": service_plan_counts["dedicated"], "tenant_avg_qps": None, "errors": [], } if not clinic_token: raise RuntimeError("CLINIC_API_KEY is required when QPS collection is enabled") end = int(end or time.time()) start = end - QPS_DAYS * 24 * 60 * 60 tenant_qps = defaultdict(float) clinic_cluster_cache = {} errors = [] for devops_cluster in devops_clusters: cluster_id = ( devops_cluster.get("cluster_id") or devops_cluster.get("id") or devops_cluster.get("clusterId") or "" ) cluster_id = str(cluster_id).strip() if not cluster_id: errors.append("cluster record is missing cluster_id") continue try: clinic_cluster = clinic_cluster_cache.get(cluster_id) if clinic_cluster is None: clinic_cluster = fetch_clinic_cluster_by_id(clinic_token, cluster_id) clinic_cluster_cache[cluster_id] = clinic_cluster cluster_qps = cluster_qps_by_timestamp(clinic_token, clinic_cluster, start, end) for ts, value in cluster_qps.items(): tenant_qps[ts] += value except Exception as exc: errors.append(f"cluster_id={cluster_id}: {exc}") return { "cluster_count": cluster_total, "starter_cluster_count": service_plan_counts["starter"], "essential_cluster_count": service_plan_counts["essential"], "premium_cluster_count": service_plan_counts["premium"], "dedicated_cluster_count": service_plan_counts["dedicated"], "tenant_avg_qps": None if errors else average_qps(tenant_qps), "errors": errors, }