← Files CrowdStrike AgentWorksARCHIVED FILE
common/scripts/discovery_helpers.py
7.69 KB · Nov 11, 2026 · 13:09 UTC
"""
Shared helpers for Charlotte AI AgentWorks discovery scripts.
This module provides common functionality for querying discovery endpoints
(spans, templates, tools, versions) which all share the same interface.
"""
import argparse
import json
import re
import sys
import time
from typing import Any, Callable
from auth import call_native, get_spans_client, is_transient_error
# Attempts per span batch (1 try + retries on transient errors).
HYDRATE_ATTEMPTS = 3
def hydrate_spans(span_ids: list[str], batch_size: int = 50) -> list[dict]:
"""Hydrate span IDs into full entities with automatic batching.
Uses the native FalconPy Spans.entities_spans_v1 method, which serializes the
`ids` array-type query parameter correctly on its own (repeated `ids=` params),
so no manual query-string construction is needed here.
Transient failures (429/5xx, connection errors) are retried per batch; a permanent
failure, or a batch that still fails after the retries, raises. Returning a partial
list would let callers compute error rates, costs and durations from a subset of spans
and present them as complete.
Args:
span_ids: List of span IDs to hydrate
batch_size: Number of IDs per request (default: 50, tested up to 50)
Returns:
List of hydrated span entities
Raises:
RuntimeError: a batch could not be hydrated.
"""
if not span_ids:
return []
all_spans = []
spans_client = get_spans_client()
for i in range(0, len(span_ids), batch_size):
batch = span_ids[i:i + batch_size]
for attempt in range(1, HYDRATE_ATTEMPTS + 1):
try:
response = call_native(spans_client.entities_spans_v1, ids=batch)
except Exception as e: # pylint: disable=broad-exception-caught
if attempt == HYDRATE_ATTEMPTS or not is_transient_error(e):
raise RuntimeError(
f"Failed to hydrate span batch {i // batch_size + 1} "
f"({len(batch)} of {len(span_ids)} spans): {e}"
) from e
print(f"Warning: span batch {i // batch_size + 1} failed ({e}); "
f"retry {attempt}/{HYDRATE_ATTEMPTS - 1}...", file=sys.stderr)
time.sleep(2 ** attempt)
continue
all_spans.extend(response.get("resources", []))
break
return all_spans
# QueriesSpansV1 rejects (or clamps) any limit above this.
SPAN_QUERY_PAGE_SIZE = 500
# The tool_response span_type is the single canonical signal for "a tool call
# completed": it carries the call's name, status, tool.result_is_error, and
# duration_ms for the full round trip. The paired "tool" span_type (the request)
# is intentionally NOT counted here -- matching both via a "tool" in span_type
# substring check (as this script used to) double-counts every single tool call,
# since "tool_response" also contains the substring "tool" (confirmed live:
# every tool invocation emits exactly one "tool" span + one "tool_response" span).
TOOL_RESPONSE_SPAN_TYPE = "tool_response"
def query_span_ids(filter_expr: str, max_results: int, sort: str = "start_time|desc") -> list[str]:
"""Query span IDs (FQL syntax), paginating with offset in pages of SPAN_QUERY_PAGE_SIZE.
Stops at max_results IDs, or earlier once the API reports no more results. Errors
propagate to the caller rather than being reported as an empty result.
Args:
filter_expr: FQL filter for QueriesSpansV1
max_results: Maximum number of span IDs to return
sort: Sort expression (default: newest first)
Returns:
List of span IDs, at most max_results long
"""
ids: list[str] = []
offset = 0
while len(ids) < max_results:
page_size = min(SPAN_QUERY_PAGE_SIZE, max_results - len(ids))
params = {
"filter": filter_expr,
"limit": str(page_size),
"offset": str(offset),
"sort": sort,
}
response = call_native(get_spans_client().queries_spans_v1, parameters=params)
page = response.get("resources", [])
ids.extend(page)
offset += len(page)
total = response.get("meta", {}).get("pagination", {}).get("total")
# Stop on an empty page or when the API's total is reached. A short page only ends the
# loop when no total is reported: the API may clamp a page below the requested limit.
if not page or (total is not None and offset >= total) or (
total is None and len(page) < page_size
):
break
return ids
def _ensure_span_time_filter(filter_str: str) -> str:
"""
Ensure span queries have time bounds per API requirements.
The span query API requires time-bounded filters using start_time/end_time.
If the filter doesn't contain these, default to last 24 hours.
Uses FQL (Falcon Query Language) syntax.
Args:
filter_str: FQL filter string
Returns:
Filter string with time bounds guaranteed (FQL syntax)
"""
# Check if filter already has start_time or end_time
if re.search(r'\b(start_time|end_time)\s*:', filter_str, re.IGNORECASE):
return filter_str
# Add default 24h time window (FQL syntax: field:operator'value')
default_time = "start_time:>='now-24h'"
if filter_str.strip():
# FQL uses + for AND
return f"({filter_str})+{default_time}"
return default_time
def run_entity_search(
entity_name: str,
entity_plural: str,
query_fn: Callable[..., dict],
require_time_filter: bool = False,
) -> None:
"""
Generic search helper for discovery endpoints.
Args:
entity_name: Singular entity name for display (e.g., "span", "template")
entity_plural: Plural entity name for display (e.g., "spans", "templates")
query_fn: Callable forwarding its kwargs to a native FalconPy query method, e.g.
`lambda **kw: get_models_client().queries_models_v1(**kw)`. Must be a lambda
(not a bound method fetched eagerly by the caller) so that get_*_client()
— which resolves credentials and can exit(1) if they're missing — only runs
after argparse below has had a chance to handle --help / bad args. Called as
call_native(query_fn, parameters=params).
require_time_filter: If True, ensure time bounds in filter (for span queries)
"""
parser = argparse.ArgumentParser(
description=f"Query {entity_plural}"
)
parser.add_argument("--filter", help="FQL filter", default="")
parser.add_argument("--limit", type=int, default=100, help="Max results")
parser.add_argument("--offset", type=int, default=0, help="Offset for pagination")
parser.add_argument("--json", action="store_true", help="Output raw JSON")
args = parser.parse_args()
filter_str = args.filter
# Span queries require time bounds per API documentation
if require_time_filter:
filter_str = _ensure_span_time_filter(filter_str)
if filter_str != args.filter:
print("INFO: Added default time filter (last 24h) to span query", file=sys.stderr)
params: dict[str, Any] = {"limit": args.limit, "offset": args.offset}
if filter_str:
params["filter"] = filter_str
response = call_native(query_fn, parameters=params)
if args.json:
print(json.dumps(response, indent=2))
return
resources = response.get("resources", [])
total = response.get("meta", {}).get("pagination", {}).get("total", len(resources))
if not resources:
print(f"No {entity_plural} found.")
return
print(f"Found {len(resources)} {entity_name} ID(s) (total: {total}):")
for entity_id in resources:
print(f" {entity_id}")
SHA-256: 02b010c2db0808aa70d46df3704e0d5585c79aacfef0b180fb7c76d249c86e90