detectkit 0.53.2__tar.gz → 0.54.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {detectkit-0.53.2/detectkit.egg-info → detectkit-0.54.0}/PKG-INFO +1 -1
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/__init__.py +1 -1
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/alerting/channels/base.py +27 -2
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/alerting/orchestrator/_base.py +7 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/alerting/orchestrator/_decision.py +17 -2
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/alerting/orchestrator/_recovery.py +1 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/cli/assets/claude/rules/alerting.md +4 -1
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/cli/assets/claude/rules/cli.md +4 -1
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/cli/assets/claude/rules/metrics.md +11 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/cli/assets/claude/rules/overview.md +4 -3
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/cli/assets/claude/rules/project.md +8 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +6 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/cli/commands/autotune.py +5 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/cli/commands/run.py +3 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/config/metric_config.py +75 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/config/project_config.py +25 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/orchestration/task_manager/_alert_step.py +5 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/orchestration/task_manager/_base.py +12 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/orchestration/task_manager/_load_step.py +19 -2
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/reporting/builder.py +19 -3
- detectkit-0.54.0/detectkit/tuning/assets/tune.js +187 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/ui/assets/ui.js +4 -4
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/ui/overview.py +29 -2
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/ui/server.py +1 -0
- {detectkit-0.53.2 → detectkit-0.54.0/detectkit.egg-info}/PKG-INFO +1 -1
- detectkit-0.53.2/detectkit/tuning/assets/tune.js +0 -187
- {detectkit-0.53.2 → detectkit-0.54.0}/LICENSE +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/MANIFEST.in +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/README.md +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/alerting/__init__.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/alerting/channels/__init__.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/alerting/channels/branding.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/alerting/channels/email.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/alerting/channels/factory.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/alerting/channels/mattermost.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/alerting/channels/slack.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/alerting/channels/telegram.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/alerting/channels/webhook.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/alerting/orchestrator/_replay.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/alerting/orchestrator/_types.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/autotune/__init__.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/autotune/_base.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/autotune/_types.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/autotune/autotuner.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/autotune/axis_spec.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/autotune/config_emitter.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/autotune/crossval.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/autotune/detector_select.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/autotune/distribution.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/autotune/grid_search.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/autotune/labels.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/autotune/result.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/autotune/runner.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/autotune/scoring.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/autotune/seasonality_search.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/autotune/settings.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/autotune/window_select.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/cli/__init__.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/cli/_output.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/cli/assets/claude/CLAUDE.section.md +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/cli/assets/claude/rules/autotune.md +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/cli/assets/claude/skills/dtk-tune/SKILL.md +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/cli/commands/__init__.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/cli/commands/clean.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/cli/commands/init.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/cli/commands/init_claude.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/cli/commands/osi.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/cli/commands/test_alert.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/cli/commands/tune.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/cli/commands/ui.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/cli/commands/unlock.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/cli/main.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/config/__init__.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/config/metric_io.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/config/profile.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/config/validator.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/core/__init__.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/core/interval.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/core/models.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/database/__init__.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/database/_sql_manager.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/database/clickhouse_manager.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/database/internal_tables/__init__.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/database/internal_tables/_base.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/database/internal_tables/_detections.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/database/internal_tables/_metrics.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/database/internal_tables/_schema.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/database/internal_tables/_tasks.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/database/internal_tables/manager.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/database/manager.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/database/mysql_manager.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/database/postgres_manager.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/database/tables.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/detectors/__init__.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/detectors/base.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/detectors/factory.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/detectors/seasonality.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/detectors/statistical/__init__.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/detectors/statistical/_windowed.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/detectors/statistical/autoreg.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/detectors/statistical/iqr.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/detectors/statistical/mad.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/detectors/statistical/zscore.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/loaders/__init__.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/loaders/metric_loader.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/loaders/query_template.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/orchestration/__init__.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/orchestration/error_dispatch.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/orchestration/task_manager/_types.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/orchestration/task_manager/manager.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/reporting/__init__.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/reporting/assets/report.js +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/reporting/html_report.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/semantic/__init__.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/semantic/errors.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/semantic/exporter.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/semantic/importer.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/semantic/osi_model.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/semantic/query_gen.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/tuning/__init__.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/tuning/config_writer.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/tuning/html.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/tuning/payload.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/tuning/server.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/ui/__init__.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/ui/html.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/ui/jobs.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/ui/metric_files.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/utils/__init__.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/utils/datetime_utils.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/utils/env_interpolation.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/utils/json_utils.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit/utils/stats.py +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit.egg-info/SOURCES.txt +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit.egg-info/dependency_links.txt +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit.egg-info/entry_points.txt +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit.egg-info/requires.txt +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/detectkit.egg-info/top_level.txt +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/pyproject.toml +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/requirements.txt +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/setup.cfg +0 -0
- {detectkit-0.53.2 → detectkit-0.54.0}/setup.py +0 -0
|
@@ -4,7 +4,7 @@ detectk - Anomaly Detection for Time-Series Metrics
|
|
|
4
4
|
A Python library for data analysts and engineers to monitor metrics with automatic anomaly detection.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
__version__ = "0.
|
|
7
|
+
__version__ = "0.54.0"
|
|
8
8
|
|
|
9
9
|
from detectkit.core.interval import Interval
|
|
10
10
|
from detectkit.core.models import ColumnDefinition, TableModel
|
|
@@ -121,6 +121,13 @@ class AlertData:
|
|
|
121
121
|
window_matched: int | None = None
|
|
122
122
|
min_anomaly_share: float | None = None
|
|
123
123
|
fired_by_share: bool = False
|
|
124
|
+
# Data-maturity delay (``loading_delay``, resolved metric → project). Exposed
|
|
125
|
+
# to message templates as the OPT-IN ``{data_delay_display}`` /
|
|
126
|
+
# ``{data_delay_line}`` variables only — the default rendering deliberately
|
|
127
|
+
# does NOT show it, so existing alerts stay byte-identical. Lets a custom
|
|
128
|
+
# template disclose that every timestamp in the message deliberately trails
|
|
129
|
+
# wall-clock by the configured delay. ``None``/0 renders unchanged.
|
|
130
|
+
loading_delay_seconds: int | None = None
|
|
124
131
|
|
|
125
132
|
|
|
126
133
|
def format_rule_display(
|
|
@@ -463,8 +470,10 @@ class BaseAlertChannel(ABC):
|
|
|
463
470
|
# "Alert fired" — the on-grid moment the rule's consecutive threshold was
|
|
464
471
|
# first met: onset + (consecutive_required - 1) * interval. Recovery
|
|
465
472
|
# messages render it as the middle of the "anomaly began → alert fired →
|
|
466
|
-
# recovered" timeline so a stakeholder can tell the onset apart from
|
|
467
|
-
#
|
|
473
|
+
# recovered" timeline so a stakeholder can tell the onset apart from the
|
|
474
|
+
# moment the rule tripped. It is a data-grid instant, not a dispatch
|
|
475
|
+
# timestamp: with a ``loading_delay`` (and run cadence) the actual
|
|
476
|
+
# notification goes out later. Skipped when the run is capped (onset is
|
|
468
477
|
# only a lower bound, so the fire time is unknown) or timing isn't wired
|
|
469
478
|
# in (direct-API callers). The firing message doesn't show it: there it
|
|
470
479
|
# coincides with the latest point the alert is firing on.
|
|
@@ -520,6 +529,20 @@ class BaseAlertChannel(ABC):
|
|
|
520
529
|
synonyms = ", ".join(alert_data.ai_synonyms) if alert_data.ai_synonyms else ""
|
|
521
530
|
synonyms_line = f"Also known as: {synonyms}\n" if synonyms else ""
|
|
522
531
|
|
|
532
|
+
# Data-maturity delay (``loading_delay``) — OPT-IN template variables
|
|
533
|
+
# only (``{data_delay_display}`` / ``{data_delay_line}``), same contract
|
|
534
|
+
# as ``{synonyms_line}``: the default templates never reference them, so
|
|
535
|
+
# every existing alert renders byte-identical. A custom template can
|
|
536
|
+
# disclose that the message's timestamps deliberately trail wall-clock.
|
|
537
|
+
delay_seconds = alert_data.loading_delay_seconds or 0
|
|
538
|
+
data_delay_display = format_duration(delay_seconds) if delay_seconds else ""
|
|
539
|
+
data_delay_line = (
|
|
540
|
+
f"Data maturity delay: {data_delay_display} "
|
|
541
|
+
"(timestamps intentionally trail real time)\n"
|
|
542
|
+
if delay_seconds
|
|
543
|
+
else ""
|
|
544
|
+
)
|
|
545
|
+
|
|
523
546
|
# Format mentions
|
|
524
547
|
mentions_str = self.format_mentions(alert_data.mentions)
|
|
525
548
|
mentions_line = f"\n{mentions_str}" if mentions_str else ""
|
|
@@ -605,6 +628,8 @@ class BaseAlertChannel(ABC):
|
|
|
605
628
|
"description_line": description_line,
|
|
606
629
|
"synonyms": synonyms,
|
|
607
630
|
"synonyms_line": synonyms_line,
|
|
631
|
+
"data_delay_display": data_delay_display,
|
|
632
|
+
"data_delay_line": data_delay_line,
|
|
608
633
|
"dashboard_url": dashboard_url,
|
|
609
634
|
"dashboard_line": dashboard_line,
|
|
610
635
|
"help_url": help_url,
|
|
@@ -37,6 +37,7 @@ class _OrchestratorBase:
|
|
|
37
37
|
project_name: str | None = None,
|
|
38
38
|
help_url: str | None = None,
|
|
39
39
|
ai_synonyms: list[str] | None = None,
|
|
40
|
+
loading_delay_seconds: int = 0,
|
|
40
41
|
):
|
|
41
42
|
self.metric_name = metric_name
|
|
42
43
|
self.interval = interval
|
|
@@ -62,6 +63,12 @@ class _OrchestratorBase:
|
|
|
62
63
|
# every AlertData so channels can render an "Also known as" identity line.
|
|
63
64
|
# Empty list when the metric has no ai_context — renders unchanged.
|
|
64
65
|
self.ai_synonyms = ai_synonyms or []
|
|
66
|
+
# Resolved data-maturity delay (``loading_delay``, metric → project → 0).
|
|
67
|
+
# Constructor state — not a per-call argument — so every internal
|
|
68
|
+
# ``get_last_complete_point()`` call site (the no-data check in the alert
|
|
69
|
+
# step AND the recovery mixin's own fetch bound) computes the same
|
|
70
|
+
# delay-aware boundary as the load step's maturity cut-off.
|
|
71
|
+
self.loading_delay_seconds = int(loading_delay_seconds or 0)
|
|
65
72
|
|
|
66
73
|
@staticmethod
|
|
67
74
|
def _group_by_timestamp(
|
|
@@ -410,6 +410,7 @@ class _DecisionMixin(_OrchestratorBase):
|
|
|
410
410
|
min_anomaly_share=self.conditions.min_anomaly_share,
|
|
411
411
|
window_matched=window_matched,
|
|
412
412
|
fired_by_share=fired_by_share,
|
|
413
|
+
loading_delay_seconds=self.loading_delay_seconds or None,
|
|
413
414
|
)
|
|
414
415
|
|
|
415
416
|
def should_alert_no_data(
|
|
@@ -467,15 +468,29 @@ class _DecisionMixin(_OrchestratorBase):
|
|
|
467
468
|
project_name=self.project_name,
|
|
468
469
|
help_url=self.help_url,
|
|
469
470
|
interval_seconds=self.interval.seconds,
|
|
471
|
+
loading_delay_seconds=self.loading_delay_seconds or None,
|
|
470
472
|
)
|
|
471
473
|
|
|
472
474
|
def get_last_complete_point(self, now: datetime | None = None) -> datetime:
|
|
473
|
-
"""Floor ``now`` to the previous fully completed interval boundary.
|
|
475
|
+
"""Floor ``now`` to the previous fully completed *mature* interval boundary.
|
|
476
|
+
|
|
477
|
+
With a ``loading_delay`` configured, the loader only trusts
|
|
478
|
+
``[t, t+interval)`` once ``now >= t + interval + delay`` — so the same
|
|
479
|
+
delay shifts the effective now back here BEFORE flooring, keeping the
|
|
480
|
+
no-data expectation (an exact-timestamp lookup) in lockstep with what
|
|
481
|
+
the load step has actually had a chance to persist. Without this, a
|
|
482
|
+
delayed metric would fire a false no-data alert on every run while the
|
|
483
|
+
newest interval is deliberately still in flight.
|
|
484
|
+
"""
|
|
474
485
|
if now is None:
|
|
475
486
|
now = now_utc()
|
|
476
487
|
now = to_aware_utc(now)
|
|
477
488
|
|
|
478
489
|
interval_seconds = self.interval.seconds
|
|
479
|
-
|
|
490
|
+
# Shift the effective now back by the maturity delay BEFORE flooring
|
|
491
|
+
# (subtract-then-floor keeps the boundary correct for delays that
|
|
492
|
+
# aren't a multiple of the interval).
|
|
493
|
+
effective_epoch = int(now.timestamp()) - self.loading_delay_seconds
|
|
494
|
+
floored = (effective_epoch // interval_seconds) * interval_seconds
|
|
480
495
|
last_complete = floored - interval_seconds
|
|
481
496
|
return datetime.fromtimestamp(last_complete, tz=timezone.utc)
|
|
@@ -169,7 +169,10 @@ interval) has no datapoint, or the row's value is `NULL`/`NaN`. `min_detectors`
|
|
|
169
169
|
and `consecutive_anomalies` do **not** apply (it's a single binary signal).
|
|
170
170
|
Honors `alert_cooldown` and `suppress_until`. Webhook channels render it amber.
|
|
171
171
|
Use for cron loaders where source absence is a real failure; **don't** enable on
|
|
172
|
-
naturally sparse metrics.
|
|
172
|
+
naturally sparse metrics. With a `loading_delay` configured (`metrics.md`), the
|
|
173
|
+
"last complete interval" shifts back by that same delay, so the deliberately
|
|
174
|
+
withheld newest interval never trips a false no-data alert while it's still in
|
|
175
|
+
flight upstream.
|
|
173
176
|
|
|
174
177
|
## Temporary suppression
|
|
175
178
|
|
|
@@ -49,7 +49,10 @@ dtk run --select <sel> [--steps load,detect,alert] [--from DATE] [--to DATE] \
|
|
|
49
49
|
query), `--steps detect` (rerun detection only), `--steps detect,alert` (skip load).
|
|
50
50
|
- `--from DATE` / `--to DATE` — `YYYY-MM-DD` or `YYYY-MM-DD HH:MM:SS`, UTC.
|
|
51
51
|
Affects only the `load` step. `--from` overrides the metric's
|
|
52
|
-
`loading_start_time`; `--to` defaults to now.
|
|
52
|
+
`loading_start_time`; `--to` defaults to now. An explicit `--to` bypasses a
|
|
53
|
+
configured `loading_delay` (trusted verbatim); to fix a bucket already
|
|
54
|
+
loaded before `loading_delay` was set, `dtk run --from <date before it>`
|
|
55
|
+
reloads and overwrites it.
|
|
53
56
|
- `--full-refresh` — **destructive**: deletes the metric's datapoints and
|
|
54
57
|
detections, then reloads from `loading_start_time`/`--from`. Use after
|
|
55
58
|
changing the query or to recompute detections over history.
|
|
@@ -38,6 +38,7 @@ query_columns: # optional — map query columns to internal name
|
|
|
38
38
|
|
|
39
39
|
loading_start_time: "2024-01-01 00:00:00" # optional — initial load start (UTC)
|
|
40
40
|
loading_batch_size: 2160 # optional — points per load batch
|
|
41
|
+
loading_delay: "10min" # optional — data-maturity delay (see below)
|
|
41
42
|
|
|
42
43
|
seasonality_columns: [hour, day_of_week] # optional — auto-extracted features
|
|
43
44
|
|
|
@@ -129,6 +130,16 @@ columns and bakes the best grouping into the tuned config (see `autotune.md`).
|
|
|
129
130
|
saved timestamp and this is ignored.
|
|
130
131
|
- `loading_batch_size` is the number of points loaded per batch (rule of thumb:
|
|
131
132
|
7–30 days of points). E.g. 10-min interval → `2160` ≈ 15 days.
|
|
133
|
+
- `loading_delay` (duration string or seconds) withholds the newest interval
|
|
134
|
+
until `now >= interval_end + loading_delay`, so a source that finishes
|
|
135
|
+
writing *after* the interval closes (a dbt model, say) never gets a
|
|
136
|
+
partial bucket persisted forever (load only resumes forward). The no-data
|
|
137
|
+
alert expectation shifts back in lockstep. Resolves **metric → project →
|
|
138
|
+
0** (`project.md`); `loading_delay: 0` on the metric opts out of a
|
|
139
|
+
project-wide default. Only affects the implicit "now" bound — an explicit
|
|
140
|
+
`--to` bypasses it. Trade-off: every second of delay adds the same to
|
|
141
|
+
real-outage detection time, and it reduces but doesn't eliminate the race
|
|
142
|
+
(repair a bucket that slipped through with `dtk run --from <date>`).
|
|
132
143
|
|
|
133
144
|
## Editing a metric that already has data
|
|
134
145
|
|
|
@@ -103,9 +103,10 @@ series, watch the band recompute live, then write the config back into the metri
|
|
|
103
103
|
|
|
104
104
|
For a **live**, project-wide view instead of a one-off file, run `dtk ui`: the
|
|
105
105
|
same reports in a browser overlay plus an overview table (alert frequency,
|
|
106
|
-
freshness
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
freshness — netting out any configured `loading_delay` so a deliberately
|
|
107
|
+
delayed metric doesn't read as stale — quality when incidents are labeled), a
|
|
108
|
+
panel that drives `dtk run` / `dtk autotune` / `dtk unlock`, and an in-browser
|
|
109
|
+
editor to create, edit and delete metric YAML files. See `cli.md`.
|
|
109
110
|
|
|
110
111
|
## Glossary
|
|
111
112
|
|
|
@@ -32,6 +32,7 @@ timeouts: # per-step, seconds
|
|
|
32
32
|
|
|
33
33
|
alert_help_url: null # optional, see below — "How to read this alert" link
|
|
34
34
|
false_alert_budget: null # optional — `dtk tune` target FDR (0,1]; per-metric override wins
|
|
35
|
+
loading_delay: null # optional — default data-maturity delay; per-metric override wins
|
|
35
36
|
|
|
36
37
|
error_alerting: # optional, see below
|
|
37
38
|
enabled: false
|
|
@@ -42,6 +43,13 @@ error_alerting: # optional, see below
|
|
|
42
43
|
metric when its false-alert rate exceeds it. A per-metric `false_alert_budget`
|
|
43
44
|
overrides it; unset → a built-in `0.5`. Tuning-only — it never touches the pipeline.
|
|
44
45
|
|
|
46
|
+
`loading_delay` is a project-wide default **data-maturity delay** (duration string
|
|
47
|
+
or seconds), useful when every metric reads from one upstream pipeline that
|
|
48
|
+
finishes a few minutes after each interval closes. A per-metric `loading_delay`
|
|
49
|
+
overrides it (`0` opts that metric out); see `metrics.md` for the full
|
|
50
|
+
load/no-data behavior and the detection-latency trade-off. Only set this
|
|
51
|
+
project-wide when your metrics genuinely share the same upstream schedule.
|
|
52
|
+
|
|
45
53
|
### `alert_help_url` — "How to read this alert" link
|
|
46
54
|
|
|
47
55
|
Every default-rendered alert on every channel carries a `How to read this alert`
|
{detectkit-0.53.2 → detectkit-0.54.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md
RENAMED
|
@@ -62,6 +62,12 @@ Use `{{ interval_seconds }}` if you need to bucket
|
|
|
62
62
|
For long/complex SQL, offer to put it in `sql/<name>.sql` and use `query_file:`
|
|
63
63
|
instead of inline `query:`.
|
|
64
64
|
|
|
65
|
+
Also ask: **does this source finish writing after the interval closes** (a dbt
|
|
66
|
+
model or other batch job that lands a few minutes late)? If so, set
|
|
67
|
+
`loading_delay` (e.g. `"10min"`) so `dtk run` doesn't load and permanently
|
|
68
|
+
persist a partially-written bucket right at the boundary — see `metrics.md`
|
|
69
|
+
for the full behavior and its detection-latency trade-off.
|
|
70
|
+
|
|
65
71
|
## Step 3 — Interval
|
|
66
72
|
|
|
67
73
|
Ask for the point spacing and set `interval` (`"1min"`, `"5min"`, `"10min"`,
|
|
@@ -275,6 +275,7 @@ def run_autotune(
|
|
|
275
275
|
dry_run=dry_run,
|
|
276
276
|
report_path=report_path,
|
|
277
277
|
project_name=project_name,
|
|
278
|
+
project_loading_delay=getattr(_project_config, "loading_delay", None),
|
|
278
279
|
)
|
|
279
280
|
if ok:
|
|
280
281
|
succeeded += 1
|
|
@@ -296,6 +297,7 @@ def _tune_one(
|
|
|
296
297
|
dry_run: bool,
|
|
297
298
|
report_path: str | None = None,
|
|
298
299
|
project_name: str | None = None,
|
|
300
|
+
project_loading_delay: str | int | None = None,
|
|
299
301
|
) -> bool:
|
|
300
302
|
"""Tune one metric end to end; return True on success."""
|
|
301
303
|
name = config.name
|
|
@@ -370,6 +372,7 @@ def _tune_one(
|
|
|
370
372
|
config=config,
|
|
371
373
|
report_path=report_path,
|
|
372
374
|
project_name=project_name,
|
|
375
|
+
project_loading_delay=project_loading_delay,
|
|
373
376
|
)
|
|
374
377
|
internal_manager.release_lock(name, "pipeline", "pipeline", status="completed")
|
|
375
378
|
return True
|
|
@@ -406,6 +409,7 @@ def _finalize(
|
|
|
406
409
|
config: MetricConfig | None = None,
|
|
407
410
|
report_path: str | None = None,
|
|
408
411
|
project_name: str | None = None,
|
|
412
|
+
project_loading_delay: str | int | None = None,
|
|
409
413
|
) -> None:
|
|
410
414
|
"""Persist run + winner detections + tuned config, prune prior winners, render RESULT."""
|
|
411
415
|
folds = " ".join(f"{f:.2f}" for f in result.cv_per_fold) or "—"
|
|
@@ -486,6 +490,7 @@ def _finalize(
|
|
|
486
490
|
end=end,
|
|
487
491
|
project_name=project_name,
|
|
488
492
|
generated_at=now_utc_naive().strftime("%Y-%m-%d %H:%M UTC"),
|
|
493
|
+
project_loading_delay=project_loading_delay,
|
|
489
494
|
)
|
|
490
495
|
if payload["points"]:
|
|
491
496
|
report_out = _resolve_report_path(report_path, project_root, out_path.stem)
|
|
@@ -245,6 +245,7 @@ def run_command(
|
|
|
245
245
|
internal_manager=internal_manager,
|
|
246
246
|
report_path=report_path,
|
|
247
247
|
project_name=getattr(project_config, "name", None),
|
|
248
|
+
project_loading_delay=getattr(project_config, "loading_delay", None),
|
|
248
249
|
from_dt=from_dt,
|
|
249
250
|
to_dt=to_dt,
|
|
250
251
|
)
|
|
@@ -275,6 +276,7 @@ def emit_metric_report(
|
|
|
275
276
|
project_name: str | None,
|
|
276
277
|
from_dt: datetime | None,
|
|
277
278
|
to_dt: datetime | None,
|
|
279
|
+
project_loading_delay: str | int | None = None,
|
|
278
280
|
) -> None:
|
|
279
281
|
"""Build and write the HTML report for one metric (best-effort)."""
|
|
280
282
|
from detectkit.reporting import build_report_payload, render_report_html
|
|
@@ -287,6 +289,7 @@ def emit_metric_report(
|
|
|
287
289
|
end=to_dt,
|
|
288
290
|
project_name=project_name,
|
|
289
291
|
generated_at=now_utc_naive().strftime("%Y-%m-%d %H:%M UTC"),
|
|
292
|
+
project_loading_delay=project_loading_delay,
|
|
290
293
|
)
|
|
291
294
|
if not payload["points"]:
|
|
292
295
|
click.echo(" │ Report: no datapoints in window, skipped")
|
|
@@ -4,6 +4,7 @@ Metric configuration models.
|
|
|
4
4
|
Defines configuration structure for individual metrics loaded from YAML files.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
import re
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
from typing import Any
|
|
9
10
|
|
|
@@ -696,6 +697,23 @@ class MetricConfig(BaseModel):
|
|
|
696
697
|
default_factory=list, description="Seasonality features to extract"
|
|
697
698
|
)
|
|
698
699
|
loading_batch_size: int = Field(default=10000, description="Batch size for loading")
|
|
700
|
+
# Data-maturity delay: how long after an interval closes the upstream source
|
|
701
|
+
# (e.g. a dbt model) needs before its data for that interval is complete.
|
|
702
|
+
# The loader treats [t, t+interval) as loadable only once
|
|
703
|
+
# now >= t + interval + loading_delay, and the no-data alert expectation
|
|
704
|
+
# shifts in lockstep — so a run scheduled right after the interval boundary
|
|
705
|
+
# never persists a partially-written bucket (which would otherwise stay
|
|
706
|
+
# wrong forever and skew trailing windows). Overrides the project-wide
|
|
707
|
+
# ``loading_delay``; an explicit 0 opts a metric out of the project default.
|
|
708
|
+
loading_delay: str | int | None = Field(
|
|
709
|
+
default=None,
|
|
710
|
+
description=(
|
|
711
|
+
"Data-maturity delay before the newest interval is trusted as "
|
|
712
|
+
"complete (duration string like '10min' or seconds). Use when the "
|
|
713
|
+
"upstream table finishes writing after the interval closes; "
|
|
714
|
+
"overrides the project-wide loading_delay, 0 opts out."
|
|
715
|
+
),
|
|
716
|
+
)
|
|
699
717
|
detectors: list[DetectorConfig] = Field(
|
|
700
718
|
default_factory=list, description="Detector configurations"
|
|
701
719
|
)
|
|
@@ -845,6 +863,12 @@ class MetricConfig(BaseModel):
|
|
|
845
863
|
|
|
846
864
|
return v
|
|
847
865
|
|
|
866
|
+
@field_validator("loading_delay")
|
|
867
|
+
@classmethod
|
|
868
|
+
def validate_loading_delay(cls, v: str | int | None) -> str | int | None:
|
|
869
|
+
"""A delay, if set, must parse as a duration (any zero form = opt out)."""
|
|
870
|
+
return validate_loading_delay_value(v)
|
|
871
|
+
|
|
848
872
|
@field_validator("loading_batch_size")
|
|
849
873
|
@classmethod
|
|
850
874
|
def validate_batch_size(cls, v: int) -> int:
|
|
@@ -976,3 +1000,54 @@ class MetricConfig(BaseModel):
|
|
|
976
1000
|
data = unwrap_metric_mapping(data)
|
|
977
1001
|
|
|
978
1002
|
return cls.model_validate(data)
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def loading_delay_to_seconds(value: str | int) -> int:
|
|
1006
|
+
"""Seconds for one ``loading_delay`` value.
|
|
1007
|
+
|
|
1008
|
+
Any zero-valued form — ``0``, ``"0"``, ``"0s"``, ``"0min"`` — means
|
|
1009
|
+
"no delay" (the explicit opt-out from a project-wide default).
|
|
1010
|
+
``Interval`` itself rejects zero, so the zero forms are special-cased
|
|
1011
|
+
before parsing; everything else must parse as a positive duration.
|
|
1012
|
+
"""
|
|
1013
|
+
if isinstance(value, int):
|
|
1014
|
+
return 0 if value == 0 else Interval(value).seconds
|
|
1015
|
+
if re.fullmatch(r"0+\s*[A-Za-z]*", str(value).strip()):
|
|
1016
|
+
return 0
|
|
1017
|
+
return Interval(value).seconds
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def validate_loading_delay_value(v: str | int | None) -> str | int | None:
|
|
1021
|
+
"""Shared ``loading_delay`` field-validator body (MetricConfig + ProjectConfig).
|
|
1022
|
+
|
|
1023
|
+
One implementation so the accepted grammar and the error message can't
|
|
1024
|
+
drift between the metric-level and project-level fields.
|
|
1025
|
+
"""
|
|
1026
|
+
if v is None:
|
|
1027
|
+
return v
|
|
1028
|
+
try:
|
|
1029
|
+
loading_delay_to_seconds(v)
|
|
1030
|
+
except (ValueError, TypeError) as exc:
|
|
1031
|
+
raise ValueError(
|
|
1032
|
+
f"loading_delay must be a duration string ('10min', '1h') or "
|
|
1033
|
+
f"seconds (0 opts out of a project-level delay): {exc}"
|
|
1034
|
+
) from exc
|
|
1035
|
+
return v
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
def resolve_loading_delay_seconds(
|
|
1039
|
+
metric_delay: str | int | None, project_delay: str | int | None
|
|
1040
|
+
) -> int:
|
|
1041
|
+
"""Resolve the effective data-maturity delay: metric → project → 0.
|
|
1042
|
+
|
|
1043
|
+
The first configured value wins, so a per-metric ``loading_delay: 0``
|
|
1044
|
+
(or ``"0"`` / ``"0min"``) explicitly opts that metric out of a
|
|
1045
|
+
project-wide delay. Shared by the load step, the alert step (no-data
|
|
1046
|
+
expectation), alert replay and the ``dtk ui`` overview so every consumer
|
|
1047
|
+
agrees on the same maturity cut-off.
|
|
1048
|
+
"""
|
|
1049
|
+
for value in (metric_delay, project_delay):
|
|
1050
|
+
if value is None:
|
|
1051
|
+
continue
|
|
1052
|
+
return loading_delay_to_seconds(value)
|
|
1053
|
+
return 0
|
|
@@ -210,6 +210,31 @@ class ProjectConfig(BaseModel):
|
|
|
210
210
|
"`false_alert_budget` takes priority; unset → a built-in default."
|
|
211
211
|
),
|
|
212
212
|
)
|
|
213
|
+
# Project-wide default data-maturity delay. Useful when every metric reads
|
|
214
|
+
# from the same upstream pipeline (e.g. one dbt project that finishes a few
|
|
215
|
+
# minutes after each interval closes). A per-metric ``loading_delay``
|
|
216
|
+
# overrides it; ``loading_delay: 0`` on a metric opts that metric out.
|
|
217
|
+
# Only sensible project-wide when the metrics genuinely share the upstream
|
|
218
|
+
# schedule — otherwise set it per metric.
|
|
219
|
+
loading_delay: str | int | None = Field(
|
|
220
|
+
default=None,
|
|
221
|
+
description=(
|
|
222
|
+
"Default data-maturity delay before the newest interval is trusted "
|
|
223
|
+
"as complete (duration string like '10min' or seconds). Per-metric "
|
|
224
|
+
"loading_delay overrides it; 0 on a metric opts out."
|
|
225
|
+
),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
@field_validator("loading_delay")
|
|
229
|
+
@classmethod
|
|
230
|
+
def validate_loading_delay(cls, v: "str | int | None") -> "str | int | None":
|
|
231
|
+
"""A delay, if set, must parse as a duration (any zero form = no delay)."""
|
|
232
|
+
# Shared with MetricConfig.loading_delay so the accepted grammar and
|
|
233
|
+
# error message can't drift between the two levels. Imported lazily to
|
|
234
|
+
# keep this module import-light (no cycle exists either way).
|
|
235
|
+
from detectkit.config.metric_config import validate_loading_delay_value
|
|
236
|
+
|
|
237
|
+
return validate_loading_delay_value(v)
|
|
213
238
|
|
|
214
239
|
@field_validator("false_alert_budget")
|
|
215
240
|
@classmethod
|
|
@@ -29,6 +29,10 @@ class _AlertStepMixin(_TaskManagerBase):
|
|
|
29
29
|
|
|
30
30
|
interval = config.get_interval()
|
|
31
31
|
multi = len(active_configs) > 1
|
|
32
|
+
# Same maturity cut-off as the load step: the no-data expectation (and
|
|
33
|
+
# every other last-complete-point computation) must not run ahead of
|
|
34
|
+
# what load has had a chance to persist under a loading_delay.
|
|
35
|
+
loading_delay_seconds = self._loading_delay_seconds(config)
|
|
32
36
|
|
|
33
37
|
for i, alerting_config in enumerate(active_configs):
|
|
34
38
|
if multi:
|
|
@@ -70,6 +74,7 @@ class _AlertStepMixin(_TaskManagerBase):
|
|
|
70
74
|
links=alerting_config.links,
|
|
71
75
|
project_name=getattr(self.project_config, "name", None),
|
|
72
76
|
help_url=help_url,
|
|
77
|
+
loading_delay_seconds=loading_delay_seconds,
|
|
73
78
|
)
|
|
74
79
|
|
|
75
80
|
last_point = orchestrator.get_last_complete_point()
|
|
@@ -7,6 +7,7 @@ from datetime import datetime
|
|
|
7
7
|
from detectkit.alerting.channels.base import BaseAlertChannel
|
|
8
8
|
from detectkit.alerting.channels.factory import AlertChannelFactory
|
|
9
9
|
from detectkit.alerting.orchestrator import DetectionRecord, hydrate_detection_records
|
|
10
|
+
from detectkit.config.metric_config import MetricConfig, resolve_loading_delay_seconds
|
|
10
11
|
from detectkit.database.internal_tables import InternalTablesManager
|
|
11
12
|
|
|
12
13
|
|
|
@@ -28,6 +29,17 @@ class _TaskManagerBase:
|
|
|
28
29
|
# per run. Abort propagation is signalled via result["abort_run"].
|
|
29
30
|
self._error_alert_sent_in_run = False
|
|
30
31
|
|
|
32
|
+
def _loading_delay_seconds(self, config: MetricConfig) -> int:
|
|
33
|
+
"""Effective data-maturity delay for *config* (metric → project → 0).
|
|
34
|
+
|
|
35
|
+
One resolution seam for the load step's end bound and the alert
|
|
36
|
+
step's no-data expectation, so the two can't drift apart.
|
|
37
|
+
"""
|
|
38
|
+
return resolve_loading_delay_seconds(
|
|
39
|
+
config.loading_delay,
|
|
40
|
+
getattr(self.project_config, "loading_delay", None),
|
|
41
|
+
)
|
|
42
|
+
|
|
31
43
|
def _load_recent_detections(
|
|
32
44
|
self,
|
|
33
45
|
metric_name: str,
|
|
@@ -9,7 +9,7 @@ import click
|
|
|
9
9
|
from detectkit.config.metric_config import MetricConfig
|
|
10
10
|
from detectkit.loaders.metric_loader import MetricLoader
|
|
11
11
|
from detectkit.orchestration.task_manager._base import _TaskManagerBase
|
|
12
|
-
from detectkit.utils.datetime_utils import now_utc_naive, to_naive_utc
|
|
12
|
+
from detectkit.utils.datetime_utils import format_duration, now_utc_naive, to_naive_utc
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
class _LoadStepMixin(_TaskManagerBase):
|
|
@@ -57,7 +57,24 @@ class _LoadStepMixin(_TaskManagerBase):
|
|
|
57
57
|
"Please specify from_date or set loading_start_time in config."
|
|
58
58
|
)
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
if actual_to is not None:
|
|
61
|
+
actual_to = to_naive_utc(actual_to)
|
|
62
|
+
else:
|
|
63
|
+
# Data-maturity delay (loading_delay): shift the effective "now"
|
|
64
|
+
# back BEFORE the interval snap below, so [t, t+I) is only loaded
|
|
65
|
+
# once now >= t + I + delay — a bucket the upstream ETL is still
|
|
66
|
+
# writing is withheld instead of being persisted half-full forever
|
|
67
|
+
# (resume never re-reads it). Subtract-then-snap is what keeps the
|
|
68
|
+
# boundary on the metric's grid for delays that aren't a multiple
|
|
69
|
+
# of the interval; snapping first and subtracting after would land
|
|
70
|
+
# off-grid. An explicit --to is trusted verbatim (no delay).
|
|
71
|
+
delay_seconds = self._loading_delay_seconds(config)
|
|
72
|
+
actual_to = now_utc_naive() - timedelta(seconds=delay_seconds)
|
|
73
|
+
if delay_seconds:
|
|
74
|
+
click.echo(
|
|
75
|
+
f" │ Holding back {format_duration(delay_seconds)} "
|
|
76
|
+
"for data maturity (loading_delay)"
|
|
77
|
+
)
|
|
61
78
|
actual_from = to_naive_utc(actual_from)
|
|
62
79
|
|
|
63
80
|
if actual_from >= actual_to:
|
|
@@ -24,7 +24,7 @@ from detectkit.alerting.orchestrator._types import (
|
|
|
24
24
|
_direction_from_metadata,
|
|
25
25
|
_parse_detection_metadata,
|
|
26
26
|
)
|
|
27
|
-
from detectkit.config.metric_config import MetricConfig
|
|
27
|
+
from detectkit.config.metric_config import MetricConfig, resolve_loading_delay_seconds
|
|
28
28
|
from detectkit.database.internal_tables import InternalTablesManager
|
|
29
29
|
from detectkit.orchestration.task_manager import make_alert_config_id
|
|
30
30
|
from detectkit.utils.datetime_utils import to_naive_utc
|
|
@@ -226,6 +226,7 @@ def replay_alert_events(
|
|
|
226
226
|
start: datetime,
|
|
227
227
|
end: datetime,
|
|
228
228
|
project_name: str | None,
|
|
229
|
+
loading_delay_seconds: int = 0,
|
|
229
230
|
) -> list[tuple[str, ReplayedEvent]]:
|
|
230
231
|
"""Replay every *enabled* alerting config over ``[start, end]``.
|
|
231
232
|
|
|
@@ -263,6 +264,9 @@ def replay_alert_events(
|
|
|
263
264
|
links=cfg.links,
|
|
264
265
|
project_name=project_name,
|
|
265
266
|
help_url=None,
|
|
267
|
+
# Constructor parity with the live path: replayed AlertData carries
|
|
268
|
+
# the same resolved data-maturity delay the dispatching run stamps.
|
|
269
|
+
loading_delay_seconds=loading_delay_seconds,
|
|
266
270
|
)
|
|
267
271
|
for event in orchestrator.replay(records, value_at, start, end):
|
|
268
272
|
out.append((config_id, event))
|
|
@@ -305,11 +309,14 @@ def build_report_payload(
|
|
|
305
309
|
end: datetime | None = None,
|
|
306
310
|
project_name: str | None = None,
|
|
307
311
|
generated_at: str | None = None,
|
|
312
|
+
project_loading_delay: str | int | None = None,
|
|
308
313
|
) -> dict:
|
|
309
314
|
"""Read the internal tables for one metric and assemble the report payload.
|
|
310
315
|
|
|
311
316
|
``start`` / ``end`` bound the report window (inclusive of both grid points);
|
|
312
|
-
when omitted they default via :func:`resolve_window`.
|
|
317
|
+
when omitted they default via :func:`resolve_window`. ``project_loading_delay``
|
|
318
|
+
is the project-wide ``loading_delay`` fallback, so replayed alerts carry the
|
|
319
|
+
same resolved data-maturity delay the live pipeline stamps.
|
|
313
320
|
"""
|
|
314
321
|
name = metric_config.name
|
|
315
322
|
interval = metric_config.get_interval()
|
|
@@ -418,7 +425,16 @@ def build_report_payload(
|
|
|
418
425
|
alerts: list[dict] = [
|
|
419
426
|
_event_to_payload(event, config_id)
|
|
420
427
|
for config_id, event in replay_alert_events(
|
|
421
|
-
metric_config,
|
|
428
|
+
metric_config,
|
|
429
|
+
internal,
|
|
430
|
+
records,
|
|
431
|
+
value_at,
|
|
432
|
+
start,
|
|
433
|
+
end,
|
|
434
|
+
project_name,
|
|
435
|
+
loading_delay_seconds=resolve_loading_delay_seconds(
|
|
436
|
+
metric_config.loading_delay, project_loading_delay
|
|
437
|
+
),
|
|
422
438
|
)
|
|
423
439
|
]
|
|
424
440
|
alerts.sort(key=lambda a: a["t"])
|