Files
taylanbakircioglu 6e503368f7 feat: L7 (Application Level) observability — Service Map, Trace Explorer, APM, Beyla
- Grafana Beyla DaemonSet for kernel-level HTTP/gRPC/DNS capture (passive,
  zero application changes, W3C traceparent header propagation)
- flowfish-l7-collector in-cluster bridge: OTLP receiver + buffered pull API
- L7 Ingestion Service: K8s service-proxy poll → enrich → RabbitMQ
- ClickHouse l7_http_flows / l7_grpc_flows / l7_dns_flows + APM RED MVs
- Neo4j L7Workload nodes + SAME_WORKLOAD cross-cluster bridges
- New pages: Service Map, Trace Explorer, APM Services List, APM Service Detail
- Analysis Wizard now supports L4 / L7 / Both modes with HTTP/gRPC/DNS picks
- Integration Hub gains L7 dependency summary + tree-summary integrations
- Multi-Cluster Management: dual-agent install (Inspector Gadget L4 + Beyla L7),
  runtime OpenShift detection so SCCs auto-install with kubectl too
- ServiceMap edge → Trace Explorer drill-down with virtual_trace_id correlation
- Docs: new L7 architecture diagram, README L7 sections, 3 new screenshots
2026-05-14 10:09:15 +03:00

2840 lines
121 KiB
Python

"""
Timeseries Query Engine - Event Data Query Layer
Provides abstracted access to time-series event data.
Currently backed by ClickHouse, but interface is database-agnostic.
"""
import logging
import math
import re
from typing import Dict, Any, List, Optional, Tuple
from datetime import datetime, timedelta, timezone
from clickhouse_driver import Client
from clickhouse_driver.errors import Error as DatabaseError
# W3C trace_id is 32 hex chars (16 bytes); we accept any non-empty hex prefix
# in case truncated/legacy values arrive, but validate strictly to prevent
# SQL injection via the ClickHouse string-interpolation pattern used here.
_TRACE_ID_RE = re.compile(r'^[0-9a-fA-F]{1,32}$')
def _validate_trace_id(trace_id: str) -> str:
"""Validate and normalize a W3C trace_id. Raises ValueError when invalid.
Used by all trace-related query methods. Returns lowercase normalized form.
"""
if not isinstance(trace_id, str) or not _TRACE_ID_RE.match(trace_id):
raise ValueError(f"Invalid trace_id format: {trace_id!r}")
return trace_id.lower()
from app.config import settings
logger = logging.getLogger(__name__)
class TimeseriesQueryEngine:
"""
Query engine for time-series event data
Features:
- Database-agnostic interface
- Connection pooling
- Query timeout handling
- Result pagination
- Aggregation support
"""
# Event type to table mapping
# NOTE: tcp_lifecycle removed - IG trace_tcp doesn't produce TCP state events
EVENT_TABLES = {
"network_flow": "network_flows",
"dns_query": "dns_queries",
"process_event": "process_events",
"file_event": "file_operations",
"security_event": "capability_checks",
"oom_event": "oom_kills",
"bind_event": "bind_events",
"sni_event": "sni_events",
"mount_event": "mount_events",
"l7_http_flow": "l7_http_flows",
"l7_grpc_flow": "l7_grpc_flows",
"l7_dns_flow": "l7_dns_flows",
}
def __init__(self):
self.client = None
self._connect()
def _connect(self):
"""Establish database connection"""
try:
self.client = Client(
host=settings.clickhouse_host,
port=settings.clickhouse_port,
user=settings.clickhouse_user,
password=settings.clickhouse_password,
database=settings.clickhouse_database,
send_receive_timeout=settings.query_timeout,
connect_timeout=10,
)
# Test connection
self.client.execute('SELECT 1')
logger.info(f"✅ Connected to timeseries database at {settings.clickhouse_host}:{settings.clickhouse_port}")
except DatabaseError as e:
logger.error(f"❌ Failed to connect to timeseries database: {e}")
raise
TABLE_SEARCH_FIELDS = {
"network_flows": ["source_ip", "dest_ip", "source_pod", "dest_pod", "source_namespace", "dest_namespace"],
"dns_queries": ["query_name", "dns_server_ip", "source_pod", "source_namespace"],
"sni_events": ["sni_name", "dst_ip", "pod", "namespace", "comm"],
"process_events": ["comm", "exe", "pod", "namespace"],
"file_operations": ["file_path", "comm", "pod", "namespace"],
"capability_checks": ["capability", "syscall", "comm", "pod", "namespace"],
"oom_kills": ["comm", "pod", "namespace", "node"],
"bind_events": ["bind_addr", "comm", "interface", "pod", "namespace"],
"mount_events": ["source", "target", "fs_type", "comm", "pod", "namespace"],
"l7_http_flows": ["src_namespace", "src_workload", "dst_namespace", "dst_workload", "http_method", "http_path", "http_host"],
"l7_grpc_flows": ["src_namespace", "src_workload", "dst_namespace", "dst_workload", "grpc_method", "grpc_service"],
"l7_dns_flows": ["src_namespace", "src_workload", "query_name", "query_type"],
}
def _build_search_condition(self, search: str, search_fields: List[str]) -> str:
"""
Build search condition for full-text search across multiple fields.
Uses positionCaseInsensitive for case-insensitive substring matching.
Escaping note: ClickHouse string literals interpret backslash as
an escape introducer, so a malicious caller could pass `\\' OR 1=1`
and the doubled-up quote would land *inside* an active escape
sequence rather than terminating it. We therefore double the
backslashes first and only then double the single quotes — this
mirrors the `_escape_ch` helper used elsewhere in this file
(added during a previous audit).
"""
if not search or not search.strip():
return ""
safe_search = (
search.strip()
.replace("\\", "\\\\")
.replace("'", "''")
)
conditions = []
for field in search_fields:
conditions.append(f"positionCaseInsensitive(toString({field}), '{safe_search}') > 0")
return f"({' OR '.join(conditions)})"
def _build_where_clause(
self,
cluster_id: Optional[int] = None,
analysis_id: Optional[int] = None,
namespace: Optional[str] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
namespace_column: str = "namespace",
extra_conditions: Optional[List[str]] = None,
search: Optional[str] = None,
search_fields: Optional[List[str]] = None
) -> str:
"""
Build WHERE clause from filters
Multi-cluster support: analysis_id can be in format '{id}' or '{id}-{cluster_id}'
Uses LIKE pattern matching to support both formats.
"""
conditions = ["1=1"]
# cluster_id filter - optional for multi-cluster queries
if cluster_id and cluster_id > 0:
conditions.append(f"cluster_id = '{cluster_id}'")
# Multi-cluster support: match both single-cluster and multi-cluster analysis_id formats
if analysis_id:
conditions.append(f"(analysis_id = '{analysis_id}' OR analysis_id LIKE '{analysis_id}-%')")
if namespace:
conditions.append(f"{namespace_column} = '{namespace}'")
if start_time:
# Use parseDateTimeBestEffort for robust ISO 8601 parsing
conditions.append(f"timestamp >= parseDateTimeBestEffort('{start_time}')")
if end_time:
# Use parseDateTimeBestEffort for robust ISO 8601 parsing
conditions.append(f"timestamp <= parseDateTimeBestEffort('{end_time}')")
if search and search_fields:
search_condition = self._build_search_condition(search, search_fields)
if search_condition:
conditions.append(search_condition)
if extra_conditions:
conditions.extend(extra_conditions)
return " AND ".join(conditions)
async def get_event_stats(
self,
cluster_id: Optional[int] = None,
analysis_id: Optional[int] = None
) -> Dict[str, Any]:
"""
Get aggregated event statistics
Returns counts per event type, time range, top namespaces/pods.
Gracefully handles missing tables (e.g., L7 tables absent in L4-only
deployments, or L4 tables absent in L7-only setups).
"""
try:
event_counts = {}
L7_TABLES = {"l7_http_flows", "l7_grpc_flows", "l7_dns_flows"}
SOURCE_COL_TABLES = {"network_flows", "dns_queries"}
for event_type, table_name in self.EVENT_TABLES.items():
ns_col = "source_namespace" if table_name in SOURCE_COL_TABLES else ("src_namespace" if table_name in L7_TABLES else "namespace")
where = self._build_where_clause(cluster_id, analysis_id, namespace_column=ns_col)
if table_name == "capability_checks":
sensitive_caps = [
'CAP_SYS_ADMIN', 'CAP_NET_ADMIN', 'CAP_NET_RAW',
'CAP_SYS_PTRACE', 'CAP_SYS_MODULE', 'CAP_DAC_OVERRIDE',
'CAP_SETUID', 'CAP_SETGID', 'CAP_CHOWN', 'CAP_FOWNER',
'CAP_SYS_RAWIO', 'CAP_MKNOD', 'CAP_LINUX_IMMUTABLE'
]
sensitive_caps_str = ", ".join([f"'{c}'" for c in sensitive_caps])
where += f" AND (verdict = 'denied' OR verdict = '1' OR toString(verdict) = '1' OR capability IN ({sensitive_caps_str}))"
try:
query = f"SELECT count() as cnt FROM {table_name} WHERE {where}"
result = self.client.execute(query)
count = result[0][0] if result else 0
if count > 0:
event_counts[event_type] = count
except Exception as table_err:
logger.debug(f"Table {table_name} not available: {table_err}")
total_events = sum(event_counts.values())
# Time range / top namespaces / top pods — try network_flows first,
# fall back to l7_http_flows, then skip if neither exists.
time_range = {"start": None, "end": None}
top_namespaces: list = []
top_pods: list = []
for stats_table, ns_col, pod_col in [
("network_flows", "source_namespace", "source_pod"),
("l7_http_flows", "src_namespace", "src_workload"),
]:
try:
where_ts = self._build_where_clause(cluster_id, analysis_id, namespace_column=ns_col)
time_query = f"SELECT min(timestamp), max(timestamp) FROM {stats_table} WHERE {where_ts}"
time_result = self.client.execute(time_query)
if time_result and time_result[0][0]:
time_range = {
"start": time_result[0][0].isoformat(),
"end": time_result[0][1].isoformat() if time_result[0][1] else None
}
ns_query = f"""
SELECT {ns_col} as namespace, count() as cnt
FROM {stats_table} WHERE {where_ts}
GROUP BY {ns_col} ORDER BY cnt DESC LIMIT 10
"""
ns_result = self.client.execute(ns_query)
top_namespaces = [{"namespace": r[0], "count": r[1]} for r in ns_result]
pod_query = f"""
SELECT {pod_col} as pod, {ns_col} as namespace, count() as cnt
FROM {stats_table} WHERE {where_ts}
GROUP BY {pod_col}, {ns_col} ORDER BY cnt DESC LIMIT 10
"""
pod_result = self.client.execute(pod_query)
top_pods = [{"pod": r[0], "namespace": r[1], "count": r[2]} for r in pod_result]
break # success — no need to try the next table
except Exception:
continue
return {
"cluster_id": str(cluster_id),
"analysis_id": str(analysis_id) if analysis_id else "",
"total_events": total_events,
"event_counts": event_counts,
"time_range": time_range,
"top_namespaces": top_namespaces,
"top_pods": top_pods
}
except Exception as e:
logger.error(f"Failed to get event stats: {e}")
raise
async def query_events(
self,
event_type: str,
cluster_id: Optional[int] = None,
analysis_id: Optional[int] = None,
namespace: Optional[str] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
limit: int = 100,
offset: int = 0,
search: Optional[str] = None
) -> Tuple[List[Dict[str, Any]], int]:
"""
Query events by type with pagination
Args:
event_type: Type of event (network_flow, dns_query, etc.)
cluster_id: Cluster ID filter
analysis_id: Optional analysis ID filter
namespace: Optional namespace filter
start_time: Optional start time (ISO format)
end_time: Optional end time (ISO format)
limit: Max results (default 100)
offset: Pagination offset
search: Optional full-text search across relevant fields
Returns:
Tuple of (events list, total count)
"""
try:
table_name = self.EVENT_TABLES.get(event_type)
if not table_name:
raise ValueError(f"Unknown event type: {event_type}")
L7_TABLES_Q = {"l7_http_flows", "l7_grpc_flows", "l7_dns_flows"}
SOURCE_COL_TABLES_Q = {"network_flows", "dns_queries"}
ns_col = "source_namespace" if table_name in SOURCE_COL_TABLES_Q else ("src_namespace" if table_name in L7_TABLES_Q else "namespace")
# Resolve search fields for this table
s_fields = self.TABLE_SEARCH_FIELDS.get(table_name) if search else None
where_clause = self._build_where_clause(
cluster_id=cluster_id,
analysis_id=analysis_id,
namespace=namespace,
start_time=start_time,
end_time=end_time,
namespace_column=ns_col,
search=search,
search_fields=s_fields
)
# For security events (capability_checks), show:
# 1. All denied verdicts (blocked capabilities)
# 2. Sensitive capabilities even if allowed (potential security concerns)
# Note: verdict can be string ('denied') or integer (1) depending on data version
if table_name == "capability_checks":
sensitive_caps = [
'CAP_SYS_ADMIN', 'CAP_NET_ADMIN', 'CAP_NET_RAW',
'CAP_SYS_PTRACE', 'CAP_SYS_MODULE', 'CAP_DAC_OVERRIDE',
'CAP_SETUID', 'CAP_SETGID', 'CAP_CHOWN', 'CAP_FOWNER',
'CAP_SYS_RAWIO', 'CAP_MKNOD', 'CAP_LINUX_IMMUTABLE'
]
sensitive_caps_str = ", ".join([f"'{c}'" for c in sensitive_caps])
# Support both string 'denied' and integer '1' verdict formats
where_clause += f" AND (verdict = 'denied' OR verdict = '1' OR toString(verdict) = '1' OR capability IN ({sensitive_caps_str}))"
logger.info(f"Security events filter: denied + sensitive capabilities")
# Get count
count_query = f"SELECT count() FROM {table_name} WHERE {where_clause}"
count_result = self.client.execute(count_query)
total = count_result[0][0] if count_result else 0
# Get data
data_query = f"""
SELECT *
FROM {table_name}
WHERE {where_clause}
ORDER BY timestamp DESC
LIMIT {limit} OFFSET {offset}
"""
result = self.client.execute(data_query, with_column_types=True)
if not result:
return ([], total)
rows, columns = result
column_names = [col[0] for col in columns]
events = []
for row in rows:
event = {}
for i, col_name in enumerate(column_names):
value = row[i]
# Handle datetime serialization
if isinstance(value, datetime):
value = value.isoformat()
event[col_name] = value
events.append(event)
return (events, total)
except Exception as e:
logger.error(f"Failed to query {event_type} events: {e}")
raise
async def query_network_flows(
self,
cluster_id: Optional[int] = None,
analysis_id: Optional[int] = None,
namespace: Optional[str] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
limit: int = 100,
offset: int = 0,
search: Optional[str] = None
) -> Tuple[List[Dict[str, Any]], int]:
"""Query network flow events"""
return await self.query_events(
"network_flow", cluster_id, analysis_id, namespace,
start_time=start_time, end_time=end_time,
limit=limit, offset=offset, search=search
)
async def query_dns_queries(
self,
cluster_id: Optional[int] = None,
analysis_id: Optional[int] = None,
namespace: Optional[str] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
limit: int = 100,
offset: int = 0,
search: Optional[str] = None
) -> Tuple[List[Dict[str, Any]], int]:
"""Query DNS query events"""
return await self.query_events(
"dns_query", cluster_id, analysis_id, namespace,
start_time=start_time, end_time=end_time,
limit=limit, offset=offset, search=search
)
async def query_process_events(
self,
cluster_id: Optional[int] = None,
analysis_id: Optional[int] = None,
namespace: Optional[str] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
limit: int = 100,
offset: int = 0,
search: Optional[str] = None
) -> Tuple[List[Dict[str, Any]], int]:
"""Query process events"""
return await self.query_events(
"process_event", cluster_id, analysis_id, namespace,
start_time=start_time, end_time=end_time,
limit=limit, offset=offset, search=search
)
async def query_file_events(
self,
cluster_id: Optional[int] = None,
analysis_id: Optional[int] = None,
namespace: Optional[str] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
limit: int = 100,
offset: int = 0,
search: Optional[str] = None
) -> Tuple[List[Dict[str, Any]], int]:
"""Query file operation events"""
return await self.query_events(
"file_event", cluster_id, analysis_id, namespace,
start_time=start_time, end_time=end_time,
limit=limit, offset=offset, search=search
)
async def query_security_events(
self,
cluster_id: Optional[int] = None,
analysis_id: Optional[int] = None,
namespace: Optional[str] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
limit: int = 100,
offset: int = 0,
search: Optional[str] = None
) -> Tuple[List[Dict[str, Any]], int]:
"""Query security/capability events"""
return await self.query_events(
"security_event", cluster_id, analysis_id, namespace,
start_time=start_time, end_time=end_time,
limit=limit, offset=offset, search=search
)
async def query_oom_events(
self,
cluster_id: Optional[int] = None,
analysis_id: Optional[int] = None,
namespace: Optional[str] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
limit: int = 100,
offset: int = 0,
search: Optional[str] = None
) -> Tuple[List[Dict[str, Any]], int]:
"""Query OOM kill events"""
return await self.query_events(
"oom_event", cluster_id, analysis_id, namespace,
start_time=start_time, end_time=end_time,
limit=limit, offset=offset, search=search
)
async def query_bind_events(
self,
cluster_id: Optional[int] = None,
analysis_id: Optional[int] = None,
namespace: Optional[str] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
limit: int = 100,
offset: int = 0,
search: Optional[str] = None
) -> Tuple[List[Dict[str, Any]], int]:
"""Query socket bind events"""
return await self.query_events(
"bind_event", cluster_id, analysis_id, namespace,
start_time=start_time, end_time=end_time,
limit=limit, offset=offset, search=search
)
async def query_sni_events(
self,
cluster_id: Optional[int] = None,
analysis_id: Optional[int] = None,
namespace: Optional[str] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
limit: int = 100,
offset: int = 0,
search: Optional[str] = None
) -> Tuple[List[Dict[str, Any]], int]:
"""Query TLS/SNI events"""
return await self.query_events(
"sni_event", cluster_id, analysis_id, namespace,
start_time=start_time, end_time=end_time,
limit=limit, offset=offset, search=search
)
async def query_mount_events(
self,
cluster_id: Optional[int] = None,
analysis_id: Optional[int] = None,
namespace: Optional[str] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
limit: int = 100,
offset: int = 0,
search: Optional[str] = None
) -> Tuple[List[Dict[str, Any]], int]:
"""Query mount events"""
return await self.query_events(
"mount_event", cluster_id, analysis_id, namespace,
start_time=start_time, end_time=end_time,
limit=limit, offset=offset, search=search
)
# NOTE: query_tcp_connections removed - IG trace_tcp doesn't produce TCP state events
# TCP connection info is captured in network_flows via connect/accept/close events
# Per-table search field map. Mirrors backend ClickHouseEventRepository
# `TABLE_SEARCH_FIELDS` so the UI's "Search" box matches the same
# surface regardless of which repository implementation is wired in.
#
# CRITICAL: every column listed here must actually exist on the
# table — `_build_search_condition` builds
# `positionCaseInsensitive(toString({col}), '...')` and ClickHouse
# rejects unknown identifiers with a 500. The legacy
# `TABLE_SEARCH_FIELDS` in event_repository.py listed `dest_pod`
# and `dest_namespace` for `tcp_lifecycle`, but the actual schema
# for that table only has source-side columns plus dest_ip /
# dest_port. We deliberately diverge from the backend copy here
# (and document the discrepancy) so this code path doesn't
# inherit a 500-on-search bug.
_SEARCH_FIELDS_BY_TABLE: Dict[str, List[str]] = {
"network_flows": [
"source_ip", "dest_ip", "source_pod", "dest_pod",
"source_namespace", "dest_namespace",
],
# tcp_lifecycle has source_namespace/source_pod but no
# dest_namespace/dest_pod columns — only dest_ip/dest_port.
"tcp_lifecycle": [
"source_ip", "dest_ip", "source_pod", "source_namespace",
],
"dns_queries": [
"query_name", "dns_server_ip", "source_pod", "source_namespace",
],
"process_events": ["comm", "exe", "pod", "namespace"],
"file_operations": ["file_path", "comm", "pod", "namespace"],
"capability_checks": ["capability", "syscall", "comm", "pod", "namespace"],
"oom_kills": ["comm", "pod", "namespace", "node"],
"bind_events": ["bind_addr", "comm", "pod", "namespace"],
"sni_events": ["sni_name", "dst_ip", "pod", "namespace", "comm"],
"mount_events": ["source", "target", "fs_type", "comm", "pod", "namespace"],
# L7 tables use src_*/dst_* column families instead of pod/namespace
# (the generic L4 fallback). Without these explicit entries the
# `query_all_events` search clause silently dropped L7 rows because
# the unknown identifier raised a ClickHouse error that was caught
# and translated to "table unavailable" → 0 results in the UI.
"l7_http_flows": [
"src_namespace", "src_workload", "src_pod",
"dst_namespace", "dst_workload", "dst_pod",
"http_method", "http_path", "http_host",
],
"l7_grpc_flows": [
"src_namespace", "src_workload", "src_pod",
"dst_namespace", "dst_workload", "dst_pod",
"grpc_service", "grpc_method",
],
"l7_dns_flows": [
"src_namespace", "src_workload", "src_pod",
"query_name", "query_type",
],
}
async def query_all_events(
self,
cluster_id: Optional[int] = None,
analysis_id: Optional[int] = None,
namespace: Optional[str] = None,
search: Optional[str] = None,
event_types: Optional[List[str]] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
limit: int = 100,
offset: int = 0
) -> Tuple[List[Dict[str, Any]], int]:
"""
Query all events with unified format
Uses UNION ALL to merge results from multiple tables
"""
try:
# Filter tables
tables_to_query = self.EVENT_TABLES.copy()
if event_types:
tables_to_query = {k: v for k, v in self.EVENT_TABLES.items() if k in event_types}
if not tables_to_query:
return ([], 0)
# Column mapping per table family:
# - L4 "source_*" tables: network_flows, dns_queries
# - L4 generic tables: sni_events, process_events, etc.
# - L7 tables: l7_http_flows, l7_grpc_flows, l7_dns_flows
L7_TABLES = {"l7_http_flows", "l7_grpc_flows", "l7_dns_flows"}
SOURCE_COL_TABLES = {"network_flows", "dns_queries"}
def _col_map(tbl: str):
if tbl in SOURCE_COL_TABLES:
return "source_namespace", "source_pod", "source_container"
if tbl in L7_TABLES:
return "src_namespace", "src_pod", "''"
return "namespace", "pod", "container"
def _search_fields(tbl: str) -> List[str]:
# Default fallback covers tables without a dedicated entry
# (mostly the L7 ones, which this generic endpoint usually
# doesn't surface but keep safe just in case).
return self._SEARCH_FIELDS_BY_TABLE.get(tbl, ["pod", "namespace"])
# Count total (skip missing tables gracefully)
total = 0
available_tables: Dict[str, str] = {}
for event_type, table_name in tables_to_query.items():
ns_col, _, _ = _col_map(table_name)
where = self._build_where_clause(
cluster_id, analysis_id, namespace, start_time, end_time, ns_col,
search=search, search_fields=_search_fields(table_name),
)
try:
count_query = f"SELECT count() FROM {table_name} WHERE {where}"
result = self.client.execute(count_query)
total += result[0][0] if result else 0
available_tables[event_type] = table_name
except Exception:
continue
if not available_tables:
return ([], 0)
# Build UNION query only for tables that exist
union_parts = []
for event_type, table_name in available_tables.items():
ns_col, pod_col, container_col = _col_map(table_name)
where = self._build_where_clause(
cluster_id, analysis_id, namespace, start_time, end_time, ns_col,
search=search, search_fields=_search_fields(table_name),
)
union_parts.append(f"""
SELECT
timestamp,
'{event_type}' as event_type,
cluster_id,
analysis_id,
{ns_col} as namespace,
{pod_col} as pod,
{container_col} as container,
event_data_json as details
FROM {table_name}
WHERE {where}
""")
union_query = " UNION ALL ".join(union_parts)
full_query = f"""
SELECT * FROM ({union_query})
ORDER BY timestamp DESC
LIMIT {limit} OFFSET {offset}
"""
result = self.client.execute(full_query, with_column_types=True)
if not result:
return ([], total)
rows, columns = result
column_names = [col[0] for col in columns]
events = []
for row in rows:
event = {}
for i, col_name in enumerate(column_names):
value = row[i]
if isinstance(value, datetime):
value = value.isoformat()
event[col_name] = value
events.append(event)
return (events, total)
except Exception as e:
logger.error(f"Failed to query all events: {e}")
raise
async def query_event_histogram(
self,
cluster_id: Optional[int] = None,
analysis_id: Optional[int] = None,
event_types: Optional[List[str]] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
bucket_count: int = 60
) -> Dict[str, Any]:
"""
Time-bucketed event histogram for timeline visualization.
Uses ClickHouse toStartOfInterval for efficient server-side aggregation
across all event tables, returning counts per bucket per event type.
"""
try:
tables_to_query = self.EVENT_TABLES.copy()
if event_types:
tables_to_query = {k: v for k, v in self.EVENT_TABLES.items() if k in event_types}
if not tables_to_query:
return {"buckets": [], "time_range": {"start": None, "end": None}, "interval_seconds": 0, "total_events": 0}
sensitive_caps = [
'CAP_SYS_ADMIN', 'CAP_NET_ADMIN', 'CAP_NET_RAW',
'CAP_SYS_PTRACE', 'CAP_SYS_MODULE', 'CAP_DAC_OVERRIDE',
'CAP_SETUID', 'CAP_SETGID', 'CAP_CHOWN', 'CAP_FOWNER',
'CAP_SYS_RAWIO', 'CAP_MKNOD', 'CAP_LINUX_IMMUTABLE'
]
sensitive_caps_str = ", ".join([f"'{c}'" for c in sensitive_caps])
cap_filter = f" AND (verdict = 'denied' OR verdict = '1' OR toString(verdict) = '1' OR capability IN ({sensitive_caps_str}))"
# Step 1: Find global time range across all tables
global_min = None
global_max = None
available_tables: Dict[str, str] = {}
L7_TABLES_H = {"l7_http_flows", "l7_grpc_flows", "l7_dns_flows"}
SOURCE_COL_TABLES_H = {"network_flows", "dns_queries"}
for event_type, table_name in tables_to_query.items():
ns_col = "source_namespace" if table_name in SOURCE_COL_TABLES_H else ("src_namespace" if table_name in L7_TABLES_H else "namespace")
where = self._build_where_clause(
cluster_id, analysis_id,
start_time=start_time, end_time=end_time,
namespace_column=ns_col
)
if table_name == "capability_checks":
where += cap_filter
try:
query = f"SELECT min(timestamp), max(timestamp), count() FROM {table_name} WHERE {where}"
result = self.client.execute(query)
if result and result[0][2] > 0:
t_min, t_max = result[0][0], result[0][1]
if global_min is None or t_min < global_min:
global_min = t_min
if global_max is None or t_max > global_max:
global_max = t_max
available_tables[event_type] = table_name
except Exception:
continue
if global_min is None or global_max is None:
return {"buckets": [], "time_range": {"start": None, "end": None}, "interval_seconds": 0, "total_events": 0}
# Step 2: Compute interval
total_seconds = (global_max - global_min).total_seconds()
interval_seconds = max(1, math.ceil(total_seconds / bucket_count))
# Step 3: Query histogram per table (only available tables)
raw_buckets: Dict[str, Dict[str, int]] = {}
total_events = 0
for event_type, table_name in available_tables.items():
ns_col = "source_namespace" if table_name in SOURCE_COL_TABLES_H else ("src_namespace" if table_name in L7_TABLES_H else "namespace")
where = self._build_where_clause(
cluster_id, analysis_id,
start_time=start_time, end_time=end_time,
namespace_column=ns_col
)
if table_name == "capability_checks":
where += cap_filter
try:
hist_query = f"""
SELECT
toStartOfInterval(timestamp, INTERVAL {interval_seconds} SECOND) as bucket,
count() as cnt
FROM {table_name}
WHERE {where}
GROUP BY bucket
ORDER BY bucket
"""
result = self.client.execute(hist_query)
for row in result:
bucket_time = row[0].isoformat() if isinstance(row[0], datetime) else str(row[0])
count = row[1]
total_events += count
if bucket_time not in raw_buckets:
raw_buckets[bucket_time] = {}
raw_buckets[bucket_time][event_type] = raw_buckets[bucket_time].get(event_type, 0) + count
except Exception:
continue
# Step 4-5: Build complete bucket series with empty fills.
# Align to epoch-based boundaries matching ClickHouse toStartOfInterval:
# intDiv(toUnixTimestamp(ts), N) * N
epoch = datetime(1970, 1, 1)
if global_min.tzinfo:
epoch = epoch.replace(tzinfo=global_min.tzinfo)
global_min_unix = int((global_min - epoch).total_seconds())
global_max_unix = int((global_max - epoch).total_seconds())
aligned_start = epoch + timedelta(seconds=(global_min_unix // interval_seconds) * interval_seconds)
aligned_end = epoch + timedelta(seconds=(global_max_unix // interval_seconds) * interval_seconds)
buckets = []
current = aligned_start
while current <= aligned_end:
bucket_key = current.isoformat()
types = raw_buckets.get(bucket_key, {})
bucket_total = sum(types.values())
buckets.append({
"time": bucket_key,
"count": bucket_total,
"types": types
})
current = current + timedelta(seconds=interval_seconds)
if len(buckets) > bucket_count + 2:
buckets = buckets[:bucket_count + 2]
return {
"buckets": buckets,
"time_range": {
"start": global_min.isoformat(),
"end": global_max.isoformat()
},
"interval_seconds": interval_seconds,
"total_events": total_events
}
except Exception as e:
logger.error(f"Failed to query event histogram: {e}")
raise
def _escape_ch(self, value: str) -> str:
"""Escape a string for safe inclusion inside a ClickHouse single-quoted
literal. Backslashes MUST be doubled before single quotes are doubled —
ClickHouse accepts both `''` and `\'` as escape sequences inside
single-quoted strings, so a naive `.replace("'", "''")` leaves an
attacker-controlled `\'` payload exploitable (the leading backslash
consumes the first quote and the trailing one closes the literal).
Order matters: escape `\` first, then `'`.
"""
return str(value).replace("\\", "\\\\").replace("'", "''")
def _build_l7_base_where(
self,
cluster_id=None,
analysis_id=None,
namespace: Optional[str] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
) -> str:
conditions = ["1=1"]
if cluster_id is not None and str(cluster_id).strip() not in ("", "0"):
conditions.append(f"cluster_id = '{self._escape_ch(str(cluster_id))}'")
if analysis_id is not None:
aid = self._escape_ch(str(analysis_id))
conditions.append(f"(analysis_id = '{aid}' OR analysis_id LIKE '{aid}-%')")
if namespace:
ns = self._escape_ch(namespace)
conditions.append(f"(src_namespace = '{ns}' OR dst_namespace = '{ns}')")
if start_time:
conditions.append(
f"timestamp >= parseDateTimeBestEffort('{self._escape_ch(start_time)}')"
)
if end_time:
conditions.append(
f"timestamp <= parseDateTimeBestEffort('{self._escape_ch(end_time)}')"
)
return " AND ".join(conditions)
async def query_l7_http_flows(
self,
cluster_id: Optional[str] = None,
analysis_id: Optional[str] = None,
namespace: Optional[str] = None,
method: Optional[str] = None,
path: Optional[str] = None,
status_code: Optional[int] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
limit: int = 100,
offset: int = 0,
) -> Tuple[List[Dict[str, Any]], int]:
where = self._build_l7_base_where(
cluster_id, analysis_id, namespace, start_time, end_time
)
if method:
where += f" AND http_method = '{self._escape_ch(method)}'"
if path:
where += f" AND http_path = '{self._escape_ch(path)}'"
if status_code is not None:
where += f" AND http_status_code = {int(status_code)}"
table = "l7_http_flows"
try:
count_query = f"SELECT count() FROM {table} WHERE {where}"
total = self.client.execute(count_query)[0][0]
data_query = f"""
SELECT * FROM {table} WHERE {where}
ORDER BY timestamp DESC
LIMIT {int(limit)} OFFSET {int(offset)}
"""
result = self.client.execute(data_query, with_column_types=True)
if not result:
return ([], total)
rows, columns = result
column_names = [col[0] for col in columns]
events = []
for row in rows:
event = {}
for i, col_name in enumerate(column_names):
value = row[i]
if isinstance(value, datetime):
value = value.isoformat()
event[col_name] = value
events.append(event)
return (events, total)
except Exception as e:
logger.warning(f"L7 HTTP flows query failed (table may not exist): {e}")
return ([], 0)
async def query_l7_grpc_flows(
self,
cluster_id: Optional[str] = None,
analysis_id: Optional[str] = None,
namespace: Optional[str] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
grpc_service: Optional[str] = None,
grpc_method: Optional[str] = None,
grpc_status_code: Optional[int] = None,
limit: int = 100,
offset: int = 0,
) -> Tuple[List[Dict[str, Any]], int]:
where = self._build_l7_base_where(
cluster_id, analysis_id, namespace, start_time, end_time
)
if grpc_service:
where += f" AND grpc_service = '{self._escape_ch(grpc_service)}'"
if grpc_method:
where += f" AND grpc_method = '{self._escape_ch(grpc_method)}'"
if grpc_status_code is not None:
where += f" AND grpc_status_code = {int(grpc_status_code)}"
table = "l7_grpc_flows"
try:
count_query = f"SELECT count() FROM {table} WHERE {where}"
total = self.client.execute(count_query)[0][0]
data_query = f"""
SELECT * FROM {table} WHERE {where}
ORDER BY timestamp DESC
LIMIT {int(limit)} OFFSET {int(offset)}
"""
result = self.client.execute(data_query, with_column_types=True)
if not result:
return ([], total)
rows, columns = result
column_names = [col[0] for col in columns]
events = []
for row in rows:
event = {}
for i, col_name in enumerate(column_names):
value = row[i]
if isinstance(value, datetime):
value = value.isoformat()
event[col_name] = value
events.append(event)
return (events, total)
except Exception as e:
logger.warning(f"L7 gRPC flows query failed (table may not exist): {e}")
return ([], 0)
async def query_l7_dns_flows(
self,
cluster_id: Optional[str] = None,
analysis_id: Optional[str] = None,
namespace: Optional[str] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
query_name: Optional[str] = None,
query_type: Optional[str] = None,
response_code: Optional[int] = None,
limit: int = 100,
offset: int = 0,
) -> Tuple[List[Dict[str, Any]], int]:
where = self._build_l7_base_where(
cluster_id, analysis_id, namespace, start_time, end_time
)
if query_name:
where += f" AND query_name = '{self._escape_ch(query_name)}'"
if query_type:
where += f" AND query_type = '{self._escape_ch(query_type)}'"
if response_code is not None:
where += f" AND response_code = {int(response_code)}"
table = "l7_dns_flows"
try:
count_query = f"SELECT count() FROM {table} WHERE {where}"
total = self.client.execute(count_query)[0][0]
data_query = f"""
SELECT * FROM {table} WHERE {where}
ORDER BY timestamp DESC
LIMIT {int(limit)} OFFSET {int(offset)}
"""
result = self.client.execute(data_query, with_column_types=True)
if not result:
return ([], total)
rows, columns = result
column_names = [col[0] for col in columns]
events = []
for row in rows:
event = {}
for i, col_name in enumerate(column_names):
value = row[i]
if isinstance(value, datetime):
value = value.isoformat()
event[col_name] = value
events.append(event)
return (events, total)
except Exception as e:
logger.warning(f"L7 DNS flows query failed (table may not exist): {e}")
return ([], 0)
async def query_l7_events_stats(
self,
cluster_id: Optional[str] = None,
analysis_id: Optional[str] = None,
namespace: Optional[str] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
) -> Dict[str, Any]:
base = self._build_l7_base_where(
cluster_id, analysis_id, namespace, start_time, end_time
)
http_where = base
grpc_where = base
dns_where = base
empty_row = (0, 0, 0.0)
try:
http_row = self.client.execute(
f"""
SELECT
count() AS total,
countIf(http_status_code >= 400) AS errors,
if(count() > 0, avg(latency_ms), 0) AS avg_latency
FROM l7_http_flows
WHERE {http_where}
"""
)[0]
except Exception as e:
logger.warning(f"L7 HTTP stats query failed: {e}")
http_row = empty_row
try:
grpc_row = self.client.execute(
f"""
SELECT
count() AS total,
countIf(grpc_status_code != 0) AS errors,
if(count() > 0, avg(latency_ms), 0) AS avg_latency
FROM l7_grpc_flows
WHERE {grpc_where}
"""
)[0]
except Exception as e:
logger.warning(f"L7 gRPC stats query failed: {e}")
grpc_row = empty_row
try:
dns_row = self.client.execute(
f"""
SELECT
count() AS total,
countIf(response_code != 0) AS errors,
if(count() > 0, avg(latency_ms), 0) AS avg_latency
FROM l7_dns_flows
WHERE {dns_where}
"""
)[0]
except Exception as e:
logger.warning(f"L7 DNS stats query failed: {e}")
dns_row = empty_row
def pack(row) -> Dict[str, Any]:
total = int(row[0] or 0)
err = int(row[1] or 0)
lat = row[2]
try:
avg_lat = float(lat) if lat is not None else 0.0
import math
if math.isnan(avg_lat) or math.isinf(avg_lat):
avg_lat = 0.0
except (TypeError, ValueError):
avg_lat = 0.0
return {
"total_requests": total,
"error_count": err,
"avg_latency_ms": round(avg_lat, 4),
"error_rate_percent": round((err / total) * 100.0, 4) if total > 0 else 0.0,
}
http_s = pack(http_row)
grpc_s = pack(grpc_row)
dns_s = pack(dns_row)
total_req = http_s["total_requests"] + grpc_s["total_requests"] + dns_s["total_requests"]
total_err = http_s["error_count"] + grpc_s["error_count"] + dns_s["error_count"]
combined_avg = 0.0
if total_req > 0:
weighted = (
http_s["avg_latency_ms"] * http_s["total_requests"]
+ grpc_s["avg_latency_ms"] * grpc_s["total_requests"]
+ dns_s["avg_latency_ms"] * dns_s["total_requests"]
)
combined_avg = round(weighted / total_req, 4)
return {
"cluster_id": str(cluster_id) if cluster_id else None,
"analysis_id": str(analysis_id) if analysis_id is not None else None,
"namespace": namespace,
"http": http_s,
"grpc": grpc_s,
"dns": dns_s,
"total_requests": total_req,
"total_errors": total_err,
"error_rate_percent": round((total_err / total_req) * 100.0, 4) if total_req > 0 else 0.0,
"avg_latency_ms": combined_avg,
}
async def query_l7_http_histogram(
self,
cluster_id: Optional[str] = None,
analysis_id: Optional[str] = None,
namespace: Optional[str] = None,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
bucket_count: int = 60,
) -> Dict[str, Any]:
"""
Time-based HTTP histogram aligned with l7_http_flows_5min_mv (5-minute buckets).
Reads from l7_http_flows_5min_mv when no namespace filter (MV has no namespace
columns). With namespace, rolls up l7_http_flows with toStartOfFiveMinutes.
"""
use_mv = not namespace
if use_mv:
conditions = ["1=1"]
if cluster_id is not None and str(cluster_id).strip() not in ("", "0"):
conditions.append(f"cluster_id = '{self._escape_ch(str(cluster_id))}'")
if analysis_id is not None:
aid = self._escape_ch(str(analysis_id))
conditions.append(f"(analysis_id = '{aid}' OR analysis_id LIKE '{aid}-%')")
if start_time:
conditions.append(
f"timestamp_5min >= toStartOfFiveMinutes(parseDateTimeBestEffort('{self._escape_ch(start_time)}'))"
)
if end_time:
conditions.append(
f"timestamp_5min <= toStartOfFiveMinutes(parseDateTimeBestEffort('{self._escape_ch(end_time)}'))"
)
where_mv = " AND ".join(conditions)
bounds = self.client.execute(
f"""
SELECT min(timestamp_5min), max(timestamp_5min), sum(request_count)
FROM l7_http_flows_5min_mv
WHERE {where_mv}
"""
)
if not bounds or not bounds[0][0] or (bounds[0][2] or 0) == 0:
return {
"buckets": [],
"time_range": {"start": None, "end": None},
"interval_seconds": 300,
"total_requests": 0,
"source": "l7_http_flows_5min_mv",
}
global_min, global_max = bounds[0][0], bounds[0][1]
total_requests = int(bounds[0][2] or 0)
total_seconds = (global_max - global_min).total_seconds()
interval_seconds = max(300, math.ceil(total_seconds / max(1, bucket_count)))
interval_seconds = (interval_seconds // 300) * 300 or 300
hist_query = f"""
SELECT
toStartOfInterval(timestamp_5min, INTERVAL {interval_seconds} SECOND) AS bucket,
sum(request_count) AS cnt,
sum(error_count) AS err_cnt,
if(sum(request_count) > 0, sum(total_latency_ms) / sum(request_count), 0) AS avg_lat
FROM l7_http_flows_5min_mv
WHERE {where_mv}
GROUP BY bucket
ORDER BY bucket
"""
result = self.client.execute(hist_query)
buckets = []
for row in result:
bt = row[0]
buckets.append(
{
"time": bt.isoformat() if isinstance(bt, datetime) else str(bt),
"request_count": int(row[1] or 0),
"error_count": int(row[2] or 0),
"avg_latency_ms": round(float(row[3] or 0), 4),
}
)
return {
"buckets": buckets,
"time_range": {
"start": global_min.isoformat(),
"end": global_max.isoformat(),
},
"interval_seconds": interval_seconds,
"total_requests": total_requests,
"namespace": None,
"source": "l7_http_flows_5min_mv",
}
where = self._build_l7_base_where(
cluster_id, analysis_id, namespace, start_time, end_time
)
bounds = self.client.execute(
f"""
SELECT min(timestamp), max(timestamp), count()
FROM l7_http_flows
WHERE {where}
"""
)
if not bounds or not bounds[0][0] or (bounds[0][2] or 0) == 0:
return {
"buckets": [],
"time_range": {"start": None, "end": None},
"interval_seconds": 300,
"total_requests": 0,
"source": "l7_http_flows",
}
global_min, global_max = bounds[0][0], bounds[0][1]
total_requests = int(bounds[0][2] or 0)
total_seconds = (global_max - global_min).total_seconds()
interval_seconds = max(300, math.ceil(total_seconds / max(1, bucket_count)))
interval_seconds = (interval_seconds // 300) * 300 or 300
hist_query = f"""
SELECT
toStartOfInterval(toStartOfFiveMinutes(timestamp), INTERVAL {interval_seconds} SECOND) AS bucket,
count() AS cnt,
countIf(http_status_code >= 400) AS err_cnt,
avg(latency_ms) AS avg_lat
FROM l7_http_flows
WHERE {where}
GROUP BY bucket
ORDER BY bucket
"""
result = self.client.execute(hist_query)
buckets = []
for row in result:
bt = row[0]
buckets.append(
{
"time": bt.isoformat() if isinstance(bt, datetime) else str(bt),
"request_count": int(row[1] or 0),
"error_count": int(row[2] or 0),
"avg_latency_ms": round(float(row[3] or 0), 4),
}
)
return {
"buckets": buckets,
"time_range": {
"start": global_min.isoformat(),
"end": global_max.isoformat(),
},
"interval_seconds": interval_seconds,
"total_requests": total_requests,
"namespace": namespace,
"source": "l7_http_flows",
}
def health_check(self) -> Dict[str, Any]:
"""Check database health"""
try:
start = datetime.now(timezone.utc)
self.client.execute("SELECT 1")
latency_ms = (datetime.now(timezone.utc) - start).total_seconds() * 1000
return {
"healthy": True,
"latency_ms": round(latency_ms, 2),
"database": settings.clickhouse_database
}
except Exception as e:
return {
"healthy": False,
"error": str(e)
}
async def delete_analysis_data(
self,
analysis_id: int,
wait_for_completion: bool = True,
timeout_seconds: int = 60
) -> Dict[str, Any]:
"""
Delete all events for an analysis from all tables
Args:
analysis_id: Analysis ID to delete data for
wait_for_completion: If True, wait for mutations to complete
timeout_seconds: Max time to wait for mutations
Returns:
Deletion summary with counts and timing
"""
import asyncio
import time
start_time = time.time()
# All tables that might contain analysis-specific data.
# NOTE: tcp_lifecycle included even though no data is written to it currently
# — this ensures complete cleanup if data is ever written in the future.
# NOTE: APM RED MVs (Phase 2) point at separate destination tables, not
# the *_mv view names. We delete from the destination table directly so
# mutations actually drop rows; deleting from a MV is a no-op.
tables = list(self.EVENT_TABLES.values()) + [
'workload_metadata',
'communication_edges',
'tcp_lifecycle',
'change_events',
'l7_http_flows_5min_mv',
# APM RED MVs (Phase 2) — destination tables for AggregatingMergeTree
'l7_http_red_svc_5min',
'l7_http_red_ops_5min',
'l7_grpc_red_svc_5min',
'l7_grpc_red_ops_5min',
'l7_dns_red_svc_5min',
]
# Step 1: Get counts before deletion
# Multi-cluster support: match both single-cluster (analysis_id = '123')
# and multi-cluster (analysis_id LIKE '123-%') formats
counts_before = {}
for table in tables:
try:
result = self.client.execute(
f"SELECT count() as cnt FROM {table} WHERE analysis_id = '{analysis_id}' OR analysis_id LIKE '{analysis_id}-%'"
)
counts_before[table] = result[0][0] if result else 0
except Exception:
counts_before[table] = 0
total_to_delete = sum(counts_before.values())
logger.info(f"Found {total_to_delete} records to delete for analysis_id={analysis_id}")
if total_to_delete == 0:
return {
"tables": counts_before,
"total_deleted": 0,
"completed": True,
"duration_ms": int((time.time() - start_time) * 1000)
}
# Step 2: Submit delete mutations
# Multi-cluster support: delete both single and multi-cluster analysis_id formats
for table in tables:
if counts_before.get(table, 0) == 0:
continue
try:
self.client.execute(
f"ALTER TABLE {table} DELETE WHERE analysis_id = '{analysis_id}' OR analysis_id LIKE '{analysis_id}-%'"
)
logger.debug(f"Delete mutation submitted for {table}")
except Exception as e:
logger.warning(f"Failed to delete from {table}: {e}")
# Step 3: Wait for mutations if requested
completed = True
if wait_for_completion:
deadline = time.time() + timeout_seconds
while time.time() < deadline:
try:
pending = self.client.execute("""
SELECT table, mutation_id, is_done
FROM system.mutations
WHERE database = 'flowfish' AND is_done = 0
AND create_time > now() - INTERVAL 5 MINUTE
""")
pending_tables = {r[0] for r in pending} if pending else set()
our_pending = set(tables) & pending_tables
if not our_pending:
break
await asyncio.sleep(0.5)
except Exception:
await asyncio.sleep(1)
else:
completed = False
# Step 4: Get counts after deletion (verify with same multi-cluster pattern)
counts_after = {}
for table in tables:
try:
result = self.client.execute(
f"SELECT count() as cnt FROM {table} WHERE analysis_id = '{analysis_id}' OR analysis_id LIKE '{analysis_id}-%'"
)
counts_after[table] = result[0][0] if result else 0
except Exception:
counts_after[table] = 0
deleted_counts = {
table: counts_before.get(table, 0) - counts_after.get(table, 0)
for table in tables
}
total_deleted = sum(deleted_counts.values())
duration_ms = int((time.time() - start_time) * 1000)
logger.info(f"Deletion completed: analysis_id={analysis_id}, total_deleted={total_deleted}, duration_ms={duration_ms}")
return {
"tables": deleted_counts,
"total_deleted": total_deleted,
"completed": completed,
"duration_ms": duration_ms
}
# ============================================================
# L7 Distributed Tracing API (Faz 3.1)
# Reads from l7_http_flows + l7_grpc_flows. DNS spans are excluded
# because the DNS protocol cannot propagate W3C traceparent headers.
# All methods raise on database error; callers should propagate to
# the HTTP layer (do not silently return empty lists).
# ============================================================
def get_trace_spans(
self,
trace_id: str,
analysis_id: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Return all spans (HTTP + gRPC) for a given trace_id, ordered by timestamp.
When `analysis_id` is supplied the result is restricted to that
analysis (and its multi-cluster sub-analyses via the `<aid>-*` prefix).
When omitted, the lookup spans every analysis — used for the Trace
Explorer deep-link where the operator only has the trace_id (the
16-byte hex string is unique per W3C spec, so collisions across
analyses are not a concern).
Phase 4: matches BOTH `trace_id` and `virtual_trace_id`. The two
columns share the same hex width and are mutually exclusive per
row (see virtual_trace_correlator.correlate — events with a real
trace_id are skipped). The `virtual_trace_id != ''` guard
prevents matching unstamped legacy rows. The bloom_filter index
added by clickhouse_007_add_l7_pid.sql accelerates the OR clause;
when the column is missing on legacy schemas the writer leaves
virtual_trace_id at default '' and the OR collapses to the
original trace_id-only behaviour with no performance penalty.
"""
tid = _validate_trace_id(trace_id)
if analysis_id is not None and str(analysis_id):
aid = self._escape_ch(str(analysis_id))
scope = f"AND (analysis_id = '{aid}' OR analysis_id LIKE '{aid}-%')"
else:
scope = ""
# `virtual_trace_id` is added defensively as a column reference: on
# clusters where clickhouse_007 hasn't been applied yet the column
# doesn't exist and the query throws "Unknown column virtual_trace_id".
# We catch that and fall back to a query without the OR.
primary_query = f"""
SELECT trace_id, span_id, parent_span_id, span_name, span_kind,
timestamp, analysis_id, cluster_id, cluster_name,
src_namespace, src_workload, src_pod, src_ip, src_port,
dst_namespace, dst_workload, dst_pod, dst_ip, dst_port,
virtual_trace_id,
'HTTP' AS protocol,
http_method AS method, http_path AS path, '' AS grpc_service,
toInt32(http_status_code) AS status_code, latency_ms
FROM l7_http_flows
WHERE (trace_id = '{tid}' OR (virtual_trace_id = '{tid}' AND virtual_trace_id != ''))
{scope}
UNION ALL
SELECT trace_id, span_id, parent_span_id, span_name, span_kind,
timestamp, analysis_id, cluster_id, cluster_name,
src_namespace, src_workload, src_pod, src_ip, src_port,
dst_namespace, dst_workload, dst_pod, dst_ip, dst_port,
virtual_trace_id,
'GRPC' AS protocol,
grpc_method AS method, grpc_service AS path, grpc_service,
grpc_status_code AS status_code, latency_ms
FROM l7_grpc_flows
WHERE (trace_id = '{tid}' OR (virtual_trace_id = '{tid}' AND virtual_trace_id != ''))
{scope}
ORDER BY timestamp ASC
"""
legacy_query = f"""
SELECT trace_id, span_id, parent_span_id, span_name, span_kind,
timestamp, analysis_id, cluster_id, cluster_name,
src_namespace, src_workload, src_pod, src_ip, src_port,
dst_namespace, dst_workload, dst_pod, dst_ip, dst_port,
'' AS virtual_trace_id,
'HTTP' AS protocol,
http_method AS method, http_path AS path, '' AS grpc_service,
toInt32(http_status_code) AS status_code, latency_ms
FROM l7_http_flows
WHERE trace_id = '{tid}' {scope}
UNION ALL
SELECT trace_id, span_id, parent_span_id, span_name, span_kind,
timestamp, analysis_id, cluster_id, cluster_name,
src_namespace, src_workload, src_pod, src_ip, src_port,
dst_namespace, dst_workload, dst_pod, dst_ip, dst_port,
'' AS virtual_trace_id,
'GRPC' AS protocol,
grpc_method AS method, grpc_service AS path, grpc_service,
grpc_status_code AS status_code, latency_ms
FROM l7_grpc_flows
WHERE trace_id = '{tid}' {scope}
ORDER BY timestamp ASC
"""
try:
result = self.client.execute(primary_query, with_column_types=True)
except Exception as e:
err_str = str(e)
if (
"Unknown column" in err_str
or "doesn't have column" in err_str
or "No such column" in err_str
) and "virtual_trace_id" in err_str:
logger.warning(
"virtual_trace_id column missing — falling back to legacy trace_id-only query (apply clickhouse_007 to enable Phase 4)"
)
result = self.client.execute(legacy_query, with_column_types=True)
else:
raise
rows, cols = result if isinstance(result, tuple) else (result, [])
col_names = [c[0] for c in cols]
spans = [dict(zip(col_names, row)) for row in rows]
for s in spans:
ts = s.get("timestamp")
if hasattr(ts, "isoformat"):
s["timestamp"] = ts.isoformat()
return spans
def get_recent_traces(
self,
analysis_id: str,
workload: Optional[str] = None,
limit: int = 50,
offset: int = 0,
# Phase 1A filters (optional, backward-compat). Each filter narrows
# the returned trace set further; passing none reproduces the
# legacy behaviour exactly.
cluster_id: Optional[str] = None,
src_workload: Optional[str] = None,
dst_workload: Optional[str] = None,
operation: Optional[str] = None,
min_latency_ms: Optional[float] = None,
# Plan v3 Akış B m.3 (B1.1, B1.2): trace-level upper bound on the
# aggregated `max(latency_ms)`. Used by the Trace Explorer latency
# histogram bucket click — combined with `min_latency_ms` it lets
# operators isolate traces whose worst span falls inside a bucket
# (e.g. 100-500ms tail).
max_latency_ms: Optional[float] = None,
error_only: bool = False,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
# Plan v3 Akış B m.4: free-form search across operation, src/dst
# workload, namespace and trace_id. Mapped to ClickHouse via
# `_build_search_condition` which already escapes single quotes
# and short-circuits on empty / oversized inputs.
q: Optional[str] = None,
) -> Dict[str, Any]:
"""List recent traces (grouped by trace_id) for an analysis, optionally filtered by workload.
Returns {"traces": [...], "total": N}. Each trace contains start/end time,
span count, error count, max latency, and the set of clusters involved.
Filter semantics:
- workload: matches src_workload OR dst_workload (legacy combined filter).
- src_workload / dst_workload: column-specific filters (apply on top of `workload`).
- operation: matches http_path on HTTP rows OR grpc_method on gRPC rows.
- min_latency_ms: keeps only spans with latency >= threshold (any span in a
trace passing the threshold keeps the whole trace).
- max_latency_ms: trace-level upper bound — keeps only traces whose
aggregated max(latency_ms) < threshold (HAVING clause). The bucket
click on the latency histogram passes both min and max to scope a
trace to a specific log-scale bucket (Plan v3 Akış B m.3).
- error_only: keeps only HTTP 4xx/5xx OR gRPC non-zero status spans.
- start_time / end_time: ISO-8601, applied to span timestamps.
- q: free-form search; matches operation, workloads, namespace, trace_id.
"""
aid = self._escape_ch(str(analysis_id))
# Shared filters that apply equally to both HTTP and gRPC rows.
shared_filters: List[str] = []
if cluster_id:
# cluster_id is a stable identifier (numeric string from the
# clusters table). Escape defensively in case a multi-cluster
# rename ever lets non-numeric values through.
shared_filters.append(
f"cluster_id = '{self._escape_ch(str(cluster_id))}'"
)
if workload:
wl = self._escape_ch(str(workload))
shared_filters.append(f"(src_workload = '{wl}' OR dst_workload = '{wl}')")
if src_workload:
shared_filters.append(f"src_workload = '{self._escape_ch(str(src_workload))}'")
if dst_workload:
shared_filters.append(f"dst_workload = '{self._escape_ch(str(dst_workload))}'")
if min_latency_ms is not None:
shared_filters.append(f"latency_ms >= {float(min_latency_ms)}")
if start_time:
shared_filters.append(
f"timestamp >= parseDateTimeBestEffort('{self._escape_ch(start_time)}')"
)
if end_time:
shared_filters.append(
f"timestamp <= parseDateTimeBestEffort('{self._escape_ch(end_time)}')"
)
shared_clause = (" AND " + " AND ".join(shared_filters)) if shared_filters else ""
# Protocol-specific filters: `operation` and `error_only` map to
# different columns for HTTP vs gRPC. We build separate clauses to
# avoid impossible cross-column constraints.
http_extra: List[str] = []
grpc_extra: List[str] = []
if operation:
op_esc = self._escape_ch(str(operation))
http_extra.append(f"http_path = '{op_esc}'")
grpc_extra.append(f"grpc_method = '{op_esc}'")
if error_only:
http_extra.append("http_status_code >= 400")
grpc_extra.append("grpc_status_code != 0")
# Plan v3 Akış B m.4 (B1.3): free-form search. We extend the
# existing TABLE_SEARCH_FIELDS list with `trace_id` so operators
# can paste a short hex prefix and still get a hit. Empty / blank
# `q` is filtered out upstream (`min_length=1`) but we keep the
# defensive `if q` guard here.
if q:
http_search_cond = self._build_search_condition(
q, list(self.TABLE_SEARCH_FIELDS["l7_http_flows"]) + ["trace_id"]
)
grpc_search_cond = self._build_search_condition(
q, list(self.TABLE_SEARCH_FIELDS["l7_grpc_flows"]) + ["trace_id"]
)
if http_search_cond:
http_extra.append(http_search_cond)
if grpc_search_cond:
grpc_extra.append(grpc_search_cond)
http_clause = (" AND " + " AND ".join(http_extra)) if http_extra else ""
grpc_clause = (" AND " + " AND ".join(grpc_extra)) if grpc_extra else ""
# Trace-level HAVING — applies to the aggregated max(latency_ms),
# not individual spans. Only used when the histogram bucket click
# provides an upper bound (Plan v3 Akış B m.3 / B1.2).
trace_having_clauses: List[str] = []
if max_latency_ms is not None:
trace_having_clauses.append(f"max_lat < {float(max_latency_ms)}")
having_clause = (
" HAVING " + " AND ".join(trace_having_clauses)
if trace_having_clauses
else ""
)
# Per-table aggregation, then outer aggregation merges HTTP + gRPC.
# error_count semantics: HTTP -> status>=400; gRPC -> grpc_status_code != 0.
# NOTE — Phase 4 known gap: this query intentionally returns ONLY rows
# with a non-empty W3C `trace_id`. PID-correlated virtual traces
# (rows where `trace_id = ''` but `virtual_trace_id != ''`) are NOT
# surfaced in the Recent Traces list because mixing the two without
# a UI badge would confuse operators. Virtual traces are still:
# (1) discoverable via the Related Traces tab on any anchor trace,
# (2) addressable directly via the Trace Explorer search box (paste
# the virtual_trace_id; get_trace_spans matches both columns), and
# (3) tracked in the L7_DISTRIBUTED_TRACING_MIGRATION runbook.
# Surfacing them in this list will require an `include_virtual` opt-in
# plus an `is_virtual` badge column on the frontend.
inner = f"""
SELECT trace_id, min(timestamp) AS ts, max(timestamp) AS ts2,
count() AS cnt, countIf(http_status_code >= 400) AS errs,
max(latency_ms) AS max_lat,
groupUniqArray(cluster_id) AS clusters_arr
FROM l7_http_flows
WHERE (analysis_id = '{aid}' OR analysis_id LIKE '{aid}-%')
AND trace_id != ''{shared_clause}{http_clause}
GROUP BY trace_id
UNION ALL
SELECT trace_id, min(timestamp), max(timestamp),
count(), countIf(grpc_status_code != 0),
max(latency_ms),
groupUniqArray(cluster_id)
FROM l7_grpc_flows
WHERE (analysis_id = '{aid}' OR analysis_id LIKE '{aid}-%')
AND trace_id != ''{shared_clause}{grpc_clause}
GROUP BY trace_id
"""
query = f"""
SELECT trace_id, min(ts) AS start_time, max(ts2) AS end_time,
sum(cnt) AS span_count, sum(errs) AS error_count,
max(max_lat) AS max_latency_ms,
groupUniqArrayArray(clusters_arr) AS clusters
FROM ({inner})
GROUP BY trace_id{having_clause}
ORDER BY start_time DESC
LIMIT {int(limit)} OFFSET {int(offset)}
"""
rows = self.client.execute(query)
# Total distinct traces (HTTP + gRPC combined). Separate query because
# the LIMIT/OFFSET above doesn't allow direct count.
# Plan v3 Akış B m.3 (B1.2 fix): when `max_latency_ms` is in play
# we can't just count the union — we have to apply the same
# trace-level HAVING. The two-level subquery materialises the
# `max(latency_ms)` per trace_id and counts rows that pass.
if having_clause:
total_query = f"""
SELECT count() FROM (
SELECT trace_id, max(max_lat) AS max_lat FROM (
SELECT trace_id, max(latency_ms) AS max_lat FROM l7_http_flows
WHERE (analysis_id = '{aid}' OR analysis_id LIKE '{aid}-%')
AND trace_id != ''{shared_clause}{http_clause}
GROUP BY trace_id
UNION ALL
SELECT trace_id, max(latency_ms) AS max_lat FROM l7_grpc_flows
WHERE (analysis_id = '{aid}' OR analysis_id LIKE '{aid}-%')
AND trace_id != ''{shared_clause}{grpc_clause}
GROUP BY trace_id
)
GROUP BY trace_id{having_clause}
)
"""
else:
total_query = f"""
SELECT uniqExact(trace_id) FROM (
SELECT trace_id FROM l7_http_flows
WHERE (analysis_id = '{aid}' OR analysis_id LIKE '{aid}-%')
AND trace_id != ''{shared_clause}{http_clause}
UNION ALL
SELECT trace_id FROM l7_grpc_flows
WHERE (analysis_id = '{aid}' OR analysis_id LIKE '{aid}-%')
AND trace_id != ''{shared_clause}{grpc_clause}
)
"""
total_rows = self.client.execute(total_query)
total = int(total_rows[0][0]) if total_rows else 0
traces = []
for row in rows:
trace_id, start_time, end_time, span_count, error_count, max_latency, clusters = row
duration_ms = 0.0
if start_time and end_time:
try:
duration_ms = (end_time - start_time).total_seconds() * 1000.0
except (TypeError, AttributeError):
duration_ms = 0.0
traces.append({
"trace_id": trace_id,
"start_time": start_time.isoformat() if hasattr(start_time, "isoformat") else start_time,
"end_time": end_time.isoformat() if hasattr(end_time, "isoformat") else end_time,
"span_count": int(span_count or 0),
"error_count": int(error_count or 0),
"max_latency_ms": float(max_latency or 0.0),
"duration_ms": duration_ms,
"clusters": [str(c) for c in (clusters or []) if c],
})
return {"traces": traces, "total": total, "limit": int(limit), "offset": int(offset)}
def get_trace_summary(
self,
trace_id: str,
analysis_id: Optional[str] = None,
spans: Optional[List[Dict[str, Any]]] = None,
) -> Dict[str, Any]:
"""Return summary statistics for a single trace.
If `spans` is supplied (as in the /l7/traces/{trace_id} endpoint that
also returns the spans themselves) we skip the second ClickHouse round
trip and compute the summary in-memory. Backward compatible: when
`spans` is None we fall back to fetching them. `analysis_id` accepts
None for the Trace Explorer deep-link path (looks up across
analyses); see get_trace_spans() for the scoping rules.
"""
if spans is None:
spans = self.get_trace_spans(trace_id, analysis_id)
if not spans:
return {
"trace_id": trace_id,
"span_count": 0,
"clusters": [],
"services": [],
"error_count": 0,
"duration_ms": 0.0,
}
clusters = sorted({s.get("cluster_id") for s in spans if s.get("cluster_id")})
services = sorted({
s.get("dst_workload") for s in spans if s.get("dst_workload")
} | {
s.get("src_workload") for s in spans if s.get("src_workload")
})
errors = 0
for s in spans:
sc = s.get("status_code") or 0
proto = s.get("protocol")
if proto == "HTTP" and sc >= 400:
errors += 1
elif proto == "GRPC" and sc != 0:
errors += 1
timestamps = [s.get("timestamp") for s in spans if s.get("timestamp")]
duration_ms = 0.0
if len(timestamps) >= 2:
try:
ts_dt = [datetime.fromisoformat(t) if isinstance(t, str) else t for t in timestamps]
duration_ms = (max(ts_dt) - min(ts_dt)).total_seconds() * 1000.0
except (ValueError, TypeError):
duration_ms = 0.0
return {
"trace_id": trace_id,
"span_count": len(spans),
"clusters": clusters,
"services": services,
"error_count": errors,
"duration_ms": duration_ms,
}
# ============================================================
# APM RED Metrics API (Phase 2)
# ============================================================
# Reads from the AggregatingMergeTree RED MVs introduced by
# `clickhouse_005_add_apm_red_mvs.sql`. All four endpoints share the
# same scoping rules: analysis_id is matched as both the exact ID and
# the multi-cluster sub-analysis prefix `<aid>-*`. cluster_id is an
# optional narrowing filter.
#
# Latency percentiles (p50/p95/p99) are computed via
# `quantileTDigestMerge`, which is mathematically sound across any
# number of 5-minute states (TDigests merge associatively). Rate and
# error counts use `sumMerge` for the same reason.
#
# Protocol handling: by default HTTP and gRPC are unioned, since most
# services have a mix; DNS is excluded from the default view because
# its latency characteristics differ in scale (DNS p99 in the
# sub-millisecond range vs. HTTP p99 in the hundreds-of-ms range)
# and would skew tail-latency aggregates. DNS gets its own future
# endpoint when needed.
# ============================================================
def _apm_scope(self, analysis_id: str, cluster_id: Optional[str] = None) -> str:
"""Standard scoping clause used by every APM query. analysis_id is
always required; cluster_id narrows further. Multi-cluster sub-IDs
(`44-15`) are matched via the `<aid>-*` LIKE pattern.
"""
aid = self._escape_ch(str(analysis_id))
clauses = [f"(analysis_id = '{aid}' OR analysis_id LIKE '{aid}-%')"]
if cluster_id is not None and str(cluster_id).strip() not in ("", "0"):
clauses.append(f"cluster_id = '{self._escape_ch(str(cluster_id))}'")
return " AND ".join(clauses)
def get_apm_services(
self,
analysis_id: str,
cluster_id: Optional[str] = None,
namespace: Optional[str] = None,
sort_by: str = "rate",
limit: int = 100,
offset: int = 0,
# Plan v3 Akış B m.4 — free-form search across dst_workload and
# dst_namespace so the Trace Explorer "Services" tab stays in sync
# with the page-level search box. `_build_search_condition`
# already escapes single quotes & backslashes.
q: Optional[str] = None,
) -> Dict[str, Any]:
"""Service-level RED tablosu for APM Services List page.
Combines HTTP and gRPC RED MVs into a single per-(cluster,
dst_workload, dst_namespace) row. p50/p95/p99 are weighted by
protocol via TDigest merge; rate and errors are simple sums.
"""
# NOTE: `avg` was previously in this whitelist but the SELECT clause
# below does not project an `avg` column (only rate/errors/p50/p95/p99
# are computed from the AggregatingMergeTree state). ORDER BY avg
# would therefore raise "Unknown identifier" -> HTTP 500. Removing
# `avg` makes the API contract match what the SQL actually supports,
# so an `avg` value coming through (e.g. URL hand-edit) silently
# falls back to `rate` instead of crashing. Frontend dropdown does
# not expose `avg`; backend Pydantic regex was also tightened.
if sort_by not in {"rate", "errors", "p50", "p95", "p99"}:
sort_by = "rate"
scope = self._apm_scope(analysis_id, cluster_id)
ns_filter = ""
if namespace:
ns_filter = f" AND dst_namespace = '{self._escape_ch(namespace)}'"
# `q` is applied at the inner UNION level (per-table scope) and at
# the outer aggregated level (post-union) — the inner scope keeps
# the row count low; the outer scope makes the filter visible to
# operators inspecting the SQL plan.
q_scope = ""
if q:
cond = self._build_search_condition(q, ["dst_workload", "dst_namespace"])
if cond:
q_scope = f" AND {cond}"
query = f"""
WITH unioned AS (
SELECT cluster_id, dst_workload, dst_namespace,
request_count_state, error_count_state, lat_quantile_state
FROM flowfish.l7_http_red_svc_5min
WHERE {scope}{ns_filter}{q_scope}
UNION ALL
SELECT cluster_id, dst_workload, dst_namespace,
request_count_state, error_count_state, lat_quantile_state
FROM flowfish.l7_grpc_red_svc_5min
WHERE {scope}{ns_filter}{q_scope}
)
SELECT cluster_id, dst_workload, dst_namespace,
sumMerge(request_count_state) AS rate,
sumMerge(error_count_state) AS errors,
quantileTDigestMerge(0.50)(lat_quantile_state) AS p50,
quantileTDigestMerge(0.95)(lat_quantile_state) AS p95,
quantileTDigestMerge(0.99)(lat_quantile_state) AS p99
FROM unioned
WHERE dst_workload != ''
GROUP BY cluster_id, dst_workload, dst_namespace
ORDER BY {sort_by} DESC
LIMIT {int(limit)} OFFSET {int(offset)}
"""
rows = self.client.execute(query)
# Total count for pagination — separate query because the LIMIT
# above would otherwise truncate the count.
total_query = f"""
SELECT count() FROM (
SELECT cluster_id, dst_workload, dst_namespace
FROM (
SELECT cluster_id, dst_workload, dst_namespace
FROM flowfish.l7_http_red_svc_5min
WHERE {scope}{ns_filter}{q_scope}
UNION ALL
SELECT cluster_id, dst_workload, dst_namespace
FROM flowfish.l7_grpc_red_svc_5min
WHERE {scope}{ns_filter}{q_scope}
)
WHERE dst_workload != ''
GROUP BY cluster_id, dst_workload, dst_namespace
)
"""
total_rows = self.client.execute(total_query)
total = int(total_rows[0][0]) if total_rows else 0
services = []
for row in rows:
cluster_id_v, dst_workload, dst_namespace, rate, errors, p50, p95, p99 = row
rate_v = int(rate or 0)
errors_v = int(errors or 0)
error_rate = (errors_v / rate_v) if rate_v > 0 else 0.0
services.append({
"cluster_id": str(cluster_id_v or ""),
"dst_workload": str(dst_workload or ""),
"dst_namespace": str(dst_namespace or ""),
"workload_key": f"{dst_namespace}/{dst_workload}",
"request_count": rate_v,
"error_count": errors_v,
"error_rate": round(error_rate, 4),
"latency_p50_ms": round(float(p50 or 0.0), 4),
"latency_p95_ms": round(float(p95 or 0.0), 4),
"latency_p99_ms": round(float(p99 or 0.0), 4),
})
return {
"services": services,
"total": total,
"limit": int(limit),
"offset": int(offset),
"sort_by": sort_by,
}
def get_apm_operations(
self,
analysis_id: str,
workload_key: str,
cluster_id: Optional[str] = None,
limit: int = 50,
offset: int = 0,
# Plan v3 Akış B m.4 — free-form search across HTTP path/method
# and gRPC method/service columns. Wired into the Trace Explorer
# global search → "Operations" tab path.
q: Optional[str] = None,
) -> Dict[str, Any]:
"""Per-operation RED tablosu (HTTP method+path / gRPC service+method).
`workload_key` is `{dst_namespace}/{dst_workload}`; we split it back
into namespace and workload to filter the ops MV.
"""
if "/" not in workload_key:
return {"operations": [], "total": 0, "limit": int(limit), "offset": int(offset)}
ns, wl = workload_key.split("/", 1)
ns_esc = self._escape_ch(ns)
wl_esc = self._escape_ch(wl)
scope = self._apm_scope(analysis_id, cluster_id)
http_q_scope = ""
grpc_q_scope = ""
if q:
http_cond = self._build_search_condition(q, ["http_method", "http_path_normalized"])
grpc_cond = self._build_search_condition(q, ["grpc_method", "grpc_service"])
if http_cond:
http_q_scope = f" AND {http_cond}"
if grpc_cond:
grpc_q_scope = f" AND {grpc_cond}"
# gRPC mapping note: Beyla emits the full RPC path (e.g.
# `/grpc.health.v1.Health/Check`) in `grpc_method` and frequently
# leaves `grpc_service` empty. Using `grpc_service` for `op_path`
# therefore produced blank `operation` rows in the UI. We feed
# `grpc_method` into `op_path` (the unique-key column for the
# group-by) and surface `grpc_service` in `op_method` as auxiliary
# context. Combined with the Python-side fallback below, this
# guarantees a non-empty `operation` for every gRPC entry.
query = f"""
WITH unioned AS (
SELECT 'HTTP' AS protocol,
http_method AS op_method,
http_path_normalized AS op_path,
request_count_state, error_count_state, lat_quantile_state
FROM flowfish.l7_http_red_ops_5min
WHERE {scope}
AND dst_namespace = '{ns_esc}'
AND dst_workload = '{wl_esc}'{http_q_scope}
UNION ALL
SELECT 'GRPC' AS protocol,
grpc_service AS op_method,
grpc_method AS op_path,
request_count_state, error_count_state, lat_quantile_state
FROM flowfish.l7_grpc_red_ops_5min
WHERE {scope}
AND dst_namespace = '{ns_esc}'
AND dst_workload = '{wl_esc}'{grpc_q_scope}
)
SELECT protocol, op_method, op_path,
sumMerge(request_count_state) AS rate,
sumMerge(error_count_state) AS errors,
quantileTDigestMerge(0.50)(lat_quantile_state) AS p50,
quantileTDigestMerge(0.95)(lat_quantile_state) AS p95,
quantileTDigestMerge(0.99)(lat_quantile_state) AS p99
FROM unioned
GROUP BY protocol, op_method, op_path
ORDER BY rate DESC
LIMIT {int(limit)} OFFSET {int(offset)}
"""
rows = self.client.execute(query)
operations = []
for row in rows:
protocol, method, path_v, rate, errors, p50, p95, p99 = row
rate_v = int(rate or 0)
errors_v = int(errors or 0)
method_str = str(method or "")
# Defensive fallback: if both fields exist but `op_path` is
# empty (Beyla pre-3.x sometimes emits service-only or
# method-only spans), promote `op_method` to `operation` so
# the UI never renders a blank row that the operator can't
# correlate back to traffic.
operation_str = str(path_v or "") or method_str
if not operation_str:
operation_str = "(unknown)"
operations.append({
"protocol": str(protocol),
"method": method_str,
"operation": operation_str,
"request_count": rate_v,
"error_count": errors_v,
"error_rate": round((errors_v / rate_v) if rate_v > 0 else 0.0, 4),
"latency_p50_ms": round(float(p50 or 0.0), 4),
"latency_p95_ms": round(float(p95 or 0.0), 4),
"latency_p99_ms": round(float(p99 or 0.0), 4),
})
return {
"operations": operations,
"workload_key": workload_key,
"limit": int(limit),
"offset": int(offset),
}
def get_apm_service_stats(
self,
analysis_id: str,
workload_key: str,
cluster_id: Optional[str] = None,
) -> Dict[str, Any]:
"""RED metrics over time (5-minute buckets) for a single service.
Returns a list of `{timestamp, rate, errors, p50, p95, p99}` rows
suitable for a recharts LineChart. Combines HTTP+gRPC like
`get_apm_services`.
"""
if "/" not in workload_key:
return {"buckets": [], "workload_key": workload_key}
ns, wl = workload_key.split("/", 1)
ns_esc = self._escape_ch(ns)
wl_esc = self._escape_ch(wl)
scope = self._apm_scope(analysis_id, cluster_id)
query = f"""
WITH unioned AS (
SELECT timestamp_5min, request_count_state, error_count_state, lat_quantile_state
FROM flowfish.l7_http_red_svc_5min
WHERE {scope}
AND dst_namespace = '{ns_esc}'
AND dst_workload = '{wl_esc}'
UNION ALL
SELECT timestamp_5min, request_count_state, error_count_state, lat_quantile_state
FROM flowfish.l7_grpc_red_svc_5min
WHERE {scope}
AND dst_namespace = '{ns_esc}'
AND dst_workload = '{wl_esc}'
)
SELECT timestamp_5min,
sumMerge(request_count_state) AS rate,
sumMerge(error_count_state) AS errors,
quantileTDigestMerge(0.50)(lat_quantile_state) AS p50,
quantileTDigestMerge(0.95)(lat_quantile_state) AS p95,
quantileTDigestMerge(0.99)(lat_quantile_state) AS p99
FROM unioned
GROUP BY timestamp_5min
ORDER BY timestamp_5min ASC
"""
rows = self.client.execute(query)
buckets = []
for row in rows:
ts, rate, errors, p50, p95, p99 = row
rate_v = int(rate or 0)
errors_v = int(errors or 0)
buckets.append({
"timestamp": ts.isoformat() if hasattr(ts, "isoformat") else str(ts),
"request_count": rate_v,
"error_count": errors_v,
"error_rate": round((errors_v / rate_v) if rate_v > 0 else 0.0, 4),
"latency_p50_ms": round(float(p50 or 0.0), 4),
"latency_p95_ms": round(float(p95 or 0.0), 4),
"latency_p99_ms": round(float(p99 or 0.0), 4),
})
return {"buckets": buckets, "workload_key": workload_key, "interval_seconds": 300}
def get_apm_service_dependencies(
self,
analysis_id: str,
workload_key: str,
cluster_id: Optional[str] = None,
direction: str = "both",
# Plan v3 Akış B m.4 — search across the *peer* workload/namespace
# (i.e. for upstream we filter `src_*`, for downstream `dst_*`).
# The peer side is what the operator sees in the table, so it's
# what they'd type into the global search box.
q: Optional[str] = None,
) -> Dict[str, Any]:
"""Dependency neighbours of a service (upstream/downstream).
Reads from the SVC RED MVs and groups by the *other* side of the
edge: when `direction=upstream` we look at rows where the target
is `workload_key` and group by source; vice versa for `downstream`.
`both` returns two lists.
"""
if "/" not in workload_key:
return {"upstream": [], "downstream": [], "workload_key": workload_key}
ns, wl = workload_key.split("/", 1)
ns_esc = self._escape_ch(ns)
wl_esc = self._escape_ch(wl)
scope = self._apm_scope(analysis_id, cluster_id)
up_q_scope = ""
dn_q_scope = ""
if q:
up_cond = self._build_search_condition(q, ["src_workload", "src_namespace"])
dn_cond = self._build_search_condition(q, ["dst_workload", "dst_namespace"])
if up_cond:
up_q_scope = f" AND {up_cond}"
if dn_cond:
dn_q_scope = f" AND {dn_cond}"
upstream: List[Dict[str, Any]] = []
downstream: List[Dict[str, Any]] = []
# Audit fix (multi-cluster correctness): the previous query
# grouped only by (workload, namespace) so a peer service with
# the same name in two different clusters collapsed into a
# single row, hiding which cluster contributed which traffic.
# We add `cluster_id` to the SELECT and GROUP BY so the
# DependencyCard can render a ClusterBadge alongside each
# neighbour and downstream consumers can disambiguate edges
# that span clusters (e.g. service mesh / shared LB topologies).
if direction in ("both", "upstream"):
up_query = f"""
WITH unioned AS (
SELECT cluster_id, src_workload, src_namespace, request_count_state,
error_count_state, lat_quantile_state
FROM flowfish.l7_http_red_svc_5min
WHERE {scope}
AND dst_namespace = '{ns_esc}'
AND dst_workload = '{wl_esc}'{up_q_scope}
UNION ALL
SELECT cluster_id, src_workload, src_namespace, request_count_state,
error_count_state, lat_quantile_state
FROM flowfish.l7_grpc_red_svc_5min
WHERE {scope}
AND dst_namespace = '{ns_esc}'
AND dst_workload = '{wl_esc}'{up_q_scope}
)
SELECT cluster_id, src_workload, src_namespace,
sumMerge(request_count_state) AS rate,
sumMerge(error_count_state) AS errors,
quantileTDigestMerge(0.95)(lat_quantile_state) AS p95
FROM unioned
WHERE src_workload != ''
GROUP BY cluster_id, src_workload, src_namespace
ORDER BY rate DESC LIMIT 50
"""
for row in self.client.execute(up_query):
cluster_id_v, src_wl, src_ns, rate, errors, p95 = row
rate_v = int(rate or 0)
errors_v = int(errors or 0)
upstream.append({
"cluster_id": str(cluster_id_v or ""),
"workload": str(src_wl or ""),
"namespace": str(src_ns or ""),
"workload_key": f"{src_ns}/{src_wl}",
"request_count": rate_v,
"error_count": errors_v,
"error_rate": round((errors_v / rate_v) if rate_v > 0 else 0.0, 4),
"latency_p95_ms": round(float(p95 or 0.0), 4),
})
if direction in ("both", "downstream"):
dn_query = f"""
WITH unioned AS (
SELECT cluster_id, dst_workload, dst_namespace, request_count_state,
error_count_state, lat_quantile_state
FROM flowfish.l7_http_red_svc_5min
WHERE {scope}
AND src_namespace = '{ns_esc}'
AND src_workload = '{wl_esc}'{dn_q_scope}
UNION ALL
SELECT cluster_id, dst_workload, dst_namespace, request_count_state,
error_count_state, lat_quantile_state
FROM flowfish.l7_grpc_red_svc_5min
WHERE {scope}
AND src_namespace = '{ns_esc}'
AND src_workload = '{wl_esc}'{dn_q_scope}
)
SELECT cluster_id, dst_workload, dst_namespace,
sumMerge(request_count_state) AS rate,
sumMerge(error_count_state) AS errors,
quantileTDigestMerge(0.95)(lat_quantile_state) AS p95
FROM unioned
WHERE dst_workload != ''
GROUP BY cluster_id, dst_workload, dst_namespace
ORDER BY rate DESC LIMIT 50
"""
for row in self.client.execute(dn_query):
cluster_id_v, dst_wl, dst_ns, rate, errors, p95 = row
rate_v = int(rate or 0)
errors_v = int(errors or 0)
downstream.append({
"cluster_id": str(cluster_id_v or ""),
"workload": str(dst_wl or ""),
"namespace": str(dst_ns or ""),
"workload_key": f"{dst_ns}/{dst_wl}",
"request_count": rate_v,
"error_count": errors_v,
"error_rate": round((errors_v / rate_v) if rate_v > 0 else 0.0, 4),
"latency_p95_ms": round(float(p95 or 0.0), 4),
})
return {
"workload_key": workload_key,
"upstream": upstream,
"downstream": downstream,
"direction": direction,
}
# ============================================================
# Plan v3 Akış B m.2 — Trace Explorer "Operations" /
# "Dependencies" tabs need a *cross-workload* aggregate (the
# operator hasn't picked a single service yet). The per-workload
# endpoints `get_apm_operations` / `get_apm_service_dependencies`
# require a workload_key, so we add two thin sibling methods that
# share the same MV scoping rules but group by the global axes.
#
# All three filters from the parent page are honoured:
# - analysis_id (always)
# - cluster_id (optional narrowing)
# - q (free-form search; scoped to the per-table columns the
# operator might reasonably type)
#
# Limits intentionally cap top-N at 50; the goal is to give the
# operator a quick "at a glance" picture, not pagination. If they
# need the full list they can pivot to the dedicated APM page.
# ============================================================
def get_apm_operations_global(
self,
analysis_id: str,
cluster_id: Optional[str] = None,
q: Optional[str] = None,
limit: int = 50,
) -> Dict[str, Any]:
"""Top operations across every workload in the analysis scope.
Used by the Trace Explorer "Operations" tab so the operator can
see what kinds of HTTP/gRPC calls the analysis collected without
first picking a service. Combines HTTP + gRPC ops MVs and
applies the same `q` semantics as `get_apm_operations`.
"""
scope = self._apm_scope(analysis_id, cluster_id)
http_q_scope = ""
grpc_q_scope = ""
if q:
http_cond = self._build_search_condition(
q, ["http_method", "http_path_normalized", "dst_workload", "dst_namespace"]
)
grpc_cond = self._build_search_condition(
q, ["grpc_method", "grpc_service", "dst_workload", "dst_namespace"]
)
if http_cond:
http_q_scope = f" AND {http_cond}"
if grpc_cond:
grpc_q_scope = f" AND {grpc_cond}"
# gRPC mirroring of `get_apm_operations`: feed `grpc_method` into
# `op_path` (the unique-key column for the group-by) and surface
# `grpc_service` in `op_method` so the UI can render either.
query = f"""
WITH unioned AS (
SELECT 'HTTP' AS protocol,
cluster_id, dst_workload, dst_namespace,
http_method AS op_method,
http_path_normalized AS op_path,
request_count_state, error_count_state, lat_quantile_state
FROM flowfish.l7_http_red_ops_5min
WHERE {scope}{http_q_scope}
UNION ALL
SELECT 'GRPC' AS protocol,
cluster_id, dst_workload, dst_namespace,
grpc_service AS op_method,
grpc_method AS op_path,
request_count_state, error_count_state, lat_quantile_state
FROM flowfish.l7_grpc_red_ops_5min
WHERE {scope}{grpc_q_scope}
)
SELECT protocol, cluster_id, dst_workload, dst_namespace,
op_method, op_path,
sumMerge(request_count_state) AS rate,
sumMerge(error_count_state) AS errors,
quantileTDigestMerge(0.50)(lat_quantile_state) AS p50,
quantileTDigestMerge(0.95)(lat_quantile_state) AS p95,
quantileTDigestMerge(0.99)(lat_quantile_state) AS p99
FROM unioned
GROUP BY protocol, cluster_id, dst_workload, dst_namespace, op_method, op_path
ORDER BY rate DESC
LIMIT {int(limit)}
"""
rows = self.client.execute(query)
operations = []
for row in rows:
(protocol, cluster_id_v, dst_wl, dst_ns,
method, path_v, rate, errors, p50, p95, p99) = row
rate_v = int(rate or 0)
errors_v = int(errors or 0)
method_str = str(method or "")
operation_str = str(path_v or "") or method_str
if not operation_str:
operation_str = "(unknown)"
operations.append({
"protocol": str(protocol),
"cluster_id": str(cluster_id_v or ""),
"workload": str(dst_wl or ""),
"namespace": str(dst_ns or ""),
"workload_key": f"{dst_ns}/{dst_wl}",
"method": method_str,
"operation": operation_str,
"request_count": rate_v,
"error_count": errors_v,
"error_rate": round((errors_v / rate_v) if rate_v > 0 else 0.0, 4),
"latency_p50_ms": round(float(p50 or 0.0), 4),
"latency_p95_ms": round(float(p95 or 0.0), 4),
"latency_p99_ms": round(float(p99 or 0.0), 4),
})
return {"operations": operations, "limit": int(limit)}
def get_apm_dependencies_global(
self,
analysis_id: str,
cluster_id: Optional[str] = None,
q: Optional[str] = None,
limit: int = 50,
) -> Dict[str, Any]:
"""All service-to-service edges across the analysis scope.
Powers the Trace Explorer "Dependencies" tab. Each row is a
directed edge `(src → dst)` with rate / errors / p95 latency.
Searches across either side of the edge so a single token like
"checkout" surfaces both upstream and downstream uses.
"""
scope = self._apm_scope(analysis_id, cluster_id)
q_scope = ""
if q:
cond = self._build_search_condition(
q,
[
"src_workload",
"src_namespace",
"dst_workload",
"dst_namespace",
],
)
if cond:
q_scope = f" AND {cond}"
query = f"""
WITH unioned AS (
SELECT cluster_id,
src_namespace, src_workload,
dst_namespace, dst_workload,
request_count_state, error_count_state, lat_quantile_state
FROM flowfish.l7_http_red_svc_5min
WHERE {scope}{q_scope}
UNION ALL
SELECT cluster_id,
src_namespace, src_workload,
dst_namespace, dst_workload,
request_count_state, error_count_state, lat_quantile_state
FROM flowfish.l7_grpc_red_svc_5min
WHERE {scope}{q_scope}
)
SELECT cluster_id, src_namespace, src_workload, dst_namespace, dst_workload,
sumMerge(request_count_state) AS rate,
sumMerge(error_count_state) AS errors,
quantileTDigestMerge(0.95)(lat_quantile_state) AS p95
FROM unioned
WHERE src_workload != '' AND dst_workload != ''
GROUP BY cluster_id, src_namespace, src_workload, dst_namespace, dst_workload
ORDER BY rate DESC
LIMIT {int(limit)}
"""
rows = self.client.execute(query)
edges = []
for row in rows:
(cluster_id_v, src_ns, src_wl, dst_ns, dst_wl,
rate, errors, p95) = row
rate_v = int(rate or 0)
errors_v = int(errors or 0)
edges.append({
"cluster_id": str(cluster_id_v or ""),
"src_workload": str(src_wl or ""),
"src_namespace": str(src_ns or ""),
"src_workload_key": f"{src_ns}/{src_wl}",
"dst_workload": str(dst_wl or ""),
"dst_namespace": str(dst_ns or ""),
"dst_workload_key": f"{dst_ns}/{dst_wl}",
"request_count": rate_v,
"error_count": errors_v,
"error_rate": round((errors_v / rate_v) if rate_v > 0 else 0.0, 4),
"latency_p95_ms": round(float(p95 or 0.0), 4),
})
return {"edges": edges, "limit": int(limit)}
# ============================================================
# Phase 3B: Related Traces
# ============================================================
# Given an "anchor" trace_id, find other traces that share useful
# context. Two correlation strategies are exposed:
# - same_edge: same (src_workload, dst_workload) pair. Answers
# "what other calls did service A make to service B around the
# same time?".
# - same_pod: same dst_pod (or src_pod when the anchor span is
# client-side). Answers "what else hit this exact replica?".
#
# 5-tuple correlation is intentionally deferred to Phase 4 because
# in Beyla passive mode src_port is ephemeral and rarely matches
# across spans (Plan v1.5 Section 16.F).
#
# The anchor trace's metadata is fetched by reading the *earliest*
# span of the trace. For multi-cluster traces this is fine — we
# only need (src_workload, dst_workload, src_pod, dst_pod, ts) to
# build the related queries.
# ============================================================
def get_related_traces(
self,
trace_id: str,
analysis_id: Optional[str] = None,
rel_type: str = "both",
limit: int = 50,
time_window_minutes: int = 60,
) -> Dict[str, Any]:
"""Find traces related to `trace_id` by edge or pod proximity.
Args:
trace_id: Anchor trace's W3C 16-byte hex ID.
analysis_id: Restrict the search to this analysis (and its
multi-cluster sub-analyses via `<aid>-*`). When omitted
the search spans every analysis — operator deep-link case.
rel_type: `same_edge`, `same_pod`, or `both`.
limit: Max results *per group* (default 50).
time_window_minutes: Look-back window from the anchor's
start time (default 60). Caps the search to bounded
partitions for ClickHouse efficiency.
Returns:
{"anchor": {...}, "same_edge": [...], "same_pod": [...]}.
Each related trace has the same shape as `get_recent_traces`:
trace_id, start_time, end_time, span_count, error_count,
max_latency_ms, duration_ms, clusters[].
Anchor metadata is included so the UI can show "what we
pivoted from" without a second round trip.
"""
if rel_type not in {"same_edge", "same_pod", "both"}:
rel_type = "both"
tid = _validate_trace_id(trace_id)
if analysis_id is not None and str(analysis_id):
aid = self._escape_ch(str(analysis_id))
scope = f"AND (analysis_id = '{aid}' OR analysis_id LIKE '{aid}-%')"
else:
scope = ""
# 1. Anchor metadata: pull the earliest span to get the edge
# signature. We use UNION ALL across HTTP+gRPC because the
# anchor could be either protocol; DNS is excluded because a
# DNS-only "trace" has no meaningful peer to pivot on.
# Phase 4: also match `virtual_trace_id` so operators can pivot
# FROM a virtual trace. Falls back to the legacy trace_id-only
# query when the column is missing (Phase 4 migration not yet
# applied) — same defensive pattern used in get_trace_spans.
anchor_match_with_vt = (
f"(trace_id = '{tid}' OR (virtual_trace_id = '{tid}' AND virtual_trace_id != ''))"
)
anchor_match_legacy = f"trace_id = '{tid}'"
anchor_query_vt = f"""
SELECT timestamp, src_namespace, src_workload, src_pod,
dst_namespace, dst_workload, dst_pod, cluster_id, 'HTTP' AS proto
FROM l7_http_flows
WHERE {anchor_match_with_vt} {scope}
UNION ALL
SELECT timestamp, src_namespace, src_workload, src_pod,
dst_namespace, dst_workload, dst_pod, cluster_id, 'GRPC' AS proto
FROM l7_grpc_flows
WHERE {anchor_match_with_vt} {scope}
ORDER BY timestamp ASC
LIMIT 1
"""
anchor_query_legacy = f"""
SELECT timestamp, src_namespace, src_workload, src_pod,
dst_namespace, dst_workload, dst_pod, cluster_id, 'HTTP' AS proto
FROM l7_http_flows
WHERE {anchor_match_legacy} {scope}
UNION ALL
SELECT timestamp, src_namespace, src_workload, src_pod,
dst_namespace, dst_workload, dst_pod, cluster_id, 'GRPC' AS proto
FROM l7_grpc_flows
WHERE {anchor_match_legacy} {scope}
ORDER BY timestamp ASC
LIMIT 1
"""
try:
anchor_rows = self.client.execute(anchor_query_vt)
virtual_trace_supported = True
except Exception as e:
err_str = str(e)
if (
"Unknown column" in err_str
or "doesn't have column" in err_str
or "No such column" in err_str
) and "virtual_trace_id" in err_str:
logger.warning(
"virtual_trace_id column missing in anchor query — falling back to legacy"
)
anchor_rows = self.client.execute(anchor_query_legacy)
virtual_trace_supported = False
else:
raise
if not anchor_rows:
return {
"anchor": None,
"same_edge": [],
"same_pod": [],
"rel_type": rel_type,
}
a_ts, a_src_ns, a_src_wl, a_src_pod, a_dst_ns, a_dst_wl, a_dst_pod, a_cluster, _ = anchor_rows[0]
anchor_meta = {
"trace_id": tid,
"timestamp": a_ts.isoformat() if hasattr(a_ts, "isoformat") else str(a_ts),
"src_namespace": str(a_src_ns or ""),
"src_workload": str(a_src_wl or ""),
"src_pod": str(a_src_pod or ""),
"dst_namespace": str(a_dst_ns or ""),
"dst_workload": str(a_dst_wl or ""),
"dst_pod": str(a_dst_pod or ""),
"cluster_id": str(a_cluster or ""),
}
# Time bounds applied to all related queries; converts the anchor
# timestamp + ±window into ClickHouse DateTime literals. We bound
# both ends to make the query bounded on partition pruning.
try:
window_minutes = max(5, min(int(time_window_minutes), 24 * 60))
except (TypeError, ValueError):
window_minutes = 60
ts_clause = (
f"timestamp >= toDateTime64('{a_ts.isoformat()}', 3) - INTERVAL {window_minutes} MINUTE "
f"AND timestamp <= toDateTime64('{a_ts.isoformat()}', 3) + INTERVAL {window_minutes} MINUTE"
)
same_edge: List[Dict[str, Any]] = []
same_pod: List[Dict[str, Any]] = []
if rel_type in {"same_edge", "both"} and a_src_wl and a_dst_wl:
src_wl_esc = self._escape_ch(a_src_wl)
dst_wl_esc = self._escape_ch(a_dst_wl)
edge_filter = (
f"src_workload = '{src_wl_esc}' AND dst_workload = '{dst_wl_esc}'"
)
same_edge = self._related_traces_query(
tid, scope, edge_filter, ts_clause, int(limit), virtual_trace_supported
)
if rel_type in {"same_pod", "both"} and a_dst_pod:
# Use dst_pod for server-side pivot (most common case: which
# other traces hit this same backend pod?). When the anchor
# span has no dst_pod (e.g. external destinations) we skip.
dst_pod_esc = self._escape_ch(a_dst_pod)
pod_filter = f"dst_pod = '{dst_pod_esc}'"
same_pod = self._related_traces_query(
tid, scope, pod_filter, ts_clause, int(limit), virtual_trace_supported
)
return {
"anchor": anchor_meta,
"same_edge": same_edge,
"same_pod": same_pod,
"rel_type": rel_type,
"time_window_minutes": window_minutes,
}
def _related_traces_query(
self,
anchor_tid: str,
scope: str,
extra_filter: str,
ts_clause: str,
limit: int,
virtual_trace_supported: bool = True,
) -> List[Dict[str, Any]]:
"""Run the trace-grouping aggregation used by both same_edge and
same_pod. Mirrors the structure of get_recent_traces' inner query
but with an `extra_filter` (edge or pod) and the anchor trace_id
excluded from the result. The bloom filter index on src_pod /
dst_pod (clickhouse_006) accelerates the pod variant.
When `virtual_trace_supported` is True (Phase 4 migration applied)
the query also surfaces virtual traces by selecting an
`effective_id = if(trace_id != '', trace_id, virtual_trace_id)`
and grouping by it. The returned `trace_id` column always carries
the effective ID, so callers don't need to know the underlying
column. When False, the legacy W3C-only query runs.
"""
if virtual_trace_supported:
# `if(...)` selects trace_id when present, virtual_trace_id when not.
# The outer GROUP BY collapses each virtual trace into one row,
# exactly like a real W3C trace.
#
# Alias is `effective_id` (NOT `trace_id`) — aliasing to the same
# name as a base column makes ClickHouse resolve `trace_id` inside
# GROUP BY back to the alias, which expands recursively and
# produces a different expression than the SELECT one. The
# planner then complains "Column `trace_id` is not under
# aggregate function and not in GROUP BY". The outer query
# renames `effective_id` back to `trace_id` so the response
# shape stays identical.
inner = f"""
SELECT if(trace_id != '', trace_id, virtual_trace_id) AS effective_id,
min(timestamp) AS ts, max(timestamp) AS ts2,
count() AS cnt, countIf(http_status_code >= 400) AS errs,
max(latency_ms) AS max_lat,
groupUniqArray(cluster_id) AS clusters_arr
FROM l7_http_flows
WHERE (trace_id != '' OR virtual_trace_id != '')
AND if(trace_id != '', trace_id, virtual_trace_id) != '{anchor_tid}'
{scope}
AND {ts_clause}
AND {extra_filter}
GROUP BY effective_id
UNION ALL
SELECT if(trace_id != '', trace_id, virtual_trace_id) AS effective_id,
min(timestamp), max(timestamp),
count(), countIf(grpc_status_code != 0),
max(latency_ms),
groupUniqArray(cluster_id)
FROM l7_grpc_flows
WHERE (trace_id != '' OR virtual_trace_id != '')
AND if(trace_id != '', trace_id, virtual_trace_id) != '{anchor_tid}'
{scope}
AND {ts_clause}
AND {extra_filter}
GROUP BY effective_id
"""
outer_id_alias = "effective_id AS trace_id"
outer_group_col = "effective_id"
else:
inner = f"""
SELECT trace_id, min(timestamp) AS ts, max(timestamp) AS ts2,
count() AS cnt, countIf(http_status_code >= 400) AS errs,
max(latency_ms) AS max_lat,
groupUniqArray(cluster_id) AS clusters_arr
FROM l7_http_flows
WHERE trace_id != '' AND trace_id != '{anchor_tid}' {scope}
AND {ts_clause}
AND {extra_filter}
GROUP BY trace_id
UNION ALL
SELECT trace_id, min(timestamp), max(timestamp),
count(), countIf(grpc_status_code != 0),
max(latency_ms),
groupUniqArray(cluster_id)
FROM l7_grpc_flows
WHERE trace_id != '' AND trace_id != '{anchor_tid}' {scope}
AND {ts_clause}
AND {extra_filter}
GROUP BY trace_id
"""
outer_id_alias = "trace_id"
outer_group_col = "trace_id"
query = f"""
SELECT {outer_id_alias}, min(ts) AS start_time, max(ts2) AS end_time,
sum(cnt) AS span_count, sum(errs) AS error_count,
max(max_lat) AS max_latency_ms,
groupUniqArrayArray(clusters_arr) AS clusters
FROM ({inner})
GROUP BY {outer_group_col}
ORDER BY start_time DESC
LIMIT {int(limit)}
"""
try:
rows = self.client.execute(query)
except Exception as e:
err_str = str(e)
if (
virtual_trace_supported
and (
"Unknown column" in err_str
or "doesn't have column" in err_str
or "No such column" in err_str
)
and "virtual_trace_id" in err_str
):
# Schema drift between anchor and table — anchor query
# succeeded with virtual_trace_id but inner failed (e.g.
# one table migrated, other not). Re-run with legacy.
logger.warning(
"virtual_trace_id column missing in related-traces inner query — falling back to legacy"
)
return self._related_traces_query(
anchor_tid, scope, extra_filter, ts_clause, limit, virtual_trace_supported=False
)
raise
out: List[Dict[str, Any]] = []
for row in rows:
tid, st, et, cnt, errs, lat, clusters = row
duration_ms = 0.0
if st and et:
try:
duration_ms = (et - st).total_seconds() * 1000.0
except (TypeError, AttributeError):
duration_ms = 0.0
out.append({
"trace_id": str(tid),
"start_time": st.isoformat() if hasattr(st, "isoformat") else st,
"end_time": et.isoformat() if hasattr(et, "isoformat") else et,
"span_count": int(cnt or 0),
"error_count": int(errs or 0),
"max_latency_ms": float(lat or 0.0),
"duration_ms": duration_ms,
"clusters": [str(c) for c in (clusters or []) if c],
})
return out
def close(self):
"""Close database connection"""
if self.client:
self.client.disconnect()
logger.info("Timeseries database connection closed")