detectkit 0.53.3__tar.gz → 0.55.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.
Files changed (157) hide show
  1. {detectkit-0.53.3/detectkit.egg-info → detectkit-0.55.0}/PKG-INFO +1 -1
  2. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/__init__.py +1 -1
  3. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/alerting/channels/base.py +27 -2
  4. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/alerting/orchestrator/_base.py +7 -0
  5. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/alerting/orchestrator/_decision.py +17 -2
  6. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/alerting/orchestrator/_recovery.py +1 -0
  7. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/cli/assets/claude/rules/alerting.md +4 -1
  8. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/cli/assets/claude/rules/cli.md +36 -10
  9. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/cli/assets/claude/rules/metrics.md +11 -0
  10. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/cli/assets/claude/rules/overview.md +6 -3
  11. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/cli/assets/claude/rules/project.md +8 -0
  12. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +14 -6
  13. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/cli/commands/autotune.py +5 -0
  14. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/cli/commands/run.py +3 -0
  15. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/cli/commands/ui.py +4 -1
  16. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/config/metric_config.py +75 -0
  17. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/config/project_config.py +25 -0
  18. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/orchestration/task_manager/_alert_step.py +5 -0
  19. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/orchestration/task_manager/_base.py +12 -0
  20. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/orchestration/task_manager/_load_step.py +19 -2
  21. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/reporting/builder.py +19 -3
  22. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/semantic/__init__.py +2 -0
  23. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/semantic/osi_model.py +29 -13
  24. detectkit-0.55.0/detectkit/ui/assets/ui.js +515 -0
  25. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/ui/metric_files.py +15 -0
  26. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/ui/overview.py +29 -2
  27. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/ui/server.py +231 -5
  28. {detectkit-0.53.3 → detectkit-0.55.0/detectkit.egg-info}/PKG-INFO +1 -1
  29. detectkit-0.53.3/detectkit/ui/assets/ui.js +0 -323
  30. {detectkit-0.53.3 → detectkit-0.55.0}/LICENSE +0 -0
  31. {detectkit-0.53.3 → detectkit-0.55.0}/MANIFEST.in +0 -0
  32. {detectkit-0.53.3 → detectkit-0.55.0}/README.md +0 -0
  33. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/alerting/__init__.py +0 -0
  34. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/alerting/channels/__init__.py +0 -0
  35. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/alerting/channels/branding.py +0 -0
  36. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/alerting/channels/email.py +0 -0
  37. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/alerting/channels/factory.py +0 -0
  38. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/alerting/channels/mattermost.py +0 -0
  39. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/alerting/channels/slack.py +0 -0
  40. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/alerting/channels/telegram.py +0 -0
  41. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/alerting/channels/webhook.py +0 -0
  42. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
  43. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
  44. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
  45. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/alerting/orchestrator/_replay.py +0 -0
  46. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/alerting/orchestrator/_types.py +0 -0
  47. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
  48. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/autotune/__init__.py +0 -0
  49. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/autotune/_base.py +0 -0
  50. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/autotune/_types.py +0 -0
  51. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/autotune/autotuner.py +0 -0
  52. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/autotune/axis_spec.py +0 -0
  53. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/autotune/config_emitter.py +0 -0
  54. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/autotune/crossval.py +0 -0
  55. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/autotune/detector_select.py +0 -0
  56. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/autotune/distribution.py +0 -0
  57. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/autotune/grid_search.py +0 -0
  58. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/autotune/labels.py +0 -0
  59. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/autotune/result.py +0 -0
  60. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/autotune/runner.py +0 -0
  61. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/autotune/scoring.py +0 -0
  62. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/autotune/seasonality_search.py +0 -0
  63. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/autotune/settings.py +0 -0
  64. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/autotune/window_select.py +0 -0
  65. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/cli/__init__.py +0 -0
  66. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/cli/_output.py +0 -0
  67. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/cli/assets/claude/CLAUDE.section.md +0 -0
  68. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/cli/assets/claude/rules/autotune.md +0 -0
  69. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
  70. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +0 -0
  71. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
  72. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
  73. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/cli/assets/claude/skills/dtk-tune/SKILL.md +0 -0
  74. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/cli/commands/__init__.py +0 -0
  75. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/cli/commands/clean.py +0 -0
  76. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/cli/commands/init.py +0 -0
  77. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/cli/commands/init_claude.py +0 -0
  78. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/cli/commands/osi.py +0 -0
  79. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/cli/commands/test_alert.py +0 -0
  80. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/cli/commands/tune.py +0 -0
  81. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/cli/commands/unlock.py +0 -0
  82. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/cli/main.py +0 -0
  83. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/config/__init__.py +0 -0
  84. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/config/metric_io.py +0 -0
  85. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/config/profile.py +0 -0
  86. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/config/validator.py +0 -0
  87. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/core/__init__.py +0 -0
  88. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/core/interval.py +0 -0
  89. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/core/models.py +0 -0
  90. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/database/__init__.py +0 -0
  91. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/database/_sql_manager.py +0 -0
  92. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/database/clickhouse_manager.py +0 -0
  93. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/database/internal_tables/__init__.py +0 -0
  94. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
  95. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
  96. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/database/internal_tables/_base.py +0 -0
  97. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
  98. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/database/internal_tables/_detections.py +0 -0
  99. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
  100. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/database/internal_tables/_metrics.py +0 -0
  101. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/database/internal_tables/_schema.py +0 -0
  102. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/database/internal_tables/_tasks.py +0 -0
  103. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/database/internal_tables/manager.py +0 -0
  104. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/database/manager.py +0 -0
  105. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/database/mysql_manager.py +0 -0
  106. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/database/postgres_manager.py +0 -0
  107. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/database/tables.py +0 -0
  108. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/detectors/__init__.py +0 -0
  109. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/detectors/base.py +0 -0
  110. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/detectors/factory.py +0 -0
  111. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/detectors/seasonality.py +0 -0
  112. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/detectors/statistical/__init__.py +0 -0
  113. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/detectors/statistical/_windowed.py +0 -0
  114. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/detectors/statistical/autoreg.py +0 -0
  115. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/detectors/statistical/iqr.py +0 -0
  116. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/detectors/statistical/mad.py +0 -0
  117. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
  118. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/detectors/statistical/zscore.py +0 -0
  119. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/loaders/__init__.py +0 -0
  120. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/loaders/metric_loader.py +0 -0
  121. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/loaders/query_template.py +0 -0
  122. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/orchestration/__init__.py +0 -0
  123. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/orchestration/error_dispatch.py +0 -0
  124. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
  125. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
  126. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/orchestration/task_manager/_types.py +0 -0
  127. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/orchestration/task_manager/manager.py +0 -0
  128. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/reporting/__init__.py +0 -0
  129. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/reporting/assets/report.js +0 -0
  130. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/reporting/html_report.py +0 -0
  131. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/semantic/errors.py +0 -0
  132. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/semantic/exporter.py +0 -0
  133. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/semantic/importer.py +0 -0
  134. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/semantic/query_gen.py +0 -0
  135. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/tuning/__init__.py +0 -0
  136. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/tuning/assets/tune.js +0 -0
  137. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/tuning/config_writer.py +0 -0
  138. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/tuning/html.py +0 -0
  139. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/tuning/payload.py +0 -0
  140. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/tuning/server.py +0 -0
  141. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/ui/__init__.py +0 -0
  142. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/ui/html.py +0 -0
  143. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/ui/jobs.py +0 -0
  144. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/utils/__init__.py +0 -0
  145. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/utils/datetime_utils.py +0 -0
  146. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/utils/env_interpolation.py +0 -0
  147. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/utils/json_utils.py +0 -0
  148. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit/utils/stats.py +0 -0
  149. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit.egg-info/SOURCES.txt +0 -0
  150. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit.egg-info/dependency_links.txt +0 -0
  151. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit.egg-info/entry_points.txt +0 -0
  152. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit.egg-info/requires.txt +0 -0
  153. {detectkit-0.53.3 → detectkit-0.55.0}/detectkit.egg-info/top_level.txt +0 -0
  154. {detectkit-0.53.3 → detectkit-0.55.0}/pyproject.toml +0 -0
  155. {detectkit-0.53.3 → detectkit-0.55.0}/requirements.txt +0 -0
  156. {detectkit-0.53.3 → detectkit-0.55.0}/setup.cfg +0 -0
  157. {detectkit-0.53.3 → detectkit-0.55.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.53.3
3
+ Version: 0.55.0
4
4
  Summary: Metric monitoring with automatic anomaly detection
5
5
  Author: detectkit team
6
6
  License: MIT
@@ -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.53.3"
7
+ __version__ = "0.55.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 when
467
- # detectkit actually notified. Skipped when the run is capped (onset is
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
- floored = (int(now.timestamp()) // interval_seconds) * interval_seconds
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)
@@ -234,6 +234,7 @@ class _RecoveryMixin(_OrchestratorBase):
234
234
  interval_seconds=self.interval.seconds,
235
235
  onset_timestamp=onset_ts,
236
236
  streak_capped=capped,
237
+ loading_delay_seconds=self.loading_delay_seconds or None,
237
238
  )
