detectkit 0.53.3__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.
Files changed (156) hide show
  1. {detectkit-0.53.3/detectkit.egg-info → detectkit-0.54.0}/PKG-INFO +1 -1
  2. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/__init__.py +1 -1
  3. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/alerting/channels/base.py +27 -2
  4. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/alerting/orchestrator/_base.py +7 -0
  5. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/alerting/orchestrator/_decision.py +17 -2
  6. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/alerting/orchestrator/_recovery.py +1 -0
  7. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/cli/assets/claude/rules/alerting.md +4 -1
  8. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/cli/assets/claude/rules/cli.md +4 -1
  9. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/cli/assets/claude/rules/metrics.md +11 -0
  10. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/cli/assets/claude/rules/overview.md +4 -3
  11. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/cli/assets/claude/rules/project.md +8 -0
  12. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +6 -0
  13. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/cli/commands/autotune.py +5 -0
  14. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/cli/commands/run.py +3 -0
  15. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/config/metric_config.py +75 -0
  16. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/config/project_config.py +25 -0
  17. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/orchestration/task_manager/_alert_step.py +5 -0
  18. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/orchestration/task_manager/_base.py +12 -0
  19. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/orchestration/task_manager/_load_step.py +19 -2
  20. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/reporting/builder.py +19 -3
  21. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/ui/assets/ui.js +4 -4
  22. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/ui/overview.py +29 -2
  23. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/ui/server.py +1 -0
  24. {detectkit-0.53.3 → detectkit-0.54.0/detectkit.egg-info}/PKG-INFO +1 -1
  25. {detectkit-0.53.3 → detectkit-0.54.0}/LICENSE +0 -0
  26. {detectkit-0.53.3 → detectkit-0.54.0}/MANIFEST.in +0 -0
  27. {detectkit-0.53.3 → detectkit-0.54.0}/README.md +0 -0
  28. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/alerting/__init__.py +0 -0
  29. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/alerting/channels/__init__.py +0 -0
  30. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/alerting/channels/branding.py +0 -0
  31. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/alerting/channels/email.py +0 -0
  32. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/alerting/channels/factory.py +0 -0
  33. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/alerting/channels/mattermost.py +0 -0
  34. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/alerting/channels/slack.py +0 -0
  35. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/alerting/channels/telegram.py +0 -0
  36. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/alerting/channels/webhook.py +0 -0
  37. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
  38. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
  39. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
  40. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/alerting/orchestrator/_replay.py +0 -0
  41. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/alerting/orchestrator/_types.py +0 -0
  42. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
  43. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/autotune/__init__.py +0 -0
  44. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/autotune/_base.py +0 -0
  45. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/autotune/_types.py +0 -0
  46. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/autotune/autotuner.py +0 -0
  47. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/autotune/axis_spec.py +0 -0
  48. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/autotune/config_emitter.py +0 -0
  49. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/autotune/crossval.py +0 -0
  50. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/autotune/detector_select.py +0 -0
  51. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/autotune/distribution.py +0 -0
  52. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/autotune/grid_search.py +0 -0
  53. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/autotune/labels.py +0 -0
  54. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/autotune/result.py +0 -0
  55. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/autotune/runner.py +0 -0
  56. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/autotune/scoring.py +0 -0
  57. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/autotune/seasonality_search.py +0 -0
  58. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/autotune/settings.py +0 -0
  59. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/autotune/window_select.py +0 -0
  60. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/cli/__init__.py +0 -0
  61. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/cli/_output.py +0 -0
  62. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/cli/assets/claude/CLAUDE.section.md +0 -0
  63. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/cli/assets/claude/rules/autotune.md +0 -0
  64. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
  65. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +0 -0
  66. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
  67. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
  68. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/cli/assets/claude/skills/dtk-tune/SKILL.md +0 -0
  69. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/cli/commands/__init__.py +0 -0
  70. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/cli/commands/clean.py +0 -0
  71. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/cli/commands/init.py +0 -0
  72. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/cli/commands/init_claude.py +0 -0
  73. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/cli/commands/osi.py +0 -0
  74. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/cli/commands/test_alert.py +0 -0
  75. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/cli/commands/tune.py +0 -0
  76. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/cli/commands/ui.py +0 -0
  77. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/cli/commands/unlock.py +0 -0
  78. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/cli/main.py +0 -0
  79. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/config/__init__.py +0 -0
  80. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/config/metric_io.py +0 -0
  81. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/config/profile.py +0 -0
  82. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/config/validator.py +0 -0
  83. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/core/__init__.py +0 -0
  84. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/core/interval.py +0 -0
  85. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/core/models.py +0 -0
  86. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/database/__init__.py +0 -0
  87. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/database/_sql_manager.py +0 -0
  88. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/database/clickhouse_manager.py +0 -0
  89. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/database/internal_tables/__init__.py +0 -0
  90. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
  91. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
  92. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/database/internal_tables/_base.py +0 -0
  93. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
  94. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/database/internal_tables/_detections.py +0 -0
  95. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
  96. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/database/internal_tables/_metrics.py +0 -0
  97. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/database/internal_tables/_schema.py +0 -0
  98. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/database/internal_tables/_tasks.py +0 -0
  99. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/database/internal_tables/manager.py +0 -0
  100. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/database/manager.py +0 -0
  101. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/database/mysql_manager.py +0 -0
  102. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/database/postgres_manager.py +0 -0
  103. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/database/tables.py +0 -0
  104. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/detectors/__init__.py +0 -0
  105. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/detectors/base.py +0 -0
  106. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/detectors/factory.py +0 -0
  107. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/detectors/seasonality.py +0 -0
  108. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/detectors/statistical/__init__.py +0 -0
  109. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/detectors/statistical/_windowed.py +0 -0
  110. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/detectors/statistical/autoreg.py +0 -0
  111. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/detectors/statistical/iqr.py +0 -0
  112. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/detectors/statistical/mad.py +0 -0
  113. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
  114. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/detectors/statistical/zscore.py +0 -0
  115. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/loaders/__init__.py +0 -0
  116. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/loaders/metric_loader.py +0 -0
  117. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/loaders/query_template.py +0 -0
  118. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/orchestration/__init__.py +0 -0
  119. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/orchestration/error_dispatch.py +0 -0
  120. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
  121. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
  122. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/orchestration/task_manager/_types.py +0 -0
  123. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/orchestration/task_manager/manager.py +0 -0
  124. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/reporting/__init__.py +0 -0
  125. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/reporting/assets/report.js +0 -0
  126. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/reporting/html_report.py +0 -0
  127. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/semantic/__init__.py +0 -0
  128. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/semantic/errors.py +0 -0
  129. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/semantic/exporter.py +0 -0
  130. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/semantic/importer.py +0 -0
  131. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/semantic/osi_model.py +0 -0
  132. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/semantic/query_gen.py +0 -0
  133. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/tuning/__init__.py +0 -0
  134. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/tuning/assets/tune.js +0 -0
  135. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/tuning/config_writer.py +0 -0
  136. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/tuning/html.py +0 -0
  137. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/tuning/payload.py +0 -0
  138. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/tuning/server.py +0 -0
  139. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/ui/__init__.py +0 -0
  140. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/ui/html.py +0 -0
  141. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/ui/jobs.py +0 -0
  142. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/ui/metric_files.py +0 -0
  143. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/utils/__init__.py +0 -0
  144. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/utils/datetime_utils.py +0 -0
  145. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/utils/env_interpolation.py +0 -0
  146. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/utils/json_utils.py +0 -0
  147. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit/utils/stats.py +0 -0
  148. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit.egg-info/SOURCES.txt +0 -0
  149. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit.egg-info/dependency_links.txt +0 -0
  150. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit.egg-info/entry_points.txt +0 -0
  151. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit.egg-info/requires.txt +0 -0
  152. {detectkit-0.53.3 → detectkit-0.54.0}/detectkit.egg-info/top_level.txt +0 -0
  153. {detectkit-0.53.3 → detectkit-0.54.0}/pyproject.toml +0 -0
  154. {detectkit-0.53.3 → detectkit-0.54.0}/requirements.txt +0 -0
  155. {detectkit-0.53.3 → detectkit-0.54.0}/setup.cfg +0 -0
  156. {detectkit-0.53.3 → detectkit-0.54.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.54.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.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 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.
@@ -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, 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. 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`
@@ -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
- 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:
@@ -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, internal, records, value_at, start, end, project_name
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"])