mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
The tree was a debranded snapshot of upstream taken around 2026-02-20, and upstream has moved about five months since. This replaces it with upstream master, mechanically re-derived: bin/debrand does the rename, and the pipeline and squashed migrations are restored from the shipped tree. Not a merge. The previous import replaced the tree wholesale in a single commit, so there is no shared content history to merge against. Reproducing the rename on the new tree is what actually converges. What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints, compose files, LLM.md, and insights/migrations. The squashed initial migration is load-bearing — the live database records it by name, so upstream's ~918 individual migrations cannot come back. Nine products are dropped as unreferenced. Twenty-one more that we do not run are kept anyway because the core app still imports them; unpicking those is a refactor and does not belong in a rebase. This base does not build yet, and the reasons are not in the rename: upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against our 4.2, and the dependency manifests still name upstream packages that we republish under our own names. Those are decisions, not renames.
399 lines
16 KiB
Python
399 lines
16 KiB
Python
"""Facade for metrics.
|
|
|
|
This is the ONLY module other products (and the presentation layer) are
|
|
allowed to import. Internal modules (query runners) stay behind this seam
|
|
so import-linter's strict-mode contract holds.
|
|
"""
|
|
|
|
import math
|
|
import datetime as dt
|
|
from typing import Any
|
|
|
|
from insights.models import Team
|
|
|
|
from products.metrics.backend.anomaly import characterize_anomaly as _characterize_anomaly
|
|
from products.metrics.backend.facade.contracts import (
|
|
CompanionMetric,
|
|
IncidentContext,
|
|
InvestigationResult,
|
|
MetricAnomalyReport,
|
|
MetricEventSample,
|
|
MetricFilter,
|
|
MetricPoint,
|
|
MetricQueryClause,
|
|
MetricQueryRequest,
|
|
MetricSeries,
|
|
)
|
|
from products.metrics.backend.facade.enums import FilterOp, MetricAggregation
|
|
from products.metrics.backend.formula import evaluate, parse_formula
|
|
from products.metrics.backend.has_metrics_query_runner import team_has_metrics as _team_has_metrics
|
|
from products.metrics.backend.investigation import investigate as _investigate
|
|
from products.metrics.backend.metric_attributes_query_runner import (
|
|
MetricAttributeKeysQueryRunner,
|
|
MetricAttributeValuesQueryRunner,
|
|
)
|
|
from products.metrics.backend.metric_event_samples_query_runner import MetricEventSamplesQueryRunner
|
|
from products.metrics.backend.metric_names_query_runner import MetricNamesQueryRunner
|
|
from products.metrics.backend.metric_query_runner import MetricQueryRunner
|
|
|
|
# MetricQueryRunner still speaks the legacy aggregation strings; this shrinks
|
|
# as later PRs teach the runner the remaining MetricAggregation values.
|
|
_RUNNER_AGGREGATIONS: dict[MetricAggregation, str] = {
|
|
MetricAggregation.SUM: "sum",
|
|
MetricAggregation.AVG: "avg",
|
|
MetricAggregation.COUNT: "count",
|
|
MetricAggregation.RATE: "rate",
|
|
MetricAggregation.INCREASE: "increase",
|
|
}
|
|
|
|
|
|
def team_has_metrics(team: Team) -> bool:
|
|
"""Return True if the given team has ingested at least one metric."""
|
|
return _team_has_metrics(team)
|
|
|
|
|
|
# Hard cap on series returned per clause; the largest series (by summed
|
|
# absolute value) win so the most significant groups survive truncation.
|
|
MAX_SERIES_PER_CLAUSE = 100
|
|
|
|
|
|
def _assemble_series(
|
|
rows: list[dict[str, Any]], *, metric_name: str, clause_name: str, grid: list[str]
|
|
) -> list[MetricSeries]:
|
|
"""Split bucketed rows into one series per label-set, zero-filled onto
|
|
the shared grid so every series (and later, every clause of a formula)
|
|
has identical timestamps."""
|
|
by_labels: dict[tuple[tuple[str, str], ...], dict[str, float | None]] = {}
|
|
for row in rows:
|
|
key = tuple(sorted(row["labels"].items()))
|
|
by_labels.setdefault(key, {})[row["time"]] = row["value"]
|
|
|
|
# Rank and truncate on the sparse values BEFORE zero-filling, so a
|
|
# high-cardinality group-by never materializes label_sets x grid points
|
|
# only to throw most of them away. Zero-filled points contribute nothing
|
|
# to the magnitude, so the ranking is identical either way.
|
|
ranked = sorted(
|
|
by_labels.items(), key=lambda item: (-sum(abs(v) for v in item[1].values() if v is not None), item[0])
|
|
)
|
|
return [
|
|
MetricSeries(
|
|
labels=dict(key),
|
|
points=tuple(MetricPoint(time=time, value=values.get(time, 0.0)) for time in grid),
|
|
metric_name=metric_name,
|
|
clause=clause_name,
|
|
)
|
|
for key, values in ranked[:MAX_SERIES_PER_CLAUSE]
|
|
]
|
|
|
|
|
|
def _resolve_runner_aggregation(clause: MetricQueryClause) -> str:
|
|
if clause.aggregation == MetricAggregation.QUANTILE and clause.quantile == 0.95:
|
|
return "p95"
|
|
if clause.aggregation == MetricAggregation.HISTOGRAM_QUANTILE:
|
|
return "histogram_quantile"
|
|
if clause.aggregation in _RUNNER_AGGREGATIONS:
|
|
return _RUNNER_AGGREGATIONS[clause.aggregation]
|
|
raise ValueError(f"aggregation {clause.aggregation.value!r} is not supported yet")
|
|
|
|
|
|
def _evaluate_formula_point(
|
|
node: Any, per_clause_points: dict[str, tuple[MetricPoint, ...]], index: int
|
|
) -> float | None:
|
|
"""One formula grid point. A null (gap) in any input propagates as a
|
|
gap, and a result the formula overflowed to inf/NaN becomes a gap too —
|
|
same policy as the per-clause aggregates."""
|
|
values: dict[str, float] = {}
|
|
for name, pts in per_clause_points.items():
|
|
value = pts[index].value
|
|
if value is None:
|
|
return None
|
|
values[name] = value
|
|
result = evaluate(node, values)
|
|
return result if math.isfinite(result) else None
|
|
|
|
|
|
def _evaluate_formula(
|
|
formula_text: str, series_by_clause: dict[str, list[MetricSeries]], grid: list[str]
|
|
) -> list[MetricSeries]:
|
|
"""Combine clause results point-by-point on the shared grid.
|
|
|
|
Series are matched across clauses by exact label-set equality
|
|
(Prometheus-style one-to-one vector matching); a clause that produced a
|
|
single ungrouped series is broadcast to every label-set instead. A
|
|
label-set missing from any non-broadcast clause is dropped.
|
|
"""
|
|
node = parse_formula(formula_text, frozenset(series_by_clause))
|
|
|
|
broadcasts: dict[str, MetricSeries] = {}
|
|
grouped: dict[str, dict[tuple[tuple[str, str], ...], MetricSeries]] = {}
|
|
for name, series_list in series_by_clause.items():
|
|
if len(series_list) == 1 and not series_list[0].labels:
|
|
broadcasts[name] = series_list[0]
|
|
else:
|
|
grouped[name] = {tuple(sorted(s.labels.items())): s for s in series_list}
|
|
|
|
if grouped:
|
|
label_sets: set[tuple[tuple[str, str], ...]] = set.intersection(
|
|
*(set(by_labels) for by_labels in grouped.values())
|
|
)
|
|
else:
|
|
label_sets = {()}
|
|
|
|
result: list[MetricSeries] = []
|
|
for label_set in sorted(label_sets):
|
|
per_clause_points: dict[str, tuple[MetricPoint, ...]] = {
|
|
name: (grouped[name][label_set].points if name in grouped else broadcasts[name].points)
|
|
for name in series_by_clause
|
|
}
|
|
points = tuple(
|
|
MetricPoint(time=time, value=_evaluate_formula_point(node, per_clause_points, index))
|
|
for index, time in enumerate(grid)
|
|
)
|
|
result.append(MetricSeries(labels=dict(label_set), points=points, metric_name=None, clause="formula"))
|
|
return result
|
|
|
|
|
|
def run_metric_query(*, team: Team, request: MetricQueryRequest) -> list[MetricSeries]:
|
|
"""Execute a metric query and return one `MetricSeries` per
|
|
(clause, label-set) pair — a single ungrouped clause yields exactly one
|
|
series with empty labels, so consumers never branch on single-vs-multi.
|
|
|
|
Every series of every clause shares one bucket grid (the union of
|
|
observed buckets, zero-filled), which is what makes cross-series and
|
|
cross-clause math line up. Series per clause are capped at
|
|
`MAX_SERIES_PER_CLAUSE`, keeping the largest ones.
|
|
|
|
With `formula` set, only the formula result series are returned
|
|
(`clause="formula"`); request the clauses separately if you need the
|
|
inputs too. The presentation layer surfaces `ValueError` as a 400.
|
|
"""
|
|
rows_by_clause: dict[str, list[dict[str, Any]]] = {}
|
|
for clause in request.clauses:
|
|
runner_aggregation = _resolve_runner_aggregation(clause)
|
|
runner = MetricQueryRunner(
|
|
team=team,
|
|
metric_name=clause.metric_name,
|
|
aggregation=runner_aggregation,
|
|
date_from=request.date_from,
|
|
date_to=request.date_to,
|
|
filters=clause.filters,
|
|
group_by=clause.group_by,
|
|
interval=request.interval,
|
|
quantile=clause.quantile if runner_aggregation == "histogram_quantile" else None,
|
|
metric_type=clause.metric_type.value if clause.metric_type is not None else None,
|
|
)
|
|
rows_by_clause[clause.name] = runner.run()
|
|
|
|
# Validate the formula before any early return so bad formulas always 400.
|
|
formula_node_checked = (
|
|
parse_formula(request.formula, frozenset(rows_by_clause)) if request.formula is not None else None
|
|
)
|
|
|
|
grid = sorted({row["time"] for rows in rows_by_clause.values() for row in rows})
|
|
if not grid:
|
|
empty_clause = "formula" if formula_node_checked is not None else request.clauses[0].name
|
|
metric_name = None if formula_node_checked is not None else request.clauses[0].metric_name
|
|
return [MetricSeries(labels={}, points=(), metric_name=metric_name, clause=empty_clause)]
|
|
|
|
series_by_clause = {
|
|
clause.name: _assemble_series(
|
|
rows_by_clause[clause.name], metric_name=clause.metric_name, clause_name=clause.name, grid=grid
|
|
)
|
|
for clause in request.clauses
|
|
}
|
|
|
|
if request.formula is not None:
|
|
return _evaluate_formula(request.formula, series_by_clause, grid)
|
|
|
|
return [series for clause in request.clauses for series in series_by_clause[clause.name]]
|
|
|
|
|
|
def list_metric_names(
|
|
*,
|
|
team: Team,
|
|
search: str = "",
|
|
limit: int = 100,
|
|
) -> list[dict[str, Any]]:
|
|
"""List distinct metric names for the team's picker.
|
|
|
|
Returns a list of `{"name": str, "metric_type": str}` dicts ordered by
|
|
most-recently-seen, with exact-name matches floated to the top.
|
|
Raises `ValueError` for an out-of-range limit.
|
|
"""
|
|
runner = MetricNamesQueryRunner(team=team, search=search, limit=limit)
|
|
return runner.run()
|
|
|
|
|
|
def list_metric_attribute_keys(
|
|
*,
|
|
team: Team,
|
|
search: str = "",
|
|
date_from: dt.datetime | None = None,
|
|
date_to: dt.datetime | None = None,
|
|
limit: int = 100,
|
|
) -> list[dict[str, Any]]:
|
|
"""List distinct attribute keys seen on the team's metrics, most frequent
|
|
first, for the filter bar's key autocomplete.
|
|
|
|
Datapoint and resource attributes are merged into one list (filters run
|
|
with scope 'auto', so the split doesn't matter to callers); `service_name`
|
|
is always surfaced when it matches the search. The window defaults to the
|
|
last 7 days. Returns `{"name": str}` dicts. Raises `ValueError` for an
|
|
out-of-range limit or an inverted window.
|
|
"""
|
|
runner = MetricAttributeKeysQueryRunner(team=team, search=search, date_from=date_from, date_to=date_to, limit=limit)
|
|
return runner.run()
|
|
|
|
|
|
def list_metric_attribute_values(
|
|
*,
|
|
team: Team,
|
|
key: str,
|
|
search: str = "",
|
|
date_from: dt.datetime | None = None,
|
|
date_to: dt.datetime | None = None,
|
|
limit: int = 100,
|
|
) -> list[dict[str, Any]]:
|
|
"""List observed values for one metric attribute key, most frequent first,
|
|
for the filter bar's value autocomplete.
|
|
|
|
`service_name`/`service.name` read the first-class column, matching how
|
|
filters on it execute. The window defaults to the last 7 days. Returns
|
|
`{"id": str, "name": str, "count": int}` dicts. Raises `ValueError` for an
|
|
empty key, an out-of-range limit, or an inverted window.
|
|
"""
|
|
runner = MetricAttributeValuesQueryRunner(
|
|
team=team, key=key, search=search, date_from=date_from, date_to=date_to, limit=limit
|
|
)
|
|
return runner.run()
|
|
|
|
|
|
def list_metric_event_samples(
|
|
*,
|
|
team: Team,
|
|
metric_name: str,
|
|
date_from: dt.datetime,
|
|
date_to: dt.datetime,
|
|
trace_id: str | None = None,
|
|
limit: int = 100,
|
|
) -> list[MetricEventSample]:
|
|
"""List individual metric emissions (the events model) for a metric,
|
|
newest first.
|
|
|
|
Each sample carries its value, attributes, and trace linkage, so the
|
|
Samples view can render raw rows and pivot to the trace behind any one.
|
|
Pass `trace_id` for the reverse pivot — every emission on a given trace.
|
|
Raises `ValueError` for an empty metric name, an inverted window, or an
|
|
out-of-range limit; the presentation layer surfaces these as 400s.
|
|
"""
|
|
runner = MetricEventSamplesQueryRunner(
|
|
team=team,
|
|
metric_name=metric_name,
|
|
date_from=date_from,
|
|
date_to=date_to,
|
|
trace_id=trace_id,
|
|
limit=limit,
|
|
)
|
|
return [MetricEventSample(**row) for row in runner.run()]
|
|
|
|
|
|
def characterize_metric_anomaly(
|
|
*,
|
|
team: Team,
|
|
metric_name: str,
|
|
anomaly_from: dt.datetime,
|
|
anomaly_to: dt.datetime,
|
|
baseline_from: dt.datetime | None = None,
|
|
baseline_to: dt.datetime | None = None,
|
|
aggregation: str | None = None,
|
|
quantile: float | None = None,
|
|
filters: tuple[MetricFilter, ...] = (),
|
|
candidate_keys: tuple[str, ...] | None = None,
|
|
) -> MetricAnomalyReport:
|
|
"""Characterize how a metric behaves in an anomaly window vs a baseline:
|
|
summary statistics, change magnitude/direction, the onset bucket, and
|
|
the label values that moved the most (drilling into up to four candidate
|
|
keys, auto-discovered from the metric's attributes when not given).
|
|
|
|
The baseline defaults to the window of equal length immediately before
|
|
`anomaly_from`. `aggregation` defaults by the metric's OTel type
|
|
(counter -> rate, gauge -> avg, histogram -> histogram_quantile 0.95).
|
|
Raises `ValueError` for invalid windows/aggregations — the presentation
|
|
layer surfaces these as 400s.
|
|
"""
|
|
return _characterize_anomaly(
|
|
team=team,
|
|
metric_name=metric_name,
|
|
anomaly_from=anomaly_from,
|
|
anomaly_to=anomaly_to,
|
|
baseline_from=baseline_from,
|
|
baseline_to=baseline_to,
|
|
aggregation=aggregation,
|
|
quantile=quantile,
|
|
filters=filters,
|
|
candidate_keys=candidate_keys,
|
|
)
|
|
|
|
|
|
def investigate(
|
|
*,
|
|
team: Team,
|
|
metric_name: str,
|
|
anomaly_from: dt.datetime,
|
|
anomaly_to: dt.datetime,
|
|
baseline_from: dt.datetime | None = None,
|
|
baseline_to: dt.datetime | None = None,
|
|
aggregation: str | None = None,
|
|
quantile: float | None = None,
|
|
filters: tuple[MetricFilter, ...] = (),
|
|
candidate_keys: tuple[str, ...] | None = None,
|
|
companions: tuple[CompanionMetric, ...] = (),
|
|
) -> InvestigationResult:
|
|
"""Investigate a metric symptom end to end and return one structured result.
|
|
|
|
Builds on `characterize_metric_anomaly`: it characterizes the metric over
|
|
the anomaly window, then characterizes each `companion` over the SAME window
|
|
to confirm or rule it out (e.g. throughput flat -> not a traffic surge),
|
|
classifies blast radius from the movers, implicates a service for the
|
|
logs/traces pivot, and emits re-runnable chart specs.
|
|
|
|
The result is the single shape the agent narrates, the in-app explorer
|
|
renders, and the incident report serializes. Raises `ValueError` for invalid
|
|
windows/aggregations — the presentation layer surfaces these as 400s.
|
|
"""
|
|
return _investigate(
|
|
team=team,
|
|
metric_name=metric_name,
|
|
anomaly_from=anomaly_from,
|
|
anomaly_to=anomaly_to,
|
|
baseline_from=baseline_from,
|
|
baseline_to=baseline_to,
|
|
aggregation=aggregation,
|
|
quantile=quantile,
|
|
filters=filters,
|
|
candidate_keys=candidate_keys,
|
|
companions=companions,
|
|
)
|
|
|
|
|
|
def investigate_incident(*, team: Team, context: IncidentContext) -> InvestigationResult:
|
|
"""Investigate a fired alert's metric with no timestamp math on the caller.
|
|
|
|
Derives the anomaly window straight from `context.fired_at` (an explicit
|
|
UTC instant) — no parsing a fire time out of prose, no timezone guesswork —
|
|
scopes to the implicated service, and runs the full investigation. Returns
|
|
the same `InvestigationResult` as `investigate()`. This is the entry point
|
|
an alert's "Investigate" action calls.
|
|
"""
|
|
filters: tuple[MetricFilter, ...] = ()
|
|
if context.service_name:
|
|
filters = (MetricFilter(key="service_name", op=FilterOp.EQ, value=context.service_name),)
|
|
return _investigate(
|
|
team=team,
|
|
metric_name=context.metric_name,
|
|
anomaly_from=context.fired_at - context.lookback,
|
|
anomaly_to=context.fired_at + context.leadout,
|
|
filters=filters,
|
|
companions=context.companions,
|
|
)
|