mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
Mechanically re-derived: bin/debrand runs over a fresh import of posthog/posthog@fa1a9a60d4 (2026-08-06). The pipeline (.hanzo/workflows), the Dockerfiles and bin/docker* entrypoints, compose files, LLM.md and the squashed initial migration are restored from the shipped tree, same as the previous import. The prune's import-closure guard kept 21 products the new core now imports; dropping them is a refactor now, recorded rather than forced.
482 lines
19 KiB
Python
482 lines
19 KiB
Python
from datetime import timedelta
|
|
|
|
from django.core.cache import cache
|
|
from django.utils import timezone
|
|
|
|
import structlog
|
|
from celery import shared_task
|
|
from slack_sdk.errors import SlackApiError
|
|
|
|
from insights.egress.github.transport import GitHubEgressBudgetExhausted, GitHubRateLimitError
|
|
from insights.event_usage import groups
|
|
from insights.exceptions_capture import capture_exception
|
|
from insights.models import Team
|
|
from insights.models.scoping import with_team_scope
|
|
from insights.ph_client import ph_scoped_capture
|
|
from insights.scoping_audit import skip_team_scope_audit
|
|
|
|
from products.signals.backend.implementation_pr import PrCloseReason, close_implementation_pr_for_report
|
|
from products.signals.backend.models import (
|
|
SignalReport,
|
|
SignalReportRefund,
|
|
SignalRepositoryAreaActivity,
|
|
SignalScoutEmission,
|
|
SignalScoutRun,
|
|
)
|
|
from products.signals.backend.report_generation.repo_activity import (
|
|
ACTIVITY_KEEP_WARM_WINDOW,
|
|
rebuild_repository_activity,
|
|
repository_activity_needs_rebuild,
|
|
)
|
|
from products.signals.backend.scout_harness.inactivity import sweep_inactive_scouts
|
|
from products.signals.backend.scout_harness.slack_delivery import (
|
|
ScoutSlackOutputType,
|
|
ScoutSlackPermanentDeliveryError,
|
|
post_scout_emission_to_slack,
|
|
post_scout_report_to_slack,
|
|
slack_api_error_code,
|
|
)
|
|
from products.signals.backend.slack_inbox_notifications import dispatch_reviewer_added_notifications
|
|
from products.tasks.backend.facade.repo_activity import RepositoryCommitActivityError
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
# Bounded exponential backoff: 2m, 4m, 8m, ... capped at 1h, 8 retries ≈ 5h total. Deliberately
|
|
# NOT unbounded — a hard failure should land with the sweeper (and its 7-day horizon) rather
|
|
# than retry forever. Rollover itself is survivable: the payload reports the period bounds
|
|
# frozen on the refund row, and billing credits against that period as long as it is still the
|
|
# customer's immediately-previous one.
|
|
_REFUND_SYNC_MAX_RETRIES = 8
|
|
_REFUND_SYNC_RETRY_BASE_SECONDS = 120
|
|
_REFUND_SYNC_RETRY_MAX_SECONDS = 3600
|
|
|
|
# Sweeper horizon: stop re-enqueueing unsynced credited refunds after 7 days. Anything older is
|
|
# an operational problem surfaced by the failure analytics event, handled manually (a billing
|
|
# admin creates the credit with idempotency key `signals_pr_dispute:{billing_customer_id}:{refund_id}`;
|
|
# the row then syncs as already_processed on any later attempt).
|
|
_REFUND_SYNC_SWEEP_MAX_AGE = timedelta(days=7)
|
|
|
|
# Billing answered a handled $0 because it could no longer credit the period the refund was
|
|
# accepted in — the sync outran billing's late-credit horizon (the frozen period is no longer
|
|
# the customer's current or immediately-previous one), or the row predates the frozen period
|
|
# bounds. Terminal for automation: retrying recomputes the same $0 (the frozen period only gets
|
|
# older), so the sweeper skips these rows and recovery is the documented manual credit path.
|
|
_OUT_OF_PERIOD_SYNC_ERROR = "billing: refund period no longer creditable at sync time; credit needs manual recovery"
|
|
|
|
_SCOUT_SLACK_MAX_RETRIES = 5
|
|
_SCOUT_SLACK_RETRY_BASE_SECONDS = 60
|
|
_SCOUT_SLACK_RETRY_MAX_SECONDS = 3600
|
|
_SCOUT_SLACK_DELIVERABLE_REPORT_STATUSES = frozenset((SignalReport.Status.READY, SignalReport.Status.PENDING_INPUT))
|
|
|
|
|
|
@shared_task(
|
|
name="products.signals.backend.tasks.close_dismissed_report_pr",
|
|
ignore_result=True,
|
|
max_retries=0,
|
|
)
|
|
@with_team_scope()
|
|
def close_dismissed_report_pr(report_id: str, team_id: int, reason: PrCloseReason = "suppressed") -> None:
|
|
close_implementation_pr_for_report(team_id, report_id, reason=reason)
|
|
|
|
|
|
def _slack_retry_after_seconds(exc: Exception) -> int | None:
|
|
if not isinstance(exc, SlackApiError) or not exc.response:
|
|
return None
|
|
headers = exc.response.headers or {}
|
|
raw_value = headers.get("Retry-After") or headers.get("retry-after")
|
|
try:
|
|
retry_after = int(raw_value) if raw_value is not None else None
|
|
except (TypeError, ValueError):
|
|
return None
|
|
return retry_after if retry_after is not None and retry_after > 0 else None
|
|
|
|
|
|
def _scout_slack_retry_countdown(exc: Exception, retries: int) -> int:
|
|
backoff = min(_SCOUT_SLACK_RETRY_BASE_SECONDS * (2**retries), _SCOUT_SLACK_RETRY_MAX_SECONDS)
|
|
retry_after = _slack_retry_after_seconds(exc)
|
|
return min(max(backoff, retry_after or 0), _SCOUT_SLACK_RETRY_MAX_SECONDS)
|
|
|
|
|
|
@shared_task(
|
|
name="products.signals.backend.tasks.deliver_scout_slack_output",
|
|
ignore_result=True,
|
|
bind=True,
|
|
acks_late=True,
|
|
reject_on_worker_lost=True,
|
|
max_retries=_SCOUT_SLACK_MAX_RETRIES,
|
|
)
|
|
@with_team_scope(canonical=True)
|
|
def deliver_scout_slack_output(
|
|
self,
|
|
team_id: int,
|
|
output_type: str,
|
|
output_id: str,
|
|
run_id: str,
|
|
delivery_id: str,
|
|
integration_id: int,
|
|
channel: str,
|
|
) -> None:
|
|
context = {
|
|
"team_id": team_id,
|
|
"output_type": output_type,
|
|
"output_id": output_id,
|
|
"run_id": run_id,
|
|
"integration_id": integration_id,
|
|
}
|
|
try:
|
|
if output_type == "finding":
|
|
emission = (
|
|
SignalScoutEmission.objects.select_related("scout_run", "team")
|
|
.filter(id=output_id, team_id=team_id, scout_run_id=run_id)
|
|
.first()
|
|
)
|
|
if emission is None:
|
|
logger.warning("signals_scout.slack_delivery_output_missing", **context)
|
|
return
|
|
post_scout_emission_to_slack(
|
|
emission,
|
|
integration_id=integration_id,
|
|
channel=channel,
|
|
)
|
|
elif output_type == "report":
|
|
report = SignalReport.objects.select_related("team").filter(id=output_id, team_id=team_id).first()
|
|
run = SignalScoutRun.objects.select_related("team").filter(id=run_id, team_id=team_id).first()
|
|
if report is None or run is None:
|
|
logger.warning("signals_scout.slack_delivery_output_missing", **context)
|
|
return
|
|
if report.status not in _SCOUT_SLACK_DELIVERABLE_REPORT_STATUSES:
|
|
logger.info(
|
|
"signals_scout.slack_delivery_report_not_surfaced",
|
|
**context,
|
|
report_status=report.status,
|
|
)
|
|
return
|
|
post_scout_report_to_slack(
|
|
report,
|
|
run,
|
|
delivery_id=delivery_id,
|
|
integration_id=integration_id,
|
|
channel=channel,
|
|
)
|
|
else:
|
|
logger.warning("signals_scout.slack_delivery_output_type_invalid", **context)
|
|
return
|
|
except ScoutSlackPermanentDeliveryError as exc:
|
|
capture_exception(exc, {**context, "error_code": exc.error_code})
|
|
logger.warning(
|
|
"signals_scout.slack_delivery_permanent_failure",
|
|
**context,
|
|
error_code=exc.error_code,
|
|
)
|
|
return
|
|
except Exception as exc:
|
|
if self.request.retries < _SCOUT_SLACK_MAX_RETRIES:
|
|
countdown = _scout_slack_retry_countdown(exc, self.request.retries)
|
|
logger.warning(
|
|
"signals_scout.slack_delivery_retrying",
|
|
**context,
|
|
error_code=slack_api_error_code(exc) if isinstance(exc, SlackApiError) else None,
|
|
attempt=self.request.retries + 1,
|
|
countdown=countdown,
|
|
)
|
|
raise self.retry(exc=exc, countdown=countdown)
|
|
|
|
capture_exception(
|
|
exc,
|
|
{
|
|
**context,
|
|
"error_code": slack_api_error_code(exc) if isinstance(exc, SlackApiError) else None,
|
|
"attempts": self.request.retries + 1,
|
|
},
|
|
)
|
|
logger.exception(
|
|
"signals_scout.slack_delivery_exhausted",
|
|
**context,
|
|
attempts=self.request.retries + 1,
|
|
)
|
|
return
|
|
|
|
logger.info("signals_scout.slack_delivery_sent", **context)
|
|
|
|
|
|
def enqueue_scout_slack_delivery(
|
|
*,
|
|
team_id: int,
|
|
output_type: ScoutSlackOutputType,
|
|
output_id: str,
|
|
run_id: str,
|
|
delivery_id: str,
|
|
integration_id: int,
|
|
channel: str,
|
|
) -> None:
|
|
"""Publish after commit, capturing broker failures without affecting the completed emit."""
|
|
try:
|
|
deliver_scout_slack_output.delay(
|
|
team_id,
|
|
output_type,
|
|
output_id,
|
|
run_id,
|
|
delivery_id,
|
|
integration_id,
|
|
channel,
|
|
)
|
|
except Exception as exc:
|
|
capture_exception(
|
|
exc,
|
|
{
|
|
"team_id": team_id,
|
|
"output_type": output_type,
|
|
"output_id": output_id,
|
|
"run_id": run_id,
|
|
"integration_id": integration_id,
|
|
},
|
|
)
|
|
logger.exception(
|
|
"signals_scout.slack_delivery_enqueue_failed",
|
|
team_id=team_id,
|
|
output_type=output_type,
|
|
output_id=output_id,
|
|
run_id=run_id,
|
|
integration_id=integration_id,
|
|
)
|
|
|
|
|
|
@shared_task(
|
|
name="products.signals.backend.tasks.send_reviewer_added_slack_notifications",
|
|
ignore_result=True,
|
|
max_retries=0,
|
|
)
|
|
@with_team_scope()
|
|
def send_reviewer_added_slack_notifications(
|
|
report_id: str, team_id: int, added_github_logins: list[str], exclude_user_id: int | None = None
|
|
) -> None:
|
|
"""Slack-ping reviewers a human just added to a report.
|
|
|
|
Runs on a worker because delivery makes several network calls (source-product metadata,
|
|
Slack user lookups, chat.postMessage) that must not hold up the reviewer-edit request.
|
|
Best-effort end to end — the dispatcher logs per-destination failures itself.
|
|
"""
|
|
# Imported here rather than at module level: signal_metadata drags insights.schema and
|
|
# insights.insightsql.query onto the django.setup() path (tasks are autodiscovered by celery),
|
|
# which the startup import budget forbids.
|
|
from products.signals.backend.signal_metadata import fetch_source_products_for_reports # noqa: PLC0415
|
|
|
|
team = Team.objects.get(id=team_id)
|
|
# Source products are cosmetic (one metadata line in the message), so a failure fetching
|
|
# them must not suppress the ping itself.
|
|
source_products: list[str] | None = None
|
|
try:
|
|
meta = fetch_source_products_for_reports(team, [report_id]).get(report_id)
|
|
source_products = meta.source_products if meta else None
|
|
except Exception:
|
|
logger.exception(
|
|
"failed to fetch source products for reviewer-added notification",
|
|
report_id=report_id,
|
|
team_id=team_id,
|
|
)
|
|
dispatch_reviewer_added_notifications(
|
|
report_id=report_id,
|
|
team_id=team_id,
|
|
added_github_logins=added_github_logins,
|
|
source_products=source_products,
|
|
exclude_user_id=exclude_user_id,
|
|
)
|
|
|
|
|
|
def _capture_refund_sync_event(refund: SignalReportRefund, event: str, extra: dict[str, object]) -> None:
|
|
organization = refund.team.organization
|
|
with ph_scoped_capture() as capture:
|
|
capture(
|
|
distinct_id=str(organization.id),
|
|
event=event,
|
|
properties={
|
|
"team_id": refund.team_id,
|
|
"organization_id": str(organization.id),
|
|
"report_id": str(refund.report_id),
|
|
"refund_id": str(refund.id),
|
|
"credits": refund.credits,
|
|
**extra,
|
|
},
|
|
groups=groups(organization=organization),
|
|
)
|
|
|
|
|
|
@shared_task(
|
|
name="products.signals.backend.tasks.sync_signals_refund_credit",
|
|
ignore_result=True,
|
|
bind=True,
|
|
max_retries=_REFUND_SYNC_MAX_RETRIES,
|
|
)
|
|
@skip_team_scope_audit
|
|
def sync_signals_refund_credit(self, refund_id: str) -> None:
|
|
"""Report a credited-path refund to the billing dispute endpoint and record the outcome.
|
|
|
|
Crediting an invoice happens in the billing service, an enterprise feature this fork
|
|
does not carry, so there is no dispute endpoint to post to and no credit to apply. The
|
|
refund itself already took effect at acceptance time: the report is archived, the badge
|
|
is set, and the quota slot is freed. Only the invoice credit is unavailable.
|
|
"""
|
|
|
|
|
|
@shared_task(
|
|
name="products.signals.backend.tasks.sync_pending_signals_refund_credits",
|
|
ignore_result=True,
|
|
max_retries=0,
|
|
)
|
|
@skip_team_scope_audit
|
|
def sync_pending_signals_refund_credits() -> None:
|
|
"""Hourly sweeper: re-enqueue credited refunds whose billing sync hasn't landed yet.
|
|
|
|
Catches deliveries lost to worker crashes / deploys and rows whose bounded retries were
|
|
exhausted while billing was down. Only rows younger than the sweep horizon are retried;
|
|
re-enqueueing an already-synced row is a no-op in the task itself.
|
|
"""
|
|
cutoff = timezone.now() - _REFUND_SYNC_SWEEP_MAX_AGE
|
|
pending_ids = list(
|
|
SignalReportRefund.objects.unscoped()
|
|
.filter(
|
|
billing_path=SignalReportRefund.BillingPath.CREDITED,
|
|
billing_synced_at__isnull=True,
|
|
created_at__gte=cutoff,
|
|
)
|
|
# Horizon-lost credits are terminal for automation — replaying returns the same $0.
|
|
.exclude(billing_sync_error=_OUT_OF_PERIOD_SYNC_ERROR)
|
|
.values_list("id", flat=True)
|
|
)
|
|
for refund_id in pending_ids:
|
|
sync_signals_refund_credit.delay(str(refund_id))
|
|
if pending_ids:
|
|
logger.info("signals refund credit sweeper re-enqueued pending refunds", count=len(pending_ids))
|
|
|
|
|
|
_ACTIVITY_ROW_MAX_IDLE = timedelta(days=90)
|
|
# Backstop for a worker dying without releasing the rebuild lock; above the task's
|
|
# time_limit so a live rebuild is never treated as abandoned.
|
|
_REBUILD_LOCK_TTL_SECONDS = 30 * 60
|
|
|
|
|
|
@shared_task(
|
|
name="products.signals.backend.tasks.rebuild_signal_repository_activity",
|
|
ignore_result=True,
|
|
max_retries=0,
|
|
# Sandbox clone (10m cap) + git log (3m cap) + attribution listing; the hard limit
|
|
# backstops a hung sandbox call.
|
|
soft_time_limit=20 * 60,
|
|
time_limit=22 * 60,
|
|
)
|
|
@with_team_scope()
|
|
def rebuild_signal_repository_activity(team_id: int, repository: str, force: bool = False) -> None:
|
|
"""Rebuild one repository's area-activity map from its git history (sandbox clone + git log).
|
|
|
|
Enqueued on cache miss by reviewer resolution and weekly by the sweeper (which passes
|
|
``force`` — its rows sit exactly at the staleness boundary). A failed rebuild leaves
|
|
the cached rows untouched; the next enqueue retries.
|
|
"""
|
|
# Concurrent enqueues for the same (team, repository) are common — several reports can
|
|
# hit the same stale map before the first rebuild lands. The staleness re-check debounces
|
|
# re-triggers over the freshness window; the atomic cache lock closes the remaining race
|
|
# where several queued workers all pass the check before the first rebuild writes.
|
|
if not force and not repository_activity_needs_rebuild(team_id, repository):
|
|
return
|
|
lock_key = f"signals_repo_activity_rebuild/{team_id}/{repository.strip().lower()}"
|
|
if not cache.add(lock_key, "1", timeout=_REBUILD_LOCK_TTL_SECONDS):
|
|
return
|
|
try:
|
|
rebuild_repository_activity(team_id, repository)
|
|
except (RepositoryCommitActivityError, GitHubRateLimitError, GitHubEgressBudgetExhausted) as exc:
|
|
# Expected deferrals (integration gone, rate limit, shed by the egress limiter) —
|
|
# the map stays stale and the next enqueue retries.
|
|
logger.warning(
|
|
"signals repository activity rebuild deferred",
|
|
team_id=team_id,
|
|
repository=repository,
|
|
error=str(exc),
|
|
)
|
|
except Exception as exc:
|
|
capture_exception(exc, {"team_id": team_id, "repository": repository})
|
|
finally:
|
|
cache.delete(lock_key)
|
|
|
|
|
|
@shared_task(
|
|
name="products.signals.backend.tasks.pause_inactive_signal_scouts",
|
|
ignore_result=True,
|
|
max_retries=0,
|
|
)
|
|
@skip_team_scope_audit
|
|
def pause_inactive_signal_scouts() -> None:
|
|
"""Daily sweep: warn, then auto-pause scouts nothing comes of.
|
|
|
|
Runs here rather than on the coordinator's 30-minute tick — that tick is deliberately
|
|
short-lived and bounded, and inactivity doesn't change by the half hour. See
|
|
`scout_harness/inactivity.py` for what counts as productive.
|
|
"""
|
|
outcome = sweep_inactive_scouts()
|
|
logger.info(
|
|
"signals_scout inactivity sweep finished",
|
|
considered=outcome.considered,
|
|
warned=len(outcome.warned),
|
|
paused=len(outcome.paused),
|
|
recovered=outcome.recovered,
|
|
deferred=outcome.deferred,
|
|
)
|
|
if not outcome.warned and not outcome.paused:
|
|
return
|
|
# The fleet's spend was measured from analytics in the first place, so report the sweep the same
|
|
# way — it's how we'll tell whether the pauses are landing on the scouts we meant.
|
|
touched = outcome.warned + outcome.paused
|
|
organizations = {
|
|
team.id: team.organization
|
|
for team in Team.objects.filter(id__in={config.team_id for config in touched}).select_related("organization")
|
|
}
|
|
with ph_scoped_capture() as capture:
|
|
for event, configs in (
|
|
("signals_scout_auto_pause_warned", outcome.warned),
|
|
("signals_scout_auto_paused", outcome.paused),
|
|
):
|
|
for config in configs:
|
|
organization = organizations.get(config.team_id)
|
|
if organization is None:
|
|
continue
|
|
capture(
|
|
distinct_id=str(organization.id),
|
|
event=event,
|
|
properties={
|
|
"team_id": config.team_id,
|
|
"organization_id": str(organization.id),
|
|
"skill_name": config.skill_name,
|
|
"run_interval_minutes": config.run_interval_minutes,
|
|
"pause_reason": config.pause_reason,
|
|
# Splits noisy-but-unconsumed from silent in the read-back; the two
|
|
# cohorts need different fixes (retune vs refocus).
|
|
"had_output": outcome.had_output.get(config.pk),
|
|
},
|
|
groups=groups(organization=organization),
|
|
)
|
|
|
|
|
|
@shared_task(
|
|
name="products.signals.backend.tasks.refresh_signal_repository_activity",
|
|
ignore_result=True,
|
|
max_retries=0,
|
|
)
|
|
@skip_team_scope_audit
|
|
def refresh_signal_repository_activity() -> None:
|
|
"""Weekly (Monday) warm-up: enqueue a rebuild for every recently-used repository map."""
|
|
now = timezone.now()
|
|
SignalRepositoryAreaActivity.objects.unscoped().filter(
|
|
last_used_at__lt=now - _ACTIVITY_ROW_MAX_IDLE
|
|
).delete() # nosemgrep: idor-lookup-without-team (system Celery sweeper, no user input; unscoped is the sanctioned cross-team access)
|
|
|
|
repositories = list(
|
|
SignalRepositoryAreaActivity.objects.unscoped() # nosemgrep: idor-lookup-without-team (system Celery sweeper, no user input; unscoped is the sanctioned cross-team access)
|
|
.filter(last_used_at__gte=now - ACTIVITY_KEEP_WARM_WINDOW)
|
|
.values_list("team_id", "repository")
|
|
.distinct()
|
|
.order_by("team_id", "repository")
|
|
)
|
|
for team_id, repository in repositories:
|
|
rebuild_signal_repository_activity.delay(team_id=team_id, repository=repository, force=True)
|
|
if repositories:
|
|
logger.info("signals repository activity refresh enqueued rebuilds", count=len(repositories))
|