detectkit 0.57.0__tar.gz → 0.58.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.57.0/detectkit.egg-info → detectkit-0.58.0}/PKG-INFO +3 -1
  2. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/__init__.py +1 -1
  3. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/alerting/channels/webhook.py +273 -7
  4. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/assets/claude/CLAUDE.section.md +1 -1
  5. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/assets/claude/rules/cli.md +28 -1
  6. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/assets/claude/rules/project.md +23 -3
  7. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +15 -11
  8. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/commands/autotune.py +42 -19
  9. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/commands/clean.py +27 -16
  10. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/commands/init.py +11 -0
  11. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/commands/run.py +235 -18
  12. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/main.py +18 -4
  13. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/config/profile.py +13 -11
  14. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/database/mysql_manager.py +47 -2
  15. {detectkit-0.57.0 → detectkit-0.58.0/detectkit.egg-info}/PKG-INFO +3 -1
  16. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit.egg-info/requires.txt +3 -0
  17. {detectkit-0.57.0 → detectkit-0.58.0}/pyproject.toml +3 -0
  18. {detectkit-0.57.0 → detectkit-0.58.0}/LICENSE +0 -0
  19. {detectkit-0.57.0 → detectkit-0.58.0}/MANIFEST.in +0 -0
  20. {detectkit-0.57.0 → detectkit-0.58.0}/README.md +0 -0
  21. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/alerting/__init__.py +0 -0
  22. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/alerting/channels/__init__.py +0 -0
  23. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/alerting/channels/base.py +0 -0
  24. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/alerting/channels/branding.py +0 -0
  25. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/alerting/channels/email.py +0 -0
  26. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/alerting/channels/factory.py +0 -0
  27. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/alerting/channels/mattermost.py +0 -0
  28. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/alerting/channels/slack.py +0 -0
  29. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/alerting/channels/telegram.py +0 -0
  30. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
  31. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/alerting/orchestrator/_base.py +0 -0
  32. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
  33. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
  34. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
  35. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
  36. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/alerting/orchestrator/_replay.py +0 -0
  37. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/alerting/orchestrator/_types.py +0 -0
  38. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
  39. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/autotune/__init__.py +0 -0
  40. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/autotune/_base.py +0 -0
  41. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/autotune/_types.py +0 -0
  42. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/autotune/autotuner.py +0 -0
  43. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/autotune/axis_spec.py +0 -0
  44. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/autotune/config_emitter.py +0 -0
  45. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/autotune/crossval.py +0 -0
  46. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/autotune/detector_select.py +0 -0
  47. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/autotune/distribution.py +0 -0
  48. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/autotune/grid_search.py +0 -0
  49. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/autotune/labels.py +0 -0
  50. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/autotune/result.py +0 -0
  51. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/autotune/runner.py +0 -0
  52. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/autotune/scoring.py +0 -0
  53. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/autotune/seasonality_search.py +0 -0
  54. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/autotune/settings.py +0 -0
  55. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/autotune/window_select.py +0 -0
  56. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/__init__.py +0 -0
  57. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/_output.py +0 -0
  58. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/assets/claude/rules/alerting.md +0 -0
  59. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/assets/claude/rules/autotune.md +0 -0
  60. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
  61. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
  62. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
  63. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +0 -0
  64. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
  65. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
  66. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/assets/claude/skills/dtk-tune/SKILL.md +0 -0
  67. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/commands/__init__.py +0 -0
  68. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/commands/init_claude.py +0 -0
  69. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/commands/osi.py +0 -0
  70. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/commands/test_alert.py +0 -0
  71. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/commands/tune.py +0 -0
  72. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/commands/ui.py +0 -0
  73. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/cli/commands/unlock.py +0 -0
  74. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/config/__init__.py +0 -0
  75. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/config/metric_config.py +0 -0
  76. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/config/metric_io.py +0 -0
  77. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/config/project_config.py +0 -0
  78. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/config/validator.py +0 -0
  79. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/core/__init__.py +0 -0
  80. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/core/interval.py +0 -0
  81. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/core/models.py +0 -0
  82. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/database/__init__.py +0 -0
  83. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/database/_sql_manager.py +0 -0
  84. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/database/clickhouse_manager.py +0 -0
  85. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/database/internal_tables/__init__.py +0 -0
  86. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
  87. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
  88. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/database/internal_tables/_base.py +0 -0
  89. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
  90. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/database/internal_tables/_detections.py +0 -0
  91. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
  92. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/database/internal_tables/_metrics.py +0 -0
  93. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/database/internal_tables/_schema.py +0 -0
  94. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/database/internal_tables/_tasks.py +0 -0
  95. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/database/internal_tables/manager.py +0 -0
  96. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/database/manager.py +0 -0
  97. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/database/postgres_manager.py +0 -0
  98. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/database/tables.py +0 -0
  99. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/detectors/__init__.py +0 -0
  100. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/detectors/base.py +0 -0
  101. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/detectors/factory.py +0 -0
  102. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/detectors/seasonality.py +0 -0
  103. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/detectors/statistical/__init__.py +0 -0
  104. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/detectors/statistical/_windowed.py +0 -0
  105. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/detectors/statistical/autoreg.py +0 -0
  106. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/detectors/statistical/iqr.py +0 -0
  107. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/detectors/statistical/mad.py +0 -0
  108. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
  109. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/detectors/statistical/zscore.py +0 -0
  110. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/loaders/__init__.py +0 -0
  111. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/loaders/metric_loader.py +0 -0
  112. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/loaders/query_template.py +0 -0
  113. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/orchestration/__init__.py +0 -0
  114. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/orchestration/error_dispatch.py +0 -0
  115. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
  116. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
  117. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/orchestration/task_manager/_base.py +0 -0
  118. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
  119. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
  120. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/orchestration/task_manager/_types.py +0 -0
  121. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/orchestration/task_manager/manager.py +0 -0
  122. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/reporting/__init__.py +0 -0
  123. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/reporting/assets/report.js +0 -0
  124. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/reporting/builder.py +0 -0
  125. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/reporting/html_report.py +0 -0
  126. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/semantic/__init__.py +0 -0
  127. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/semantic/errors.py +0 -0
  128. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/semantic/exporter.py +0 -0
  129. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/semantic/importer.py +0 -0
  130. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/semantic/osi_model.py +0 -0
  131. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/semantic/query_gen.py +0 -0
  132. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/tuning/__init__.py +0 -0
  133. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/tuning/assets/tune.js +0 -0
  134. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/tuning/config_writer.py +0 -0
  135. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/tuning/html.py +0 -0
  136. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/tuning/payload.py +0 -0
  137. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/tuning/server.py +0 -0
  138. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/ui/__init__.py +0 -0
  139. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/ui/assets/ui.js +0 -0
  140. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/ui/html.py +0 -0
  141. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/ui/jobs.py +0 -0
  142. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/ui/metric_files.py +0 -0
  143. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/ui/overview.py +0 -0
  144. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/ui/server.py +0 -0
  145. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/utils/__init__.py +0 -0
  146. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/utils/datetime_utils.py +0 -0
  147. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/utils/env_interpolation.py +0 -0
  148. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/utils/json_utils.py +0 -0
  149. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit/utils/stats.py +0 -0
  150. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit.egg-info/SOURCES.txt +0 -0
  151. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit.egg-info/dependency_links.txt +0 -0
  152. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit.egg-info/entry_points.txt +0 -0
  153. {detectkit-0.57.0 → detectkit-0.58.0}/detectkit.egg-info/top_level.txt +0 -0
  154. {detectkit-0.57.0 → detectkit-0.58.0}/requirements.txt +0 -0
  155. {detectkit-0.57.0 → detectkit-0.58.0}/setup.cfg +0 -0
  156. {detectkit-0.57.0 → detectkit-0.58.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.57.0
3
+ Version: 0.58.0
4
4
  Summary: Metric monitoring with automatic anomaly detection
5
5
  Author: detectkit team
6
6
  License: MIT
@@ -36,6 +36,8 @@ Provides-Extra: postgres
36
36
  Requires-Dist: psycopg2-binary>=2.9.0; extra == "postgres"
37
37
  Provides-Extra: mysql
38
38
  Requires-Dist: pymysql>=1.0.0; extra == "mysql"
39
+ Provides-Extra: mariadb
40
+ Requires-Dist: pymysql>=1.0.0; extra == "mariadb"
39
41
  Provides-Extra: all-db
40
42
  Requires-Dist: clickhouse-driver>=0.2.0; extra == "all-db"
41
43
  Requires-Dist: psycopg2-binary>=2.9.0; extra == "all-db"
@@ -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.57.0"
7
+ __version__ = "0.58.0"
8
8
 
9
9
  from detectkit.core.interval import Interval
10
10
  from detectkit.core.models import ColumnDefinition, TableModel
@@ -5,6 +5,10 @@ Sends alerts to any webhook endpoint that accepts JSON payload.
5
5
  Compatible with Mattermost, Slack, and other webhook-based systems.
6
6
  """
7
7
 
8
+ import hashlib
9
+ import hmac
10
+ import json
11
+ import math
8
12
  from datetime import datetime, timezone
9
13
  from typing import Any
10
14
 
@@ -83,6 +87,17 @@ class WebhookChannel(BaseAlertChannel):
83
87
  channel (str): Target channel (optional, for Slack/Mattermost)
84
88
  timeout (int): Request timeout in seconds (default: 10)
85
89
  extra_headers (dict): Additional HTTP headers (optional)
90
+ format (str): Payload shape — "attachments" (default; today's
91
+ byte-identical Slack/Mattermost card), "json" (a structured event
92
+ payload, ``schema_version`` 1) or "alertmanager" (a Prometheus
93
+ Alertmanager webhook-receiver payload, version "4"). Any other
94
+ value raises ``ValueError`` at construction. A custom ``template``
95
+ passed to :meth:`send` is ignored for "json"/"alertmanager" —
96
+ those are structured formats with no free-text body.
97
+ secret (str): When set, every outgoing request (any format) is
98
+ HMAC-SHA256 signed GitHub-webhook style: header
99
+ ``X-Detectkit-Signature-256: sha256=<hexdigest>`` computed over
100
+ the exact request body bytes with *secret* as the key.
86
101
 
87
102
  Example:
88
103
  >>> # Mattermost
@@ -103,6 +118,8 @@ class WebhookChannel(BaseAlertChannel):
103
118
  ... )
104
119
  """
105
120
 
121
+ _FORMATS = ("attachments", "json", "alertmanager")
122
+
106
123
  def __init__(
107
124
  self,
108
125
  webhook_url: str,
@@ -112,10 +129,15 @@ class WebhookChannel(BaseAlertChannel):
112
129
  channel: str | None = None,
113
130
  timeout: int = 10,
114
131
  extra_headers: dict[str, str] | None = None,
132
+ format: str = "attachments",
133
+ secret: str | None = None,
115
134
  ):
116
135
  """Initialize webhook channel."""
117
136
  if not webhook_url:
118
137
  raise ValueError("webhook_url is required")
138
+ if format not in self._FORMATS:
139
+ allowed = ", ".join(self._FORMATS)
140
+ raise ValueError(f"Unknown webhook format: '{format}'. Allowed: {allowed}")
119
141
 
120
142
  self.webhook_url = webhook_url
121
143
  self.username = username
@@ -129,6 +151,8 @@ class WebhookChannel(BaseAlertChannel):
129
151
  self.channel = channel
130
152
  self.timeout = timeout
131
153
  self.extra_headers = extra_headers or {}
154
+ self.format = format
155
+ self.secret = secret
132
156
 
133
157
  def send(
134
158
  self,
@@ -140,10 +164,12 @@ class WebhookChannel(BaseAlertChannel):
140
164
 
141
165
  Args:
142
166
  alert_data: Alert data to send
143
- template: Optional custom message template. When given, the
144
- attachment carries the formatted template verbatim as the body
145
- text (in place of the structured lead/Value/tail sections);
146
- otherwise the rich default is built.
167
+ template: Optional custom message template. Applies only to the
168
+ default "attachments" format, where the attachment carries the
169
+ formatted template verbatim as the body text (in place of the
170
+ structured lead/Value/tail sections); otherwise the rich
171
+ default is built. Ignored for "json"/"alertmanager" — those
172
+ are structured formats with no free-text body.
147
173
 
148
174
  Returns:
149
175
  True if sent successfully, False otherwise
@@ -155,17 +181,31 @@ class WebhookChannel(BaseAlertChannel):
155
181
  >>> channel = WebhookChannel(webhook_url="https://...")
156
182
  >>> success = channel.send(alert_data)
157
183
  """
158
- payload = self.build_payload(alert_data, template)
184
+ if self.format == "json":
185
+ payload = self.build_event_payload(alert_data)
186
+ elif self.format == "alertmanager":
187
+ payload = self.build_alertmanager_payload(alert_data)
188
+ else:
189
+ payload = self.build_payload(alert_data, template)
159
190
 
160
- # Prepare headers
191
+ # Serialize ourselves (rather than letting ``requests`` do it via
192
+ # ``json=``) so the HMAC signature covers the exact bytes sent.
193
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
161
194
  headers = {"Content-Type": "application/json"}
162
195
  headers.update(self.extra_headers)
196
+ if self.format in ("json", "alertmanager"):
197
+ headers["X-Detectkit-Event"] = self.status_kind(alert_data)
198
+ if self.secret:
199
+ # GitHub-webhook style signature, computed AFTER extra_headers so
200
+ # a misconfigured extra header can't clobber it.
201
+ signature = hmac.new(self.secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
202
+ headers["X-Detectkit-Signature-256"] = f"sha256={signature}"
163
203
 
164
204
  # Send to webhook
165
205
  try:
166
206
  response = requests.post(
167
207
  self.webhook_url,
168
- json=payload,
208
+ data=body,
169
209
  headers=headers,
170
210
  timeout=self.timeout,
171
211
  )
@@ -405,6 +445,232 @@ class WebhookChannel(BaseAlertChannel):
405
445
  }
406
446
  ]
407
447
 
448
+ def _display_lead(self, alert_data: AlertData, ctx: dict[str, Any], kind: str) -> str:
449
+ """The plain-language lead sentence shared by both structured formats.
450
+
451
+ Anomaly/recovery reuse the same ready-made sentence the attachment
452
+ body leads with; no-data/error get a fixed one-liner (they have no
453
+ streak/duration story).
454
+ """
455
+ if kind == "anomaly":
456
+ return str(ctx["anomaly_lead"])
457
+ if kind == "recovery":
458
+ return str(ctx["recovery_lead"])
459
+ if kind == "no_data":
460
+ return "Query returned no datapoint for the latest expected interval."
461
+ return "The detectkit pipeline failed for this metric."
462
+
463
+ def build_event_payload(self, alert_data: AlertData) -> dict[str, Any]:
464
+ """Structured JSON event payload (``format="json"``).
465
+
466
+ ``schema_version`` 1 — **frozen**: this shape is a public contract for
467
+ downstream consumers (custom receivers, log pipelines), so existing
468
+ keys must not change meaning or disappear; only additive changes are
469
+ safe without bumping the version. Built from ``alert_data`` plus
470
+ :meth:`build_context` (the same resolved values every other rendering
471
+ surface — the attachment, Telegram, email — uses), so this event and
472
+ the human-readable message never disagree.
473
+ """
474
+ ctx = self.build_context(alert_data)
475
+ kind = self.status_kind(alert_data)
476
+ streak = alert_data.consecutive_count
477
+ interval_seconds = alert_data.interval_seconds
478
+ duration_seconds = streak * interval_seconds if interval_seconds is not None else None
479
+
480
+ return {
481
+ "schema_version": 1,
482
+ "source": "detectkit",
483
+ "kind": kind,
484
+ "status": "resolved" if alert_data.is_recovery else "firing",
485
+ "project": alert_data.project_name,
486
+ "metric": alert_data.metric_name,
487
+ "description": alert_data.description,
488
+ "timestamp": self._iso_utc(alert_data.timestamp),
489
+ "value": self._clean_float(alert_data.value),
490
+ "expected": {
491
+ "lower": self._bounded_value(alert_data.confidence_lower),
492
+ "upper": self._bounded_value(alert_data.confidence_upper),
493
+ },
494
+ "severity": self._clean_float(alert_data.severity),
495
+ "direction": alert_data.direction,
496
+ "detector": {
497
+ "name": alert_data.detector_name,
498
+ "params": self._parse_detector_params(alert_data.detector_params),
499
+ },
500
+ "rule": {
501
+ "min_detectors": ctx["min_detectors"],
502
+ "direction": ctx["direction_policy"],
503
+ "consecutive": ctx["consecutive_required"],
504
+ "window_points": alert_data.window_points,
505
+ "min_anomaly_share": alert_data.min_anomaly_share,
506
+ "fired_by_share": alert_data.fired_by_share,
507
+ "display": ctx["rule_display"],
508
+ },
509
+ "quorum": {
510
+ "detector_count": alert_data.detector_count,
511
+ "min_detectors": ctx["min_detectors"],
512
+ },
513
+ "incident": {
514
+ "onset": self._iso_utc(alert_data.onset_timestamp),
515
+ "streak": streak,
516
+ "capped": alert_data.streak_capped,
517
+ "interval_seconds": interval_seconds,
518
+ "duration_seconds": duration_seconds,
519
+ },
520
+ "links": {
521
+ "dashboard": alert_data.dashboard_url,
522
+ "help": ctx["help_url"] or None,
523
+ "extra": alert_data.links,
524
+ },
525
+ "mentions": alert_data.mentions,
526
+ "synonyms": alert_data.ai_synonyms,
527
+ "error": (
528
+ {"type": alert_data.error_type, "message": alert_data.error_message}
529
+ if kind == "error"
530
+ else None
531
+ ),
532
+ "display": {
533
+ "title": self.format_title(alert_data),
534
+ "lead": self._display_lead(alert_data, ctx, kind),
535
+ "value": ctx["value_display"],
536
+ "expected_range": ctx["expected_range"],
537
+ "timestamp": ctx["timestamp"],
538
+ },
539
+ }
540
+
541
+ def build_alertmanager_payload(self, alert_data: AlertData) -> dict[str, Any]:
542
+ """Prometheus Alertmanager webhook-receiver payload (``format="alertmanager"``).
543
+
544
+ Version "4" — **frozen** shape, same contract rationale as
545
+ :meth:`build_event_payload`. A recovery deliberately carries the
546
+ **same labels** as the anomaly it resolves (``kind`` maps back to
547
+ ``"anomaly"``) so Alertmanager-style consumers pair the two via
548
+ ``fingerprint`` — only ``status``/``endsAt`` differ.
549
+ """
550
+ ctx = self.build_context(alert_data)
551
+ kind = self.status_kind(alert_data)
552
+ is_recovery = alert_data.is_recovery
553
+
554
+ labels_kind = "anomaly" if is_recovery else kind
555
+ labels: dict[str, str] = {
556
+ "alertname": alert_data.metric_name,
557
+ "metric": alert_data.metric_name,
558
+ "kind": labels_kind,
559
+ "source": "detectkit",
560
+ "severity": "warning" if labels_kind == "no_data" else "critical",
561
+ }
562
+ if alert_data.project_name:
563
+ labels["project"] = alert_data.project_name
564
+
565
+ lead = self._display_lead(alert_data, ctx, kind)
566
+ annotations: dict[str, str] = {
567
+ "summary": self.format_title(alert_data),
568
+ "description": lead,
569
+ }
570
+ if kind in ("anomaly", "recovery"):
571
+ annotations["value"] = ctx["value_display"]
572
+ annotations["expected"] = ctx["expected_range"]
573
+ # Direction rides as an ANNOTATION, never a label: the orchestrator
574
+ # stamps recovery AlertData with direction="none", so a direction label
575
+ # would change the label set — and the fingerprint — between a firing
576
+ # alert and its recovery, breaking trigger/resolve pairing.
577
+ if kind == "anomaly" and alert_data.direction and alert_data.direction != "none":
578
+ annotations["direction"] = alert_data.direction
579
+
580
+ fingerprint = self._label_fingerprint(labels)
581
+ onset = (
582
+ alert_data.onset_timestamp
583
+ if alert_data.onset_timestamp is not None
584
+ else alert_data.timestamp
585
+ )
586
+ status = "resolved" if is_recovery else "firing"
587
+
588
+ alert = {
589
+ "status": status,
590
+ "labels": labels,
591
+ "annotations": annotations,
592
+ "startsAt": self._iso_utc(onset),
593
+ "endsAt": (
594
+ self._iso_utc(alert_data.timestamp) if is_recovery else "0001-01-01T00:00:00Z"
595
+ ),
596
+ "generatorURL": alert_data.dashboard_url or "",
597
+ "fingerprint": fingerprint,
598
+ }
599
+
600
+ return {
601
+ "version": "4",
602
+ "groupKey": f"detectkit/{alert_data.project_name or 'default'}/{alert_data.metric_name}",
603
+ "truncatedAlerts": 0,
604
+ "status": status,
605
+ "receiver": "detectkit",
606
+ "groupLabels": {"alertname": alert_data.metric_name},
607
+ "commonLabels": labels,
608
+ "commonAnnotations": annotations,
609
+ "externalURL": "",
610
+ "alerts": [alert],
611
+ }
612
+
613
+ @staticmethod
614
+ def _label_fingerprint(labels: dict[str, str]) -> str:
615
+ """Stable short hash of *labels*, sorted so key order never matters."""
616
+ label_str = "\n".join(f"{k}={v}" for k, v in sorted(labels.items()))
617
+ return hashlib.sha256(label_str.encode("utf-8")).hexdigest()[:16]
618
+
619
+ @staticmethod
620
+ def _clean_float(value: Any) -> float | None:
621
+ """``float(value)``, or ``None`` for a missing/NaN value."""
622
+ if value is None:
623
+ return None
624
+ if isinstance(value, float) and math.isnan(value):
625
+ return None
626
+ return float(value)
627
+
628
+ @staticmethod
629
+ def _bounded_value(bound: Any) -> float | None:
630
+ """A confidence bound as JSON, or ``None`` when unset/NaN/inf.
631
+
632
+ Mirrors ``BaseAlertChannel.build_context``'s ``_bounded`` check (a
633
+ NaN/inf bound means "no bound on that side", e.g. a one-sided
634
+ ManualBounds detector).
635
+ """
636
+ if bound is None or (isinstance(bound, float) and (math.isnan(bound) or math.isinf(bound))):
637
+ return None
638
+ return float(bound)
639
+
640
+ @staticmethod
641
+ def _parse_detector_params(raw: str) -> Any:
642
+ """Detector params as parsed JSON when possible, else the raw string.
643
+
644
+ ``detector_params`` is stored as a JSON string; consumers of the
645
+ structured formats want it as real JSON, not a string-within-a-string
646
+ — but a non-JSON/non-container value (or an empty string) is passed
647
+ through rather than dropped.
648
+ """
649
+ if not raw:
650
+ return None
651
+ try:
652
+ parsed = json.loads(raw)
653
+ except (ValueError, TypeError):
654
+ return raw
655
+ return parsed if isinstance(parsed, dict | list) else raw
656
+
657
+ @staticmethod
658
+ def _iso_utc(ts: Any) -> str | None:
659
+ """ISO-8601 UTC string (``2026-07-11T10:30:00Z``) for a naive-UTC
660
+ timestamp, or ``None`` when unset/unparseable."""
661
+ if ts is None:
662
+ return None
663
+ try:
664
+ if isinstance(ts, np.datetime64):
665
+ ts = ts.astype("datetime64[s]").astype(datetime)
666
+ if isinstance(ts, datetime):
667
+ if ts.tzinfo is not None:
668
+ ts = ts.astimezone(timezone.utc).replace(tzinfo=None)
669
+ return str(ts.strftime("%Y-%m-%dT%H:%M:%SZ"))
670
+ except (ValueError, TypeError, OverflowError):
671
+ return None
672
+ return None
673
+
408
674
  def _bold(self, text: str) -> str:
409
675
  """Render *text* bold in the target platform's markdown.
410
676
 
@@ -14,7 +14,7 @@ conventions.
14
14
  **Database access for _you_ (recommended, not required).** detectkit itself
15
15
  connects to the database directly via its drivers — it **never** needs an MCP to
16
16
  run. But you assist far better with **read access to the same database** (e.g. a
17
- database MCP for the project's ClickHouse / PostgreSQL / MySQL): you can inspect
17
+ database MCP for the project's ClickHouse / PostgreSQL / MySQL / MariaDB): you can inspect
18
18
  a metric's series, find real incidents to label for `dtk autotune`, sanity-check
19
19
  a metric query before running it, and confirm detections — instead of asking the
20
20
  user to run every query by hand. Without it, fall back to marking incidents
@@ -37,11 +37,26 @@ Used by `run`, `unlock`, and `clean` (drift mode). Three forms:
37
37
  (`--select "*" --exclude "metrics/staging/*"`). Metric names must be unique
38
38
  across the project; duplicates raise an error listing the conflicting files.
39
39
 
40
+ ## Exit codes
41
+
42
+ `dtk run`, `dtk autotune`, and `dtk clean` return a process exit code meant for
43
+ schedulers and CI to gate on:
44
+
45
+ | Code | Meaning |
46
+ |---|---|
47
+ | `0` | Success — every metric completed the requested steps. |
48
+ | `1` | Failure — any metric failed, the run aborted early, a startup/config/DB error occurred, or the selector matched no metrics. |
49
+ | `2` | Usage error — bad CLI arguments. |
50
+
51
+ On `dtk autotune`, a metric that's disabled in its config counts as **skipped**,
52
+ not failed, and doesn't turn the exit code non-zero by itself. Other commands
53
+ keep Click's default behavior (0 on success, non-zero on any exception).
54
+
40
55
  ## `dtk run`
41
56
 
42
57
  ```bash
43
58
  dtk run --select <sel> [--steps load,detect,alert] [--from DATE] [--to DATE] \
44
- [--full-refresh] [--force] [--profile NAME] [--report [PATH]]
59
+ [--full-refresh] [--force] [--profile NAME] [--report [PATH]] [--json]
45
60
  ```
46
61
 
47
62
  - `--steps` — which of `load`, `detect`, `alert` to run (default all); they always
@@ -66,6 +81,12 @@ dtk run --select <sel> [--steps load,detect,alert] [--from DATE] [--to DATE] \
66
81
  tables, so even a `--steps load` run can produce one. Dual-mode: bare `--report`
67
82
  → `reports/<metric>.html`; `--report <dir>` → `<dir>/<metric>.html`;
68
83
  `--report file.html` → that file.
84
+ - `--json` — human-readable progress still goes to **stderr**; **stdout** gets
85
+ exactly one JSON summary line for scripting/CI:
86
+ `{schema_version: 1, command, project, selector, steps, started_at,
87
+ finished_at, status: success|failed|error, metrics: [{name, status,
88
+ steps_completed, datapoints_loaded, anomalies_detected, alerts_sent, error}],
89
+ totals: {metrics, succeeded, failed, skipped, ...}, exit_code}`.
69
90
 
70
91
  ## `dtk autotune --select <sel>`
71
92
 
@@ -396,6 +417,12 @@ timers / Windows Task Scheduler. Always `cd` into the project first:
396
417
  Pair scheduling with `error_alerting` (in `detectkit_project.yml`) so in-process
397
418
  failures page someone; cron monitoring covers `dtk run` not running at all.
398
419
 
420
+ Beyond cron, orchestrators can gate directly on the process instead of parsing
421
+ logs: an Airflow `BashOperator`, a Dagster op, a Prefect task, or a CI job all
422
+ fail their step when `dtk run` / `dtk autotune` / `dtk clean` exits non-zero
423
+ (see "Exit codes" above), and can additionally parse `dtk run --json`'s stdout
424
+ summary for per-metric detail.
425
+
399
426
  ## Troubleshooting
400
427
 
401
428
  - **"Metric not found"** — selector doesn't match. Use the bare name
@@ -116,9 +116,10 @@ alert_channels:
116
116
 
117
117
  ### Database profiles
118
118
 
119
- > ClickHouse, PostgreSQL and MySQL are all fully supported. ClickHouse/MySQL use
120
- > two *databases*; PostgreSQL connects to one `database` and uses two *schemas*.
121
- > `dtk init --db-type {clickhouse,postgres,mysql}` scaffolds the right shape.
119
+ > ClickHouse, PostgreSQL, MySQL and MariaDB are all fully supported.
120
+ > ClickHouse/MySQL/MariaDB use two *databases*; PostgreSQL connects to one
121
+ > `database` and uses two *schemas*.
122
+ > `dtk init --db-type {clickhouse,postgres,mysql,mariadb}` scaffolds the right shape.
122
123
 
123
124
  **ClickHouse**:
124
125
  ```yaml
@@ -166,6 +167,12 @@ profiles:
166
167
  settings: {} # optional — extra pymysql.connect kwargs
167
168
  ```
168
169
 
170
+ **MariaDB**: identical fields to the MySQL block above — just spell `type:
171
+ mariadb` instead of `type: mysql`. The vendor (MySQL vs MariaDB) is
172
+ auto-detected at connect (`SELECT VERSION()`), so MariaDB gets its own
173
+ `VALUES()`-form upsert instead of MySQL 8.0.19's row-alias form.
174
+ `pip install 'detectkit[mariadb]'`.
175
+
169
176
  ### Alert channels
170
177
 
171
178
  Defined once in `profiles.yml`, referenced by name in each metric's
@@ -228,9 +235,22 @@ alert_channels:
228
235
  webhook_alerts:
229
236
  type: webhook
230
237
  webhook_url: "{{ env_var('WEBHOOK_URL') }}" # required
238
+ format: attachments # optional: attachments (default) | json | alertmanager
239
+ secret: "{{ env_var('WEBHOOK_SECRET') }}" # optional — HMAC-SHA256-signs the request body
231
240
  extra_headers: # optional
232
241
  Authorization: "Bearer {{ env_var('WEBHOOK_TOKEN') }}"
233
242
  ```
243
+ > `format` — `attachments` (default) renders the same platform-style card as
244
+ > Mattermost/Slack; `json` sends a versioned structured event
245
+ > (`schema_version: 1`; fields like `kind`/`status`/`project`/`metric`/
246
+ > `timestamp`/`value`/`expected`/`severity`/`direction`/`detector`/`rule`/
247
+ > `quorum`/`incident`/`links`/`display`); `alertmanager` sends a Prometheus
248
+ > Alertmanager webhook-receiver payload (v4) — a trigger/resolve pair sharing
249
+ > identical labels/fingerprint, `severity: critical` for anomaly/error and
250
+ > `warning` for no-data (no-data never auto-resolves). `json`/`alertmanager`
251
+ > ignore a custom `template` and add an `X-Detectkit-Event` header.
252
+ > `secret` — signs the raw request body as HMAC-SHA256 into
253
+ > `X-Detectkit-Signature-256: sha256=<hex>`, for any `format`.
234
254
 
235
255
  ## Notes
236
256
 
@@ -50,17 +50,19 @@ side by side, ask which one to set up.
50
50
 
51
51
  ## Step 1 — Pick the database backend
52
52
 
53
- **ClickHouse, PostgreSQL and MySQL are all fully supported.** Ask which one the
54
- project uses (default to ClickHouse if unsure). `dtk init --db-type
55
- {clickhouse,postgres,mysql}` scaffolds `profiles.yml` for the chosen backend.
56
- The location fields differ:
57
- - **ClickHouse** / **MySQL** — two *databases*: `internal_database` / `data_database`.
53
+ **ClickHouse, PostgreSQL, MySQL and MariaDB are all fully supported.** Ask
54
+ which one the project uses (default to ClickHouse if unsure). `dtk init
55
+ --db-type {clickhouse,postgres,mysql,mariadb}` scaffolds `profiles.yml` for the
56
+ chosen backend. The location fields differ:
57
+ - **ClickHouse** / **MySQL** / **MariaDB** — two *databases*: `internal_database` / `data_database`.
58
58
  - **PostgreSQL** — connect to a `database` (must already exist), then two
59
59
  *schemas*: `internal_schema` / `data_schema`.
60
60
 
61
- The metric query SQL dialect also differs (e.g. `toStartOfInterval` on
62
- ClickHouse vs `date_trunc`/`to_timestamp` on Postgres vs `FROM_UNIXTIME` on
63
- MySQL). Everything else detectors, alerting, the CLI — is identical.
61
+ `type: mariadb` is an identical profile alias to `type: mysql` (same location
62
+ fields; the vendor is auto-detected at connect). The metric query SQL dialect
63
+ also differs (e.g. `toStartOfInterval` on ClickHouse vs `date_trunc`/
64
+ `to_timestamp` on Postgres vs `FROM_UNIXTIME` on MySQL/MariaDB). Everything
65
+ else — detectors, alerting, the CLI — is identical.
64
66
 
65
67
  ## Step 2 — Connection details (gather, don't guess)
66
68
 
@@ -136,9 +138,11 @@ identity fields below are optional overrides.
136
138
  - **email** — `smtp_host`, `smtp_port`, `from_email`, `to_emails` (list);
137
139
  optional `from_name` (From display name, default `detectkit`);
138
140
  `smtp_username` / `smtp_password` via env_var.
139
- - **webhook** — generic `webhook_url` (required) + optional `extra_headers`
140
- (also accepts `username` / `icon_url` / `icon_emoji` / `channel`). There is
141
- no `url`, `method` or `headers` field.
141
+ - **webhook** — generic `webhook_url` (required) + optional `format`
142
+ (`attachments` default / `json` / `alertmanager`), `secret` (HMAC-SHA256-signs
143
+ the request body into `X-Detectkit-Signature-256`), and `extra_headers` (also
144
+ accepts `username` / `icon_url` / `icon_emoji` / `channel`). There is no
145
+ `url`, `method` or `headers` field.
142
146
 
143
147
  ```yaml
144
148
  # at the end of profiles.yml
@@ -239,14 +239,19 @@ def run_autotune(
239
239
  force: bool,
240
240
  dry_run: bool,
241
241
  report_path: str | None = None,
242
- ) -> None:
243
- """Auto-tune each selected metric's detector configuration."""
242
+ ) -> int:
243
+ """Auto-tune each selected metric's detector configuration.
244
+
245
+ Returns:
246
+ 0 if every metric tuned or was explicitly skipped, 1 if any startup
247
+ step failed or any metric's tuning attempt failed.
248
+ """
244
249
  from_dt = parse_date(from_date) if from_date else None
245
250
  to_dt = parse_date(to_date) if to_date else None
246
251
 
247
252
  loaded = _load_project(profile)
248
253
  if loaded is None:
249
- return
254
+ return 1
250
255
  project_root, _project_config, internal_manager, _db_manager = loaded
251
256
  project_name = getattr(_project_config, "name", None)
252
257
 
@@ -254,15 +259,17 @@ def run_autotune(
254
259
  metrics = select_metrics(select, project_root)
255
260
  except ValueError as exc:
256
261
  click.echo(click.style(f"Error: {exc}", fg="red", bold=True))
257
- return
262
+ return 1
258
263
  if not metrics:
259
264
  click.echo(click.style(f"No metrics found matching selector: {select}", fg="yellow"))
260
- return
265
+ return 1
261
266
 
262
267
  click.echo(f"Found {len(metrics)} metric(s) to tune")
263
- succeeded = 0
268
+ ok_count = 0
269
+ skipped_count = 0
270
+ failed_count = 0
264
271
  for metric_path, config in metrics:
265
- ok = _tune_one(
272
+ status = _tune_one(
266
273
  metric_path=metric_path,
267
274
  config=config,
268
275
  project_root=project_root,
@@ -277,10 +284,20 @@ def run_autotune(
277
284
  project_name=project_name,
278
285
  project_loading_delay=getattr(_project_config, "loading_delay", None),
279
286
  )
280
- if ok:
281
- succeeded += 1
282
-
283
- echo_done(f"Tuned {len(metrics)} metric(s), {succeeded} succeeded.")
287
+ if status == "ok":
288
+ ok_count += 1
289
+ elif status == "skipped":
290
+ skipped_count += 1
291
+ else:
292
+ failed_count += 1
293
+
294
+ parts = [f"{ok_count} succeeded"]
295
+ if skipped_count:
296
+ parts.append(f"{skipped_count} skipped")
297
+ if failed_count:
298
+ parts.append(f"{failed_count} failed")
299
+ echo_done(f"Tuned {len(metrics)} metric(s), {', '.join(parts)}.")
300
+ return 1 if failed_count else 0
284
301
 
285
302
 
286
303
  def _tune_one(
@@ -298,13 +315,19 @@ def _tune_one(
298
315
  report_path: str | None = None,
299
316
  project_name: str | None = None,
300
317
  project_loading_delay: str | int | None = None,
301
- ) -> bool:
302
- """Tune one metric end to end; return True on success."""
318
+ ) -> str:
319
+ """Tune one metric end to end.
320
+
321
+ Returns:
322
+ ``"ok"`` on success, ``"skipped"`` when autotune is disabled for this
323
+ metric (an explicit config choice, not a failure), or ``"failed"``
324
+ (no datapoints, a validation/engine error, or an unexpected exception).
325
+ """
303
326
  name = config.name
304
327
  autotune_cfg = config.autotune or AutoTuneConfig()
305
328
  if not autotune_cfg.enabled:
306
329
  echo_noop(name, "autotune disabled in config")
307
- return False
330
+ return "skipped"
308
331
 
309
332
  interval_seconds = config.get_interval().seconds
310
333
 
@@ -312,14 +335,14 @@ def _tune_one(
312
335
  data = internal_manager.load_datapoints(name, from_timestamp=from_dt, to_timestamp=to_dt)
313
336
  if len(data["timestamp"]) == 0:
314
337
  echo_noop(name, "no datapoints — run `dtk run --select " + name + " --steps load` first")
315
- return False
338
+ return "failed"
316
339
 
317
340
  click.echo(click.style(f"Tuning metric: {name}", fg="cyan", bold=True))
318
341
  click.echo(f" Config file: {metric_path.relative_to(project_root)}")
319
342
 
320
343
  if not force and not internal_manager.acquire_lock(name, "pipeline", "pipeline"):
321
344
  echo_error(name, "locked by another run (use --force or `dtk unlock`)")
322
- return False
345
+ return "failed"
323
346
 
324
347
  try:
325
348
  data = cap_history(data, autotune_cfg.max_history)
@@ -375,7 +398,7 @@ def _tune_one(
375
398
  project_loading_delay=project_loading_delay,
376
399
  )
377
400
  internal_manager.release_lock(name, "pipeline", "pipeline", status="completed")
378
- return True
401
+ return "ok"
379
402
 
380
403
  except (AutoTuneError, ValueError, FileNotFoundError) as exc:
381
404
  internal_manager.release_lock(
@@ -384,13 +407,13 @@ def _tune_one(
384
407
  if not dry_run:
385
408
  _save_failed_run(internal_manager, name, interval_seconds, scoring_override, str(exc))
386
409
  echo_error(name, str(exc))
387
- return False
410
+ return "failed"
388
411
  except Exception as exc: # noqa: BLE001 — never leave a held lock behind
389
412
  internal_manager.release_lock(
390
413
  name, "pipeline", "pipeline", status="failed", error_message=str(exc)
391
414
  )
392
415
  echo_error(name, f"unexpected error: {exc}")
393
- return False
416
+ return "failed"
394
417
 
395
418
 
396
419
  def _finalize(