238
239
 
239
240
  def _resolve_incident(
@@ -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.
@@ -221,20 +224,39 @@ exactly as if typed into a terminal.
221
224
 
222
225
  ### Managing metrics from the UI
223
226
 
224
- The cockpit header has a **New metric** button an editor overlay seeded
225
- with a starter YAML template, with an optional subfolder under `metrics/`;
226
- saving writes `metrics/[<folder>/]<name>.yml`. Every metric row also has an
227
- **Edit** action that opens its raw YAML in the same editor. **Save** validates
227
+ The cockpit header has a **New metric** button, and every metric row an
228
+ **Edit** action; both open an editor overlay with **two tabs sharing one
229
+ draft**: **Builder** a structured form over the whole config (basics,
230
+ schedule/loading, seasonality, minimal detector rows with type + 1-2 key
231
+ params — fine-tuning belongs in `dtk tune` —, alerting with a channel
232
+ multi-select seeded from `profiles.yml` channel names, `ai_context`; SQL in
233
+ a syntax-highlighted pane, `query_file` paths read-only; a **From OSI**
234
+ sub-tab compiles a pasted OSI semantic-model metric through the same code
235
+ path as `dtk osi import`) — and **YAML**, the raw text, kept for whole-config
236
+ pastes. The last-edited tab wins, never silently: leaving an edited YAML tab
237
+ validates server-side first and blocks the switch on error; leaving an
238
+ edited Builder re-emits the YAML. A debounced live-validation chip re-checks
239
+ the draft while typing. Keys the form doesn't model (`autotune:`, custom
240
+ templates, unknown detector types/params, a multi-entry alerting list)
241
+ round-trip verbatim, listed under "Preserved fields". Create writes
242
+ `metrics/[<folder>/]<name>.yml`; after a create, a **next-steps strip**
243
+ offers **Load & detect** (`dtk run --steps load,detect` for just that metric
244
+ — no alert step, so an untuned config can't spam a channel) and then **Open
245
+ tune** on the loaded series. **Save** validates
228
246
  server-side **before any write**: YAML syntax → full `MetricConfig` → a deep
229
247
  detector-params check (each factory-known detector is actually constructed) —
230
248
  an invalid config lands in the editor's error pane with nothing written. A
231
249
  successful save **archives the previous file verbatim** to
232
250
  `metrics/.history/<metric>/` (the same archive `dtk tune`'s Apply writes,
233
- excluded from metric discovery) and only then overwrites in place, so the
234
- text typed is what lands on disk, comments intact (normalized only to end
235
- with a newline). A save is refused when the file changed on disk after the
236
- editor was opened (a `dtk tune` Apply or another session saved first) —
237
- reopen the metric rather than overwrite it. Renaming via `name:`
251
+ excluded from metric discovery) and only then overwrites in place. A
252
+ YAML-tab save writes the text typed, comments intact (normalized only to end
253
+ with a newline); a Builder save **re-emits the YAML deterministically,
254
+ dropping hand-written comments** (the archive keeps the previous file; edit
255
+ comment-heavy files from the YAML tab). A save is refused when the file
256
+ changed on disk after the editor was opened (a `dtk tune` Apply or another
257
+ session saved first) — reopen the metric rather than overwrite it. A file
258
+ that doesn't parse opens YAML-only, with the parse error on the disabled
259
+ Builder tab. Renaming via `name:`
238
260
  is allowed — uniqueness is enforced project-wide — and rows under the old name
239
261
  stay in the `_dtk_*` tables until `dtk clean`. **Delete** lives in the edit
240
262
  overlay behind an explicit confirmation step, and the server additionally
@@ -317,6 +339,10 @@ dtk osi import model.osi.yml -m total_sales -i 1h -o metrics/ # scaffold a metr
317
339
  dtk osi export -o semantic/detectkit.osi.yml # publish back to OSI
318
340
  ```
319
341
 
342
+ The `dtk ui` metric Builder's **From OSI** sub-tab does the import
343
+ interactively (paste a model, pick a metric/target, Compile) through the
344
+ same code path — see "Managing metrics from the UI" above.
345
+
320
346
  ## Common workflows
321
347
 
322
348
  ```bash
@@ -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,12 @@ 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, quality when incidents are labeled), a panel that drives
107
- `dtk run` / `dtk autotune` / `dtk unlock`, and an in-browser editor to create,
108
- edit and delete metric YAML files. See `cli.md`.
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 — a structured **Builder**
110
+ form (with OSI import and a post-create load-then-tune flow) next to a raw
111
+ **YAML** tab. See `cli.md`.
109
112
 
110
113
  ## Glossary
111
114
 
@@ -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`
@@ -16,12 +16,14 @@ need detail on any field, read the matching file under
16
16
  `project.md`); this skill is the procedure, those are the reference.
17
17
 
18
18
  `dtk ui` also has a browser editor for metric YAMLs (a **New metric** button
19
- and, per row, an **Edit** action), validated the same way before it writes.
20
- Prefer this skill when scaffolding from scratch it gathers the query,
21
- detector and alerting choices and explains each one in context; prefer the UI
22
- for a quick manual edit while you're already watching the cockpit (nudging a
23
- threshold, adding a channel) it's a raw-YAML editor with no procedure behind
24
- it.
19
+ and, per row, an **Edit** action), validated the same way before it writes:
20
+ a structured **Builder** form (highlighted SQL pane, channel picker, an OSI
21
+ import sub-tab, a post-create load-then-tune strip) next to a raw **YAML**
22
+ tab. Prefer this skill when the user is working with you in the terminal
23
+ it gathers the query, detector and alerting choices and explains each one in
24
+ context; point them at the UI when they'd rather click through the same flow
25
+ themselves, or for a quick manual edit while already watching the cockpit
26
+ (nudging a threshold, adding a channel).
25
27
 
26
28
  ## Step 0 — Confirm you're in a detectkit project
27
29
 
@@ -62,6 +64,12 @@ Use `{{ interval_seconds }}` if you need to bucket
62
64
  For long/complex SQL, offer to put it in `sql/<name>.sql` and use `query_file:`
63
65
  instead of inline `query:`.
64
66
 
67
+ Also ask: **does this source finish writing after the interval closes** (a dbt
68
+ model or other batch job that lands a few minutes late)? If so, set
69
+ `loading_delay` (e.g. `"10min"`) so `dtk run` doesn't load and permanently
70
+ persist a partially-written bucket right at the boundary — see `metrics.md`
71
+ for the full behavior and its detection-latency trade-off.
72
+
65
73
  ## Step 3 — Interval
66
74
 
67
75
  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")
@@ -18,7 +18,7 @@ from detectkit.config.profile import ProfilesConfig
18
18
  from detectkit.config.project_config import ProjectConfig
19
19
  from detectkit.database.internal_tables import InternalTablesManager
20
20
  from detectkit.ui.overview import ALL_WINDOW_PRESETS
21
- from detectkit.ui.server import serve_ui
21
+ from detectkit.ui.server import build_form_meta, serve_ui
22
22
 
23
23
 
24
24
  def run_ui(
@@ -82,6 +82,8 @@ def run_ui(
82
82
  click.echo(click.style(f"detectkit UI: {project_config.name}", fg="cyan", bold=True))
83
83
  click.echo(f" {len(metrics)} metric(s) covered by --select {select} (window: {window})")
84
84
 
85
+ form_meta = build_form_meta(profiles_config)
86
+
85
87
  try:
86
88
  serve_ui(
87
89
  project_config=project_config,
@@ -92,6 +94,7 @@ def run_ui(
92
94
  profile=profile,
93
95
  echo=click.echo,
94
96
  open_browser=not no_open,
97
+ form_meta=form_meta,
95
98
  )
96
99
  except KeyboardInterrupt:
97
100
  pass
@@ -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
- actual_to = to_naive_utc(actual_to) if actual_to is not None else now_utc_naive()
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: