4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
297 lines
11 KiB
Python
297 lines
11 KiB
Python
"""Coralogix DataPrime client for RCA log queries."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from datetime import UTC, datetime, timedelta
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from integrations.config_models import CoralogixIntegrationConfig
|
|
from integrations.probes import ProbeResult
|
|
from platform.observability.errors.service import capture_service_error
|
|
from platform.observability.streaming import StreamingParseStats
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_DEFAULT_TIMEOUT_SECONDS = 30.0
|
|
|
|
|
|
def _escape_query_value(value: str) -> str:
|
|
return value.replace("\\", "\\\\").replace("'", "\\'")
|
|
|
|
|
|
def _ensure_limit_clause(query: str, limit: int) -> str:
|
|
normalized = query.strip()
|
|
if " limit " in normalized.lower() or normalized.lower().endswith("| limit"):
|
|
return normalized
|
|
return f"{normalized} | limit {max(int(limit), 1)}"
|
|
|
|
|
|
def build_coralogix_logs_query(
|
|
*,
|
|
raw_query: str = "",
|
|
application_name: str = "",
|
|
subsystem_name: str = "",
|
|
text_query: str = "",
|
|
trace_id: str = "",
|
|
limit: int = 50,
|
|
) -> str:
|
|
"""Build a Coralogix DataPrime log query with optional RCA-friendly filters."""
|
|
if raw_query.strip():
|
|
return _ensure_limit_clause(raw_query.strip(), limit)
|
|
|
|
filters: list[str] = []
|
|
if application_name:
|
|
filters.append(f"$l.applicationname == '{_escape_query_value(application_name)}'")
|
|
if subsystem_name:
|
|
filters.append(f"$l.subsystemname == '{_escape_query_value(subsystem_name)}'")
|
|
if trace_id:
|
|
filters.append(f"$d.trace_id == '{_escape_query_value(trace_id)}'")
|
|
if text_query:
|
|
filters.append(f"$d.message.contains('{_escape_query_value(text_query)}')")
|
|
|
|
query_parts = ["source logs"]
|
|
query_parts.extend(f"filter {clause}" for clause in filters)
|
|
query_parts.append(f"limit {max(int(limit), 1)}")
|
|
return " | ".join(query_parts)
|
|
|
|
|
|
class CoralogixClient:
|
|
"""Synchronous Coralogix client backed by the DataPrime HTTP API."""
|
|
|
|
def __init__(self, config: CoralogixIntegrationConfig) -> None:
|
|
self.config = config
|
|
|
|
@property
|
|
def is_configured(self) -> bool:
|
|
return bool(self.config.api_key and self.config.base_url)
|
|
|
|
@property
|
|
def query_url(self) -> str:
|
|
return f"{self.config.base_url}/api/v1/dataprime/query"
|
|
|
|
def _request_headers(self) -> dict[str, str]:
|
|
return {
|
|
"Authorization": f"Bearer {self.config.api_key}",
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json",
|
|
}
|
|
|
|
def _parse_ndjson(
|
|
self, response_text: str, stats: StreamingParseStats | None = None
|
|
) -> dict[str, Any]:
|
|
query_ids: list[str] = []
|
|
warnings: list[str] = []
|
|
rows: list[dict[str, Any]] = []
|
|
|
|
for line in response_text.splitlines():
|
|
stripped = line.strip()
|
|
if not stripped:
|
|
continue
|
|
try:
|
|
payload = json.loads(stripped)
|
|
except json.JSONDecodeError as exc:
|
|
if stats is not None:
|
|
stats.record_error(exc)
|
|
continue
|
|
if stats is not None:
|
|
stats.record_parsed()
|
|
|
|
if isinstance(payload, dict):
|
|
query_id = payload.get("queryId")
|
|
if isinstance(query_id, dict):
|
|
maybe_id = str(query_id.get("queryId", "")).strip()
|
|
if maybe_id:
|
|
query_ids.append(maybe_id)
|
|
elif isinstance(query_id, str) and query_id.strip():
|
|
query_ids.append(query_id.strip())
|
|
|
|
warning = payload.get("warning")
|
|
if isinstance(warning, str) and warning.strip():
|
|
warnings.append(warning.strip())
|
|
|
|
direct_results = payload.get("result", {})
|
|
if isinstance(direct_results, dict):
|
|
result_rows = direct_results.get("results", [])
|
|
if isinstance(result_rows, list):
|
|
rows.extend(item for item in result_rows if isinstance(item, dict))
|
|
|
|
background_results = payload.get("response", {})
|
|
if isinstance(background_results, dict):
|
|
nested_results = background_results.get("results", {})
|
|
if isinstance(nested_results, dict):
|
|
result_rows = nested_results.get("results", [])
|
|
if isinstance(result_rows, list):
|
|
rows.extend(item for item in result_rows if isinstance(item, dict))
|
|
|
|
return {
|
|
"query_ids": query_ids,
|
|
"warnings": warnings,
|
|
"rows": rows,
|
|
}
|
|
|
|
@staticmethod
|
|
def _items_to_dict(items: object) -> dict[str, Any]:
|
|
if not isinstance(items, list):
|
|
return {}
|
|
result: dict[str, Any] = {}
|
|
for item in items:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
key = str(item.get("key", "")).strip()
|
|
if not key:
|
|
continue
|
|
result[key] = item.get("value")
|
|
return result
|
|
|
|
@staticmethod
|
|
def _parse_user_data(value: object, stats: StreamingParseStats | None = None) -> dict[str, Any]:
|
|
if isinstance(value, dict):
|
|
return value
|
|
if not isinstance(value, str) or not value.strip():
|
|
return {}
|
|
# Only the string branch is an actual parse attempt, so both
|
|
# record_parsed and record_error live inside it — otherwise the
|
|
# denominator (parsed + skipped) would only count failures and skew
|
|
# the skip ratio.
|
|
try:
|
|
payload = json.loads(value)
|
|
except json.JSONDecodeError as exc:
|
|
if stats is not None:
|
|
stats.record_error(exc)
|
|
return {}
|
|
if stats is not None:
|
|
stats.record_parsed()
|
|
return payload if isinstance(payload, dict) else {}
|
|
|
|
def _normalize_row(
|
|
self, row: dict[str, Any], stats: StreamingParseStats | None = None
|
|
) -> dict[str, Any]:
|
|
metadata = self._items_to_dict(row.get("metadata"))
|
|
labels = self._items_to_dict(row.get("labels"))
|
|
user_data = self._parse_user_data(row.get("userData"), stats=stats)
|
|
log_obj = user_data.get("log_obj", {}) if isinstance(user_data, dict) else {}
|
|
if not isinstance(log_obj, dict):
|
|
log_obj = {}
|
|
|
|
message = str(
|
|
log_obj.get("message") or user_data.get("message") or metadata.get("message") or ""
|
|
).strip()
|
|
timestamp = str(
|
|
log_obj.get("timestamp")
|
|
or user_data.get("timestamp")
|
|
or metadata.get("timestamp")
|
|
or ""
|
|
).strip()
|
|
level = str(
|
|
log_obj.get("level") or user_data.get("level") or metadata.get("severity") or ""
|
|
).strip()
|
|
trace_id = str(
|
|
user_data.get("trace_id") or log_obj.get("trace_id") or metadata.get("trace_id") or ""
|
|
).strip()
|
|
|
|
return {
|
|
"timestamp": timestamp,
|
|
"message": message,
|
|
"level": level,
|
|
"trace_id": trace_id,
|
|
"application_name": str(labels.get("applicationname", "")).strip(),
|
|
"subsystem_name": str(labels.get("subsystemname", "")).strip(),
|
|
"labels": labels,
|
|
"metadata": metadata,
|
|
"user_data": user_data,
|
|
}
|
|
|
|
def query_logs(
|
|
self,
|
|
query: str,
|
|
*,
|
|
time_range_minutes: int = 60,
|
|
limit: int = 50,
|
|
) -> dict[str, Any]:
|
|
"""Run a Coralogix DataPrime direct query and normalize log rows."""
|
|
query_with_limit = _ensure_limit_clause(query, limit)
|
|
now = datetime.now(UTC)
|
|
start = now - timedelta(minutes=max(int(time_range_minutes), 1))
|
|
payload = {
|
|
"query": query_with_limit,
|
|
"metadata": {
|
|
"startDate": start.isoformat(),
|
|
"endDate": now.isoformat(),
|
|
"defaultSource": "logs",
|
|
},
|
|
}
|
|
|
|
try:
|
|
response = httpx.post(
|
|
self.query_url,
|
|
headers=self._request_headers(),
|
|
json=payload,
|
|
timeout=_DEFAULT_TIMEOUT_SECONDS,
|
|
)
|
|
response.raise_for_status()
|
|
except httpx.HTTPStatusError as exc:
|
|
capture_service_error(exc, logger=logger, integration="coralogix", method="query_logs")
|
|
return {
|
|
"success": False,
|
|
"error": f"HTTP {exc.response.status_code}: {exc.response.text[:200]}",
|
|
}
|
|
except Exception as exc:
|
|
capture_service_error(exc, logger=logger, integration="coralogix", method="query_logs")
|
|
return {"success": False, "error": str(exc)}
|
|
|
|
stats = StreamingParseStats()
|
|
parsed = self._parse_ndjson(response.text, stats=stats)
|
|
# Aggregate userData parse drops into the same stats so the threshold
|
|
# fires once per response no matter where the drops happened.
|
|
logs = [self._normalize_row(row, stats=stats) for row in parsed["rows"]]
|
|
stats.report_if_unhealthy(logger=logger, integration="coralogix", source="dataprime/query")
|
|
return {
|
|
"success": True,
|
|
"logs": logs,
|
|
"total": len(logs),
|
|
"query": query_with_limit,
|
|
"query_ids": parsed["query_ids"],
|
|
"warnings": parsed["warnings"],
|
|
}
|
|
|
|
def validate_access(self) -> dict[str, Any]:
|
|
"""Validate Coralogix access with a lightweight direct query."""
|
|
result = self.query_logs("source logs | limit 1", time_range_minutes=15, limit=1)
|
|
if not result.get("success"):
|
|
return result
|
|
return {
|
|
"success": True,
|
|
"total": result.get("total", 0),
|
|
"warnings": result.get("warnings", []),
|
|
}
|
|
|
|
def probe_access(self) -> ProbeResult:
|
|
"""Validate Coralogix access using the lightweight direct query probe."""
|
|
if not self.is_configured:
|
|
return ProbeResult.missing("Missing Coralogix API key or API URL.")
|
|
|
|
result = self.validate_access()
|
|
if not result.get("success"):
|
|
return ProbeResult.failed(
|
|
f"DataPrime check failed: {result.get('error', 'unknown error')}"
|
|
)
|
|
|
|
scope: list[str] = []
|
|
if self.config.application_name:
|
|
scope.append(f"application {self.config.application_name}")
|
|
if self.config.subsystem_name:
|
|
scope.append(f"subsystem {self.config.subsystem_name}")
|
|
scope_detail = f" ({', '.join(scope)})" if scope else ""
|
|
total = int(result.get("total", 0) or 0)
|
|
return ProbeResult.passed(
|
|
(
|
|
f"Connected to {self.config.base_url}{scope_detail}; "
|
|
f"DataPrime returned {total} row(s)."
|
|
),
|
|
total=total,
|
|
)
|