Files
zeekay 1b367e2b62 chore: rebase the tree onto upstream master
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.
2026-08-01 15:44:40 -07:00

98 lines
4.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from typing import Any
from insights.schema import AlertCondition, AlertConditionType, FunnelsAlertConfig, FunnelsQuery, IntervalType
from insights.api.services.query import ExecutionMode
from insights.caching.calculate_results import calculate_for_query_based_insight
from insights.event_usage import EventSource
from insights.tasks.alerts.trends import query_excludes_incomplete_periods
from products.alerts.backend.evaluation.contract import AlertExtractionError, ExtractionResult, lookback_intervals_for
from products.alerts.backend.evaluation.funnel_strategies import strategy_for_viz
from products.alerts.backend.models.alert import AlertConfiguration
from products.product_analytics.backend.models.insight import Insight
def _trailing_date_range_override(interval: IntervalType | None, periods: int) -> dict:
# Widen the insight's date range to the last `periods` intervals (no date_to, so the current
# interval is included) — mirrors the trends extractor so a relative funnel alert always has a
# prior period to diff against, regardless of the insight's saved range.
match interval:
case IntervalType.DAY:
unit = "d"
case IntervalType.WEEK:
unit = "w"
case IntervalType.MONTH:
unit = "m"
case IntervalType.QUARTER:
unit = "q"
case IntervalType.YEAR:
unit = "y"
case _:
unit = "h"
return {"date_from": f"-{periods}{unit}"}
class FunnelsExtractor:
"""Normalize a funnel insight into a ``ComparableSeries`` (one per breakdown) for the comparator.
The shared scaffolding lives here — config/condition guards and running the query — while the
viz-type-specific work (what metric to read, how to shape the result) is delegated to a
``FunnelVizStrategy`` selected by ``funnelVizType``. A breakdown funnel yields one series per
breakdown value and fires if ANY breaches (matching trends).
"""
def extract(
self, alert: AlertConfiguration, insight: Insight, query: Any, execution_mode: ExecutionMode
) -> ExtractionResult:
funnels_query = FunnelsQuery.model_validate(query)
viz = funnels_query.funnelsFilter.funnelVizType if funnels_query.funnelsFilter else None
strategy = strategy_for_viz(viz)
if not (alert.config and alert.config.get("type") == "FunnelsAlertConfig"):
raise AlertExtractionError(f"Unsupported alert config type: {alert.config}")
config = FunnelsAlertConfig.model_validate(alert.config)
condition = AlertCondition.model_validate(alert.condition)
# Relative conditions need a prior value to compare against, which only a time-series viz
# (historical trends) has — a steps snapshot is absolute-only.
if condition.type != AlertConditionType.ABSOLUTE_VALUE and not strategy.supports_relative_conditions:
raise AlertExtractionError("This funnel only supports absolute value conditions.")
# The query clips the ongoing interval, so the trailing point is complete.
already_complete = query_excludes_incomplete_periods(funnels_query)
# A relative condition diffs against the prior period, but the insight's own date range may be
# too short to yield one — leaving the alert silently inert. Widen it to the trailing intervals
# the comparator needs (mirroring the trends extractor). Absolute conditions read the insight's
# configured range as-is: a steps snapshot, or the trends last-complete period.
filters_override = (
None
if condition.type == AlertConditionType.ABSOLUTE_VALUE
else _trailing_date_range_override(funnels_query.interval, lookback_intervals_for(condition))
)
calculation_result = calculate_for_query_based_insight(
insight,
team=alert.team,
execution_mode=execution_mode,
user=alert.created_by,
filters_override=filters_override,
analytics_props={"source": EventSource.ALERT},
)
# A None result means the query layer swallowed an error — surface it as RuntimeError (not
# AlertExtractionError) so it routes to the harder failure path, matching the trends extractor
# (and the shared error message the alert-failure dashboards bucket on).
if calculation_result.result is None:
raise RuntimeError(f"No results found for insight with alert id = {alert.id}")
series = strategy.to_series(calculation_result.result, config, already_complete=already_complete)
return ExtractionResult(
series=series,
is_breakdown=len(series) > 1,
subject=strategy.subject,
framed=False,
unit=strategy.unit, # conversion rates are absolute 0–100 percentages
)