Files
tracer-cloud--opensre/integrations/elasticsearch/client.py
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

287 lines
11 KiB
Python

"""Elasticsearch / OpenSearch REST API client.
Uses the ES HTTP API directly via httpx (no elasticsearch-py SDK).
Supports three authentication modes, in order of precedence:
1. No authentication — clusters with security disabled.
2. API key authentication — emits ``Authorization: ApiKey <key>``.
Native to Elasticsearch and to OpenSearch deployments that have
added API key support.
3. HTTP Basic authentication — emits ``Authorization: Basic <base64>``.
This is the default and primary authentication method for most
self-hosted OpenSearch deployments, where API keys are not natively
available (see opensearch-project/security#4009).
When both ``api_key`` and (``username``, ``password``) are configured,
the API key takes precedence.
"""
from __future__ import annotations
import base64
import logging
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from typing import Any
import httpx
from platform.observability.errors.service import capture_service_error
logger = logging.getLogger(__name__)
_DEFAULT_TIMEOUT = 30
@dataclass
class ElasticsearchConfig:
url: str
api_key: str | None = None
username: str | None = None
password: str | None = None
index_pattern: str = field(default="*")
@property
def base_url(self) -> str:
return self.url.rstrip("/")
@property
def headers(self) -> dict[str, str]:
h: dict[str, str] = {"Content-Type": "application/json"}
if self.api_key:
# Preferred: API key (native to Elasticsearch; supported by
# some OpenSearch deployments).
h["Authorization"] = f"ApiKey {self.api_key}"
elif self.username and self.password:
# Fallback: HTTP Basic Auth (primary method for most
# self-hosted OpenSearch clusters).
credentials = base64.b64encode(f"{self.username}:{self.password}".encode()).decode()
h["Authorization"] = f"Basic {credentials}"
return h
class ElasticsearchClient:
"""Synchronous client for querying Elasticsearch via the REST API."""
def __init__(self, config: ElasticsearchConfig) -> None:
self.config = config
self._client: httpx.Client | None = None
def _get_client(self) -> httpx.Client:
if self._client is None:
self._client = httpx.Client(
base_url=self.config.base_url,
headers=self.config.headers,
timeout=_DEFAULT_TIMEOUT,
)
return self._client
@property
def is_configured(self) -> bool:
return bool(self.config.url)
def check_security(self) -> dict[str, Any]:
"""Probe the cluster to detect whether security (authentication) is enabled.
Makes an unauthenticated GET /_cluster/health request.
- HTTP 200 → security disabled (no credentials required)
- HTTP 401 → security enabled (credentials required)
- anything else → error
"""
try:
resp = httpx.get(
f"{self.config.base_url}/_cluster/health",
timeout=_DEFAULT_TIMEOUT,
)
if resp.status_code == 200:
security_enabled = False
elif resp.status_code == 401:
security_enabled = True
else:
return {
"success": False,
"error": f"Unexpected status {resp.status_code} from /_cluster/health",
}
return {"success": True, "security_enabled": security_enabled}
except Exception as exc:
capture_service_error(
exc, logger=logger, integration="elasticsearch", method="check_security"
)
return {"success": False, "error": str(exc)}
def list_indices(self) -> dict[str, Any]:
"""List all indices via GET /_cat/indices?format=json."""
try:
resp = self._get_client().get("/_cat/indices", params={"format": "json"})
resp.raise_for_status()
raw: list[dict[str, Any]] = resp.json()
indices = [
{
"index": idx.get("index", ""),
"health": idx.get("health", ""),
"status": idx.get("status", ""),
"docs_count": idx.get("docs.count", ""),
"store_size": idx.get("store.size", ""),
}
for idx in raw
]
return {"success": True, "indices": indices, "total": len(indices)}
except httpx.HTTPStatusError as exc:
capture_service_error(
exc, logger=logger, integration="elasticsearch", method="list_indices"
)
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="elasticsearch", method="list_indices"
)
return {"success": False, "error": str(exc)}
def list_data_streams(self) -> dict[str, Any]:
"""List all data streams via GET /_data_stream."""
try:
resp = self._get_client().get("/_data_stream")
resp.raise_for_status()
data = resp.json()
streams = data.get("data_streams", [])
results = [
{
"name": s.get("name", ""),
"status": s.get("status", ""),
"indices": [i.get("index_name", "") for i in s.get("indices", [])],
}
for s in streams
]
return {"success": True, "data_streams": results, "total": len(results)}
except httpx.HTTPStatusError as exc:
capture_service_error(
exc, logger=logger, integration="elasticsearch", method="list_data_streams"
)
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="elasticsearch", method="list_data_streams"
)
return {"success": False, "error": str(exc)}
def search_logs(
self,
query: str = "*",
time_range_minutes: int = 60,
limit: int = 50,
index_pattern: str | None = None,
timestamp_field: str = "@timestamp",
) -> dict[str, Any]:
"""Search logs via POST /{index_pattern}/_search with a time-range filter.
Args:
query: Lucene/KQL query string (default "*" = all documents)
time_range_minutes: How far back to search (default 60 minutes)
limit: Maximum number of hits to return (capped at 1000)
index_pattern: Override config index_pattern for this call
timestamp_field: Timestamp field for range filtering (default "@timestamp")
"""
pattern = index_pattern or self.config.index_pattern
now = datetime.now(UTC)
from_ts = now - timedelta(minutes=time_range_minutes)
payload: dict[str, Any] = {
"size": min(limit, 1000),
"sort": [{timestamp_field: {"order": "desc"}}],
"query": {
"bool": {
"must": [
{"query_string": {"query": query, "default_field": "*"}},
{
"range": {
timestamp_field: {
"gte": from_ts.isoformat(),
"lte": now.isoformat(),
}
}
},
]
}
},
}
try:
resp = self._get_client().post(f"/{pattern}/_search", json=payload)
resp.raise_for_status()
data = resp.json()
hits = data.get("hits", {}).get("hits", [])
logs = [
{
"timestamp": src.get(timestamp_field, ""),
"message": src.get("message", ""),
"level": src.get("level", src.get("log.level", "")),
"service": src.get("service", src.get("service.name", "")),
"index": hit.get("_index", ""),
**{
k: v
for k, v in src.items()
if k not in {timestamp_field, "message", "level", "service"}
},
}
for hit in hits
for src in [hit.get("_source", {})]
]
return {"success": True, "logs": logs, "total": len(logs), "query": query}
except httpx.HTTPStatusError as exc:
capture_service_error(
exc,
logger=logger,
integration="elasticsearch",
method="search_logs",
extras={"query": query, "time_range_minutes": time_range_minutes},
)
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="elasticsearch",
method="search_logs",
extras={"query": query, "time_range_minutes": time_range_minutes},
)
return {"success": False, "error": str(exc)}
def get_cluster_health(self) -> dict[str, Any]:
"""GET /_cluster/health — returns cluster name, status, and shard counts."""
try:
resp = self._get_client().get("/_cluster/health")
resp.raise_for_status()
data: dict[str, Any] = resp.json()
return {
"success": True,
"cluster_name": data.get("cluster_name", ""),
"status": data.get("status", ""),
"number_of_nodes": data.get("number_of_nodes", 0),
"number_of_data_nodes": data.get("number_of_data_nodes", 0),
"active_primary_shards": data.get("active_primary_shards", 0),
"active_shards": data.get("active_shards", 0),
"unassigned_shards": data.get("unassigned_shards", 0),
}
except httpx.HTTPStatusError as exc:
capture_service_error(
exc, logger=logger, integration="elasticsearch", method="get_cluster_health"
)
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="elasticsearch", method="get_cluster_health"
)
return {"success": False, "error": str(exc)}