detectkit 0.60.0__tar.gz → 0.62.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 (169) hide show
  1. {detectkit-0.60.0/detectkit.egg-info → detectkit-0.62.0}/PKG-INFO +19 -1
  2. {detectkit-0.60.0 → detectkit-0.62.0}/README.md +15 -0
  3. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/__init__.py +1 -1
  4. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/cli/assets/claude/rules/cli.md +35 -0
  5. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/cli/assets/claude/rules/metrics.md +14 -0
  6. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/cli/assets/claude/rules/project.md +21 -0
  7. detectkit-0.62.0/detectkit/cli/commands/mcp.py +31 -0
  8. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/cli/commands/run.py +135 -68
  9. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/cli/main.py +56 -0
  10. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/config/metric_config.py +36 -0
  11. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/config/profile.py +22 -3
  12. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/config/project_config.py +15 -0
  13. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/database/_sql_manager.py +9 -2
  14. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/database/clickhouse_manager.py +11 -5
  15. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/database/duckdb_manager.py +25 -1
  16. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/database/mysql_manager.py +5 -0
  17. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/database/postgres_manager.py +5 -0
  18. detectkit-0.62.0/detectkit/loaders/errors.py +29 -0
  19. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/loaders/metric_loader.py +30 -4
  20. detectkit-0.62.0/detectkit/mcp/__init__.py +47 -0
  21. detectkit-0.62.0/detectkit/mcp/context.py +329 -0
  22. detectkit-0.62.0/detectkit/mcp/errors.py +27 -0
  23. detectkit-0.62.0/detectkit/mcp/serialize.py +90 -0
  24. detectkit-0.62.0/detectkit/mcp/server.py +263 -0
  25. detectkit-0.62.0/detectkit/mcp/tools.py +502 -0
  26. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/orchestration/task_manager/_base.py +80 -0
  27. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/orchestration/task_manager/_load_step.py +6 -1
  28. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/utils/datetime_utils.py +11 -2
  29. {detectkit-0.60.0 → detectkit-0.62.0/detectkit.egg-info}/PKG-INFO +19 -1
  30. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit.egg-info/SOURCES.txt +8 -0
  31. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit.egg-info/requires.txt +4 -0
  32. {detectkit-0.60.0 → detectkit-0.62.0}/pyproject.toml +7 -0
  33. {detectkit-0.60.0 → detectkit-0.62.0}/LICENSE +0 -0
  34. {detectkit-0.60.0 → detectkit-0.62.0}/MANIFEST.in +0 -0
  35. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/alerting/__init__.py +0 -0
  36. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/alerting/channels/__init__.py +0 -0
  37. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/alerting/channels/base.py +0 -0
  38. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/alerting/channels/branding.py +0 -0
  39. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/alerting/channels/discord.py +0 -0
  40. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/alerting/channels/email.py +0 -0
  41. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/alerting/channels/factory.py +0 -0
  42. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/alerting/channels/googlechat.py +0 -0
  43. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/alerting/channels/mattermost.py +0 -0
  44. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/alerting/channels/ntfy.py +0 -0
  45. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/alerting/channels/slack.py +0 -0
  46. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/alerting/channels/teams.py +0 -0
  47. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/alerting/channels/telegram.py +0 -0
  48. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/alerting/channels/webhook.py +0 -0
  49. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
  50. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/alerting/orchestrator/_base.py +0 -0
  51. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
  52. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
  53. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
  54. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
  55. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/alerting/orchestrator/_replay.py +0 -0
  56. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/alerting/orchestrator/_types.py +0 -0
  57. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
  58. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/autotune/__init__.py +0 -0
  59. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/autotune/_base.py +0 -0
  60. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/autotune/_types.py +0 -0
  61. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/autotune/autotuner.py +0 -0
  62. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/autotune/axis_spec.py +0 -0
  63. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/autotune/config_emitter.py +0 -0
  64. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/autotune/crossval.py +0 -0
  65. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/autotune/detector_select.py +0 -0
  66. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/autotune/distribution.py +0 -0
  67. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/autotune/grid_search.py +0 -0
  68. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/autotune/labels.py +0 -0
  69. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/autotune/result.py +0 -0
  70. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/autotune/runner.py +0 -0
  71. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/autotune/scoring.py +0 -0
  72. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/autotune/seasonality_search.py +0 -0
  73. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/autotune/settings.py +0 -0
  74. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/autotune/window_select.py +0 -0
  75. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/cli/__init__.py +0 -0
  76. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/cli/_output.py +0 -0
  77. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/cli/assets/claude/CLAUDE.section.md +0 -0
  78. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/cli/assets/claude/rules/alerting.md +0 -0
  79. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/cli/assets/claude/rules/autotune.md +0 -0
  80. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
  81. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
  82. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +0 -0
  83. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
  84. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
  85. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
  86. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/cli/assets/claude/skills/dtk-tune/SKILL.md +0 -0
  87. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/cli/commands/__init__.py +0 -0
  88. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/cli/commands/autotune.py +0 -0
  89. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/cli/commands/clean.py +0 -0
  90. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/cli/commands/init.py +0 -0
  91. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/cli/commands/init_claude.py +0 -0
  92. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/cli/commands/osi.py +0 -0
  93. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/cli/commands/test_alert.py +0 -0
  94. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/cli/commands/tune.py +0 -0
  95. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/cli/commands/ui.py +0 -0
  96. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/cli/commands/unlock.py +0 -0
  97. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/config/__init__.py +0 -0
  98. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/config/metric_io.py +0 -0
  99. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/config/validator.py +0 -0
  100. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/core/__init__.py +0 -0
  101. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/core/interval.py +0 -0
  102. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/core/models.py +0 -0
  103. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/database/__init__.py +0 -0
  104. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/database/internal_tables/__init__.py +0 -0
  105. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
  106. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
  107. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/database/internal_tables/_base.py +0 -0
  108. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
  109. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/database/internal_tables/_detections.py +0 -0
  110. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
  111. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/database/internal_tables/_metrics.py +0 -0
  112. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/database/internal_tables/_schema.py +0 -0
  113. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/database/internal_tables/_tasks.py +0 -0
  114. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/database/internal_tables/manager.py +0 -0
  115. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/database/manager.py +0 -0
  116. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/database/tables.py +0 -0
  117. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/detectors/__init__.py +0 -0
  118. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/detectors/base.py +0 -0
  119. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/detectors/factory.py +0 -0
  120. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/detectors/seasonality.py +0 -0
  121. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/detectors/statistical/__init__.py +0 -0
  122. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/detectors/statistical/_windowed.py +0 -0
  123. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/detectors/statistical/autoreg.py +0 -0
  124. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/detectors/statistical/iqr.py +0 -0
  125. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/detectors/statistical/mad.py +0 -0
  126. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
  127. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/detectors/statistical/zscore.py +0 -0
  128. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/loaders/__init__.py +0 -0
  129. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/loaders/query_template.py +0 -0
  130. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/orchestration/__init__.py +0 -0
  131. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/orchestration/error_dispatch.py +0 -0
  132. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
  133. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
  134. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
  135. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/orchestration/task_manager/_types.py +0 -0
  136. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/orchestration/task_manager/manager.py +0 -0
  137. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/reporting/__init__.py +0 -0
  138. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/reporting/assets/report.js +0 -0
  139. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/reporting/builder.py +0 -0
  140. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/reporting/html_report.py +0 -0
  141. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/semantic/__init__.py +0 -0
  142. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/semantic/errors.py +0 -0
  143. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/semantic/exporter.py +0 -0
  144. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/semantic/importer.py +0 -0
  145. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/semantic/osi_model.py +0 -0
  146. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/semantic/query_gen.py +0 -0
  147. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/tuning/__init__.py +0 -0
  148. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/tuning/assets/tune.js +0 -0
  149. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/tuning/config_writer.py +0 -0
  150. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/tuning/html.py +0 -0
  151. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/tuning/payload.py +0 -0
  152. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/tuning/server.py +0 -0
  153. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/ui/__init__.py +0 -0
  154. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/ui/assets/ui.js +0 -0
  155. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/ui/html.py +0 -0
  156. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/ui/jobs.py +0 -0
  157. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/ui/metric_files.py +0 -0
  158. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/ui/overview.py +0 -0
  159. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/ui/server.py +0 -0
  160. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/utils/__init__.py +0 -0
  161. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/utils/env_interpolation.py +0 -0
  162. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/utils/json_utils.py +0 -0
  163. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit/utils/stats.py +0 -0
  164. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit.egg-info/dependency_links.txt +0 -0
  165. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit.egg-info/entry_points.txt +0 -0
  166. {detectkit-0.60.0 → detectkit-0.62.0}/detectkit.egg-info/top_level.txt +0 -0
  167. {detectkit-0.60.0 → detectkit-0.62.0}/requirements.txt +0 -0
  168. {detectkit-0.60.0 → detectkit-0.62.0}/setup.cfg +0 -0
  169. {detectkit-0.60.0 → detectkit-0.62.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.60.0
3
+ Version: 0.62.0
4
4
  Summary: Metric monitoring with automatic anomaly detection
5
5
  Author: detectkit team
6
6
  License: MIT
@@ -54,6 +54,8 @@ Requires-Dist: prophet>=1.1.0; extra == "advanced-detectors"
54
54
  Requires-Dist: timesfm>=0.1.0; extra == "advanced-detectors"
55
55
  Provides-Extra: osi
56
56
  Requires-Dist: sqlglot>=20.0; extra == "osi"
57
+ Provides-Extra: mcp
58
+ Requires-Dist: mcp<2,>=1.27; extra == "mcp"
57
59
  Provides-Extra: all
58
60
  Requires-Dist: clickhouse-driver>=0.2.0; extra == "all"
59
61
  Requires-Dist: psycopg2-binary>=2.9.0; extra == "all"
@@ -62,6 +64,7 @@ Requires-Dist: duckdb>=1.1; extra == "all"
62
64
  Requires-Dist: prophet>=1.1.0; extra == "all"
63
65
  Requires-Dist: timesfm>=0.1.0; extra == "all"
64
66
  Requires-Dist: sqlglot>=20.0; extra == "all"
67
+ Requires-Dist: mcp<2,>=1.27; extra == "all"
65
68
  Provides-Extra: dev
66
69
  Requires-Dist: pytest>=7.0; extra == "dev"
67
70
  Requires-Dist: pytest-cov>=4.0; extra == "dev"
@@ -104,6 +107,7 @@ Dynamic: license-file
104
107
  - **Alert lifecycle** — consecutive anomalies, fraction-of-window rule (`anomaly_window` + `min_anomaly_share`), cooldown, recovery notifications, no-data alerts
105
108
  - **Project-level error alerts** — catch DB outages and pipeline crashes once per run
106
109
  - **Database agnostic** — ClickHouse, PostgreSQL, MySQL/MariaDB, DuckDB (no server needed)
110
+ - **Hybrid mode** — read metric SQL from a billed-per-query warehouse while all pipeline state lives in a cheap local database
107
111
  - **Idempotent** — resume from interruptions, no duplicate processing
108
112
  - **CLI** — `dtk init`, `dtk run --select`, `dtk unlock`, `dtk clean`, tag-based selectors
109
113
  - **AI-native onboarding** — `dtk init-claude` sets up Claude Code context (CLAUDE.md + rules + three skills) so an assistant can scaffold metrics, configure databases, and file feedback upstream
@@ -198,6 +202,18 @@ for r in results:
198
202
  print(f"Anomaly at {r.timestamp}: {r.value}")
199
203
  ```
200
204
 
205
+ ## AI & automation
206
+
207
+ - **[MCP server](docs/guides/mcp.md)** (`dtk mcp`) — a read-only [Model Context
208
+ Protocol](https://modelcontextprotocol.io) server so an AI assistant (Claude
209
+ Code, Claude Desktop, any MCP client) can query a project's metric configs,
210
+ loaded data, detector results, replayed alert history and autotune runs
211
+ directly — no write access to the pipeline.
212
+ - **[GitHub Action](docs/guides/github-action.md)** — a composite action that
213
+ runs `dtk run`/`autotune`/`clean` as a CI check or scheduled job and gates
214
+ on detectkit's own exit-code contract, with the `--json` run summary wired
215
+ to step outputs.
216
+
201
217
  ## Documentation
202
218
 
203
219
  - **[dtk.pipelab.dev](https://dtk.pipelab.dev)** — full docs site, guides, reference, and a live in-browser playground
@@ -206,6 +222,8 @@ for r in results:
206
222
  - [Detectors Guide](docs/guides/detectors.md) — choosing the right detector
207
223
  - [Alerting Guide](docs/guides/alerting.md) — channels, mentions, cooldown, recovery
208
224
  - [Reading Alerts](docs/guides/reading-alerts.md) — what a received alert means (for stakeholders)
225
+ - [MCP Guide](docs/guides/mcp.md) — read-only AI-assistant access with `dtk mcp`
226
+ - [GitHub Action Guide](docs/guides/github-action.md) — run the pipeline in CI
209
227
  - [CLI Reference](docs/reference/cli.md) — command-line documentation
210
228
  - [Examples](docs/examples/) — real-world monitoring scenarios
211
229
  - [Changelog](CHANGELOG.md) — version history
@@ -25,6 +25,7 @@
25
25
  - **Alert lifecycle** — consecutive anomalies, fraction-of-window rule (`anomaly_window` + `min_anomaly_share`), cooldown, recovery notifications, no-data alerts
26
26
  - **Project-level error alerts** — catch DB outages and pipeline crashes once per run
27
27
  - **Database agnostic** — ClickHouse, PostgreSQL, MySQL/MariaDB, DuckDB (no server needed)
28
+ - **Hybrid mode** — read metric SQL from a billed-per-query warehouse while all pipeline state lives in a cheap local database
28
29
  - **Idempotent** — resume from interruptions, no duplicate processing
29
30
  - **CLI** — `dtk init`, `dtk run --select`, `dtk unlock`, `dtk clean`, tag-based selectors
30
31
  - **AI-native onboarding** — `dtk init-claude` sets up Claude Code context (CLAUDE.md + rules + three skills) so an assistant can scaffold metrics, configure databases, and file feedback upstream
@@ -119,6 +120,18 @@ for r in results:
119
120
  print(f"Anomaly at {r.timestamp}: {r.value}")
120
121
  ```
121
122
 
123
+ ## AI & automation
124
+
125
+ - **[MCP server](docs/guides/mcp.md)** (`dtk mcp`) — a read-only [Model Context
126
+ Protocol](https://modelcontextprotocol.io) server so an AI assistant (Claude
127
+ Code, Claude Desktop, any MCP client) can query a project's metric configs,
128
+ loaded data, detector results, replayed alert history and autotune runs
129
+ directly — no write access to the pipeline.
130
+ - **[GitHub Action](docs/guides/github-action.md)** — a composite action that
131
+ runs `dtk run`/`autotune`/`clean` as a CI check or scheduled job and gates
132
+ on detectkit's own exit-code contract, with the `--json` run summary wired
133
+ to step outputs.
134
+
122
135
  ## Documentation
123
136
 
124
137
  - **[dtk.pipelab.dev](https://dtk.pipelab.dev)** — full docs site, guides, reference, and a live in-browser playground
@@ -127,6 +140,8 @@ for r in results:
127
140
  - [Detectors Guide](docs/guides/detectors.md) — choosing the right detector
128
141
  - [Alerting Guide](docs/guides/alerting.md) — channels, mentions, cooldown, recovery
129
142
  - [Reading Alerts](docs/guides/reading-alerts.md) — what a received alert means (for stakeholders)
143
+ - [MCP Guide](docs/guides/mcp.md) — read-only AI-assistant access with `dtk mcp`
144
+ - [GitHub Action Guide](docs/guides/github-action.md) — run the pipeline in CI
130
145
  - [CLI Reference](docs/reference/cli.md) — command-line documentation
131
146
  - [Examples](docs/examples/) — real-world monitoring scenarios
132
147
  - [Changelog](CHANGELOG.md) — version history
@@ -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.60.0"
7
+ __version__ = "0.62.0"
8
8
 
9
9
  from detectkit.core.interval import Interval
10
10
  from detectkit.core.models import ColumnDefinition, TableModel
@@ -18,6 +18,7 @@ Run all commands from a project directory (the one containing
18
18
  | `dtk clean --select <sel>` | Prune internal data that no longer matches the config |
19
19
  | `dtk osi import <model>` | Scaffold a native metric from an OSI semantic-model metric (see "OSI interop") |
20
20
  | `dtk osi export` | Publish metrics into an OSI fragment (config in `custom_extensions[detectkit]`) |
21
+ | `dtk mcp` | Serve a **read-only** MCP server for an AI assistant (metrics, data, alerts, autotune history — no writes) |
21
22
  | `dtk --version` | Show installed detectkit version |
22
23
 
23
24
  ## Selectors (`--select` / `-s`)
@@ -376,6 +377,40 @@ The `dtk ui` metric Builder's **From OSI** sub-tab does the import
376
377
  interactively (paste a model, pick a metric/target, Compile) through the
377
378
  same code path — see "Managing metrics from the UI" above.
378
379
 
380
+ ## `dtk mcp`
381
+
382
+ A **read-only** [Model Context Protocol](https://modelcontextprotocol.io)
383
+ server, exposing a project to *another* AI assistant (an MCP client — Claude
384
+ Code, Claude Desktop, an IDE extension) rather than being one itself. It is a
385
+ separate, additive command (needs `pip install 'detectkit[mcp]'`) that
386
+ contains **zero write paths**: no config edits, no label writes, no pipeline
387
+ runs, no schema creation. Ten tools: `list_metrics`, `get_metric`,
388
+ `get_metric_status`, `get_project_status`, `query_datapoints`,
389
+ `query_detections`, `replay_alerts` (reconstructs the alert timeline the same
390
+ way `dtk run --report` does — never reads `_dtk_alert_states`, never
391
+ dispatches), `get_autotune_history`, `get_incidents`, `get_server_info`.
392
+ Channel connection details/secrets in `profiles.yml` are never exposed —
393
+ `get_metric` lists alerting channels by name only.
394
+
395
+ ```bash
396
+ dtk mcp --project-dir /abs/path/to/project [--select <sel>] [--profile NAME]
397
+ ```
398
+
399
+ - `--project-dir` — resolved before `$DETECTKIT_PROJECT_DIR` before the
400
+ current directory (an MCP client passes no cwd, so at least one must
401
+ resolve; each mechanism searches upward for `detectkit_project.yml`).
402
+ - `--select` — scopes the whole session: every tool that names a metric
403
+ refuses one outside this set (an access-control boundary, not just a
404
+ default).
405
+
406
+ This is a **different thing** from the generic database MCP CLAUDE.md
407
+ recommends you (the assistant working in this repo) optionally connect to for
408
+ your own read access to the project's DB — `dtk mcp` answers in detectkit's
409
+ own terms (replayed alert decisions, detector identity, labeled incidents),
410
+ not raw SQL, and it's for exposing *this* project to a *separate* assistant
411
+ session/client (e.g. Claude Desktop), not something you need to launch or
412
+ connect to yourself. `dtk mcp --help` has the full flag reference.
413
+
379
414
  ## Common workflows
380
415
 
381
416
  ```bash
@@ -11,6 +11,7 @@ keep them in sync. Detectors are covered in `detectors.md`, alerting in
11
11
  name: api_response_time # required, unique across the project
12
12
  description: API p95 latency # optional, shown in alerts
13
13
  profile: prod # optional, overrides project default_profile
14
+ source_profile: warehouse # optional — hybrid mode: source-DB profile for THIS metric's SQL (see below)
14
15
  enabled: true # optional, false → skipped by `dtk run`
15
16
  tags: [critical, api] # optional, used by `--select tag:<t>`
16
17
  ai_context: # optional — OSI-compatible grounding (descriptive only)
@@ -67,6 +68,19 @@ false_alert_budget: 0.3 # optional — `dtk tune` target false-alert rate
67
68
  project-wide default; unset → project, then a built-in `0.5`. Tuning-only — it never
68
69
  affects the load/detect/alert pipeline.
69
70
 
71
+ `source_profile` is **hybrid mode** for this one metric: the `profiles.yml`
72
+ profile whose database runs *this metric's* SQL query, while `_dtk_*` state
73
+ (datapoints, detections, task locks, alert state) stays in the active
74
+ **state** profile — the one `dtk run` is already connected to for everything
75
+ else. Overrides the project-wide `source_profile` (`project.md`); resolves
76
+ **metric → project → unset** (unset on both = today's behavior — one profile
77
+ for everything). Not the same field as `profile` above (`profile` is dead —
78
+ not applied at runtime by `dtk run`; `source_profile` is live). Only the
79
+ metric's **load** step ever connects to it — detect/alert and every other
80
+ command don't — though every `dtk run` fail-fast validates the resolved name
81
+ up front regardless of `--steps` (a typo exits 1 before any pipeline work).
82
+ See the [Hybrid Mode guide](https://dtk.pipelab.dev/guides/hybrid-mode/).
83
+
70
84
  `ai_context` is **OSI-compatible grounding** you can add to any metric with **no
71
85
  OSI model needed** — the metric's business meaning (`instructions`), alternative
72
86
  names (`synonyms`) and example values (`examples`), mirroring the
@@ -33,6 +33,7 @@ timeouts: # per-step, seconds
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
35
  loading_delay: null # optional — default data-maturity delay; per-metric override wins
36
+ source_profile: null # optional — hybrid mode: default source-DB profile; per-metric override wins
36
37
 
37
38
  error_alerting: # optional, see below
38
39
  enabled: false
@@ -50,6 +51,26 @@ overrides it (`0` opts that metric out); see `metrics.md` for the full
50
51
  load/no-data behavior and the detection-latency trade-off. Only set this
51
52
  project-wide when your metrics genuinely share the same upstream schedule.
52
53
 
54
+ `source_profile` is **hybrid mode**: a project-wide default `profiles.yml`
55
+ profile whose database runs metric SQL, while *every* `_dtk_*` table
56
+ (datapoints, detections, task locks, alert state) stays in the active
57
+ **state** profile — the one `dtk run` is already connected to for everything
58
+ else. A per-metric `source_profile` overrides it (`metrics.md`); resolves
59
+ **metric → project → unset**, same precedence as `loading_delay`. Unset on
60
+ both = today's behavior (one profile does everything). Only the LOAD step's
61
+ metric-SQL query is affected — detect/alert and every other command
62
+ (`dtk autotune`/`tune`/`ui`/`clean`/`unlock`) only ever touch the state
63
+ profile. A source-side failure raises `SourceDatabaseError` (message leads
64
+ with `source database (profile '<name>'): ...`), distinct from a plain
65
+ exception for a state-side failure — useful for telling a warehouse outage
66
+ apart from a state-DB outage in `error_alerting`'s `{error_type}` /
67
+ `{error_message}`. `dtk run` validates every resolved `source_profile` name
68
+ against `profiles.yml` before opening any connection (unknown name → exit
69
+ `1`, no partial run). Full config example + operational caveats (connecting a
70
+ profile always runs `CREATE DATABASE`/`SCHEMA IF NOT EXISTS` for its own
71
+ `internal_*` location too, even a source-only one) in the [Hybrid Mode
72
+ guide](https://dtk.pipelab.dev/guides/hybrid-mode/).
73
+
53
74
  ### `alert_help_url` — "How to read this alert" link
54
75
 
55
76
  Every default-rendered alert on every channel carries a `How to read this alert`
@@ -0,0 +1,31 @@
1
+ """Implementation of 'dtk mcp' — the read-only Model Context Protocol server.
2
+
3
+ Isolated from the load/detect/alert pipeline: this module imports only
4
+ ``click`` at load time. Everything else — :mod:`detectkit.mcp.server` and the
5
+ optional ``mcp`` SDK it lazily guards — is imported inside :func:`run_mcp`'s
6
+ body, so `import detectkit.cli.commands.mcp` never pulls in the SDK either.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import click
12
+
13
+
14
+ def run_mcp(*, project_dir: str | None, select: str, profile: str | None) -> None:
15
+ """Resolve the project and serve the read-only MCP server over stdio.
16
+
17
+ Args:
18
+ project_dir: Explicit project directory (searched upward for
19
+ ``detectkit_project.yml``). Falls back to ``$DETECTKIT_PROJECT_DIR``,
20
+ then to searching upward from the current directory.
21
+ select: Selector scoping which metrics the server exposes.
22
+ profile: Profile name to use (defaults to the project's
23
+ ``default_profile``).
24
+ """
25
+ from detectkit.mcp.errors import McpError
26
+ from detectkit.mcp.server import run_server
27
+
28
+ try:
29
+ run_server(project_dir=project_dir, selector=select, profile=profile)
30
+ except McpError as exc:
31
+ raise click.ClickException(str(exc)) from exc
@@ -13,7 +13,7 @@ from typing import Any
13
13
 
14
14
  import click
15
15
 
16
- from detectkit.config.metric_config import MetricConfig
16
+ from detectkit.config.metric_config import MetricConfig, resolve_source_profile
17
17
  from detectkit.config.profile import ProfilesConfig
18
18
  from detectkit.config.project_config import ProjectConfig
19
19
  from detectkit.config.validator import (
@@ -326,6 +326,21 @@ def _run_impl(
326
326
  summary["error"] = f"Error loading profiles.yml: {e}"
327
327
  return 1
328
328
 
329
+ # Fail-fast: every selected metric's RESOLVED source_profile (hybrid
330
+ # mode) must name a real profile. This is a cheap name check — no
331
+ # connections opened — so a typo'd source_profile fails the whole run
332
+ # up front instead of surfacing deep inside whichever metric's LOAD step
333
+ # happens to hit it first.
334
+ source_profile_error = _validate_source_profiles(metrics, project_config, profiles_config)
335
+ if source_profile_error:
336
+ # A config typo, not an outage: exit 1 without paging error_alerting,
337
+ # consistent with the sibling config-error paths (bad selector,
338
+ # unparseable profiles.yml). The error alert channel is reserved for
339
+ # DB-down / DDL / runtime failures.
340
+ click.echo(click.style(f"Error: {source_profile_error}", fg="red", bold=True))
341
+ summary["error"] = f"Error: {source_profile_error}"
342
+ return 1
343
+
329
344
  # Create database manager
330
345
  try:
331
346
  db_manager = profiles_config.create_manager(profile)
@@ -372,84 +387,136 @@ def _run_impl(
372
387
  summary["error"] = f"Error initializing internal tables: {e}"
373
388
  return 1
374
389
 
390
+ # The active STATE profile's actual name (mirrors ProfilesConfig.get_profile's
391
+ # own None -> default_profile resolution) — lets the task manager recognize
392
+ # a metric's source_profile that happens to name the state profile itself,
393
+ # and reuse db_manager instead of opening a duplicate connection.
394
+ state_profile_name: str | None
395
+ if profile is not None:
396
+ state_profile_name = profile
397
+ else:
398
+ state_profile_name = getattr(profiles_config, "default_profile", None)
399
+
375
400
  # Create task manager
376
401
  task_manager = TaskManager(
377
402
  internal_manager=internal_manager,
378
403
  db_manager=db_manager,
379
404
  profiles_config=profiles_config,
380
405
  project_config=project_config,
406
+ state_profile_name=state_profile_name,
381
407
  )
382
408
 
383
- # Process each metric
384
- for index, (metric_path, config) in enumerate(metrics):
385
- result = process_metric(
386
- metric_path=metric_path,
387
- config=config,
388
- project_root=project_root,
389
- task_manager=task_manager,
390
- steps=step_list,
391
- from_date=from_dt,
392
- to_date=to_dt,
393
- full_refresh=full_refresh,
394
- force=force,
395
- )
396
- summary["metrics"].append(
397
- {
398
- "name": config.name,
399
- "status": _status_str(result["status"]),
400
- "steps_completed": [s.value for s in result["steps_completed"]],
401
- "datapoints_loaded": result["datapoints_loaded"],
402
- "anomalies_detected": result["anomalies_detected"],
403
- "alerts_sent": result["alerts_sent"],
404
- "error": result["error"],
405
- }
406
- )
407
-
408
- # Project-level error alert was dispatched — stop processing the
409
- # rest of the metrics. The DB / source is presumed unreachable;
410
- # subsequent metrics would all fail with the same error.
411
- if result.get("abort_run"):
412
- click.echo(
413
- click.style(
414
- "✗ Aborting run after project error alert. " "Remaining metrics skipped.",
415
- fg="red",
416
- bold=True,
417
- )
409
+ try:
410
+ # Process each metric
411
+ for index, (metric_path, config) in enumerate(metrics):
412
+ result = process_metric(
413
+ metric_path=metric_path,
414
+ config=config,
415
+ project_root=project_root,
416
+ task_manager=task_manager,
417
+ steps=step_list,
418
+ from_date=from_dt,
419
+ to_date=to_dt,
420
+ full_refresh=full_refresh,
421
+ force=force,
422
+ )
423
+ summary["metrics"].append(
424
+ {
425
+ "name": config.name,
426
+ "status": _status_str(result["status"]),
427
+ "steps_completed": [s.value for s in result["steps_completed"]],
428
+ "datapoints_loaded": result["datapoints_loaded"],
429
+ "anomalies_detected": result["anomalies_detected"],
430
+ "alerts_sent": result["alerts_sent"],
431
+ "error": result["error"],
432
+ }
418
433
  )
419
- summary["aborted"] = True
420
- for _, skipped_config in metrics[index + 1 :]:
421
- summary["metrics"].append(
422
- {
423
- "name": skipped_config.name,
424
- "status": "skipped",
425
- "steps_completed": [],
426
- "datapoints_loaded": 0,
427
- "anomalies_detected": 0,
428
- "alerts_sent": 0,
429
- "error": None,
430
- }
431
- )
432
- break
433
434
 
434
- # Optional: emit a self-contained HTML report from the freshly-persisted
435
- # internal tables (values + bands + anomalies + replayed alerts).
436
- if report_path is not None:
437
- try:
438
- emit_metric_report(
439
- config=config,
440
- project_root=project_root,
441
- internal_manager=internal_manager,
442
- report_path=report_path,
443
- project_name=getattr(project_config, "name", None),
444
- project_loading_delay=getattr(project_config, "loading_delay", None),
445
- from_dt=from_dt,
446
- to_dt=to_dt,
435
+ # Project-level error alert was dispatched stop processing the
436
+ # rest of the metrics. The DB / source is presumed unreachable;
437
+ # subsequent metrics would all fail with the same error.
438
+ if result.get("abort_run"):
439
+ click.echo(
440
+ click.style(
441
+ "✗ Aborting run after project error alert. " "Remaining metrics skipped.",
442
+ fg="red",
443
+ bold=True,
444
+ )
447
445
  )
448
- except Exception as report_error: # never fail the run on a report
449
- click.echo(click.style(f" │ Report skipped: {report_error}", fg="yellow"))
450
-
451
- failed = any(m["status"] == "failed" for m in summary["metrics"])
452
- return 1 if (failed or summary["aborted"]) else 0
446
+ summary["aborted"] = True
447
+ for _, skipped_config in metrics[index + 1 :]:
448
+ summary["metrics"].append(
449
+ {
450
+ "name": skipped_config.name,
451
+ "status": "skipped",
452
+ "steps_completed": [],
453
+ "datapoints_loaded": 0,
454
+ "anomalies_detected": 0,
455
+ "alerts_sent": 0,
456
+ "error": None,
457
+ }
458
+ )
459
+ break
460
+
461
+ # Optional: emit a self-contained HTML report from the freshly-persisted
462
+ # internal tables (values + bands + anomalies + replayed alerts).
463
+ if report_path is not None:
464
+ try:
465
+ emit_metric_report(
466
+ config=config,
467
+ project_root=project_root,
468
+ internal_manager=internal_manager,
469
+ report_path=report_path,
470
+ project_name=getattr(project_config, "name", None),
471
+ project_loading_delay=getattr(project_config, "loading_delay", None),
472
+ from_dt=from_dt,
473
+ to_dt=to_dt,
474
+ )
475
+ except Exception as report_error: # never fail the run on a report
476
+ click.echo(click.style(f" │ Report skipped: {report_error}", fg="yellow"))
477
+
478
+ failed = any(m["status"] == "failed" for m in summary["metrics"])
479
+ return 1 if (failed or summary["aborted"]) else 0
480
+ finally:
481
+ # Close every pooled hybrid-mode SOURCE manager (never db_manager
482
+ # itself — this function owns that connection's lifecycle, and it
483
+ # was never explicitly closed here either, before or after hybrid
484
+ # mode existed). Duck-typed lookup: a test double standing in for
485
+ # TaskManager need not implement it.
486
+ close_sources = getattr(task_manager, "close_sources", None)
487
+ if close_sources is not None:
488
+ close_sources()
489
+
490
+
491
+ def _validate_source_profiles(
492
+ metrics: list[tuple[Path, MetricConfig]],
493
+ project_config: ProjectConfig,
494
+ profiles_config: ProfilesConfig,
495
+ ) -> str | None:
496
+ """Cheap, connection-free check that every metric's RESOLVED
497
+ ``source_profile`` (hybrid mode) names a real profile.
498
+
499
+ Returns an error message naming every offending metric, or ``None`` when
500
+ every resolved name is either unset (no hybrid override) or a known
501
+ profile. Runs before any database manager is built.
502
+ """
503
+ project_source_profile = getattr(project_config, "source_profile", None)
504
+ known_profiles = getattr(profiles_config, "profiles", {}) or {}
505
+ unknown: dict[str, str] = {}
506
+ for _, config in metrics:
507
+ name = resolve_source_profile(
508
+ getattr(config, "source_profile", None), project_source_profile
509
+ )
510
+ if name is not None and name not in known_profiles:
511
+ unknown[config.name] = name
512
+ if not unknown:
513
+ return None
514
+ details = ", ".join(f"{m} -> '{p}'" for m, p in sorted(unknown.items()))
515
+ available = ", ".join(sorted(known_profiles.keys()))
516
+ return (
517
+ f"Unknown source_profile referenced by metric(s): {details}. "
518
+ f"Available profiles: {available}"
519
+ )
453
520
 
454
521
 
455
522
  def _resolve_report_path(report_path: str, project_root: Path, metric_name: str) -> Path:
@@ -592,5 +592,61 @@ from detectkit.cli.commands.osi import osi as _osi_group # noqa: E402
592
592
  cli.add_command(_osi_group)
593
593
 
594
594
 
595
+ # MCP (Model Context Protocol) server — a self-contained, READ-ONLY command
596
+ # (`dtk mcp`), unlike `dtk osi` a single command rather than a group. Isolated
597
+ # from the load/detect/alert pipeline: nothing in load/detect/alert imports
598
+ # detectkit.mcp, and this module only imports click at load time — the mcp SDK
599
+ # (the optional `[mcp]` extra) and detectkit.mcp.server are imported lazily,
600
+ # inside the command body.
601
+ @cli.command()
602
+ @click.option(
603
+ "--project-dir",
604
+ default=None,
605
+ help=(
606
+ "Project directory (searched upward for detectkit_project.yml). "
607
+ "Default: $DETECTKIT_PROJECT_DIR, else search upward from the current "
608
+ "directory. An MCP client passes no cwd, so at least one of these must "
609
+ "resolve."
610
+ ),
611
+ )
612
+ @click.option(
613
+ "--select",
614
+ "-s",
615
+ default="*",
616
+ show_default=True,
617
+ help="Selector scoping which metrics the server exposes (metric name, path, or tag)",
618
+ )
619
+ @click.option(
620
+ "--profile",
621
+ help="Profile to use (default: from project config)",
622
+ )
623
+ def mcp(project_dir: str | None, select: str, profile: str | None) -> None:
624
+ """
625
+ Serve a read-only Model Context Protocol (MCP) server over stdio.
626
+
627
+ Exposes a detectkit project to an MCP client (an IDE assistant, a desktop
628
+ app, a cloud agent): metric configs, loaded datapoints, detector results,
629
+ replayed alert history, autotune runs, and labeled incidents. Nothing in
630
+ this server writes to the database or the filesystem — no config edits,
631
+ no label writes, no pipeline runs, no schema creation. It never calls
632
+ `ensure_tables()`; a project with no data yet answers tool calls with a
633
+ clear "run `dtk run` first" error instead of creating tables.
634
+
635
+ `--select` scopes the whole session: every tool that names a metric
636
+ refuses one outside this set, and list/status tools intersect their own
637
+ selector with it. Needs the `[mcp]` extra: pip install 'detectkit[mcp]'.
638
+
639
+ Examples:
640
+ # Expose every metric in the project the current directory is inside
641
+ dtk mcp
642
+
643
+ # Scope to one tag, from a project the client doesn't cd into
644
+ dtk mcp --project-dir /opt/monitoring --select "tag:critical"
645
+ """
646
+ from detectkit.cli.commands.mcp import run_mcp
647
+
648
+ run_mcp(project_dir=project_dir, select=select, profile=profile)
649
+
650
+
595
651
  if __name__ == "__main__":
596
652
  cli()
@@ -627,6 +627,8 @@ class MetricConfig(BaseModel):
627
627
  description: Optional metric description (supports multi-line text)
628
628
  tags: Optional list of tags for metric selection (e.g., ["critical", "api"])
629
629
  profile: Profile name to use (overrides default_profile from project config)
630
+ source_profile: Hybrid mode — profile whose database runs this metric's
631
+ SQL, while _dtk_* state stays in the active (state) profile
630
632
  query: Inline SQL query (mutually exclusive with query_file)
631
633
  query_file: Path to SQL file (mutually exclusive with query)
632
634
  query_columns: Column name mapping for query results
@@ -684,6 +686,22 @@ class MetricConfig(BaseModel):
684
686
  profile: str | None = Field(
685
687
  default=None, description="Profile name to use (overrides default_profile)"
686
688
  )
689
+ # Hybrid mode: run this metric's SQL against a different database than the
690
+ # one holding _dtk_* state. Unlike `profile` above (which is dead — only
691
+ # round-tripped by autotune's config emitter), this is live: the load step
692
+ # resolves it to a pooled `ProfilesConfig.create_manager(...)` and executes
693
+ # the metric's query through it, while every other step (detect/alert) and
694
+ # save_datapoints itself keep using the active state profile. Falls back to
695
+ # ProjectConfig.source_profile, then None (= use the state profile).
696
+ source_profile: str | None = Field(
697
+ default=None,
698
+ description=(
699
+ "profiles.yml profile whose database runs this metric's SQL; "
700
+ "_dtk_* state stays in the active (state) profile. Falls back to "
701
+ "the project-level source_profile, then to the state profile "
702
+ "itself."
703
+ ),
704
+ )
687
705
  query: str | None = Field(default=None, description="Inline SQL query")
688
706
  query_file: Path | None = Field(default=None, description="Path to SQL file")
689
707
  query_columns: QueryColumnsConfig | None = Field(
@@ -1054,6 +1072,24 @@ def resolve_loading_delay_seconds(
1054
1072
  return 0
1055
1073
 
1056
1074
 
1075
+ def resolve_source_profile(
1076
+ metric_source_profile: str | None, project_source_profile: str | None
1077
+ ) -> str | None:
1078
+ """Resolve the profiles.yml profile that should run a metric's SQL:
1079
+ metric -> project -> ``None``.
1080
+
1081
+ ``None`` means "run the SQL through the same connection detectkit is
1082
+ already using for ``_dtk_*`` state" — i.e. hybrid mode is off for this
1083
+ metric and the meaning of ``dtk run --profile`` / ``default_profile`` is
1084
+ unchanged. The first configured value wins, mirroring
1085
+ :func:`resolve_loading_delay_seconds`'s metric -> project precedence.
1086
+ """
1087
+ for value in (metric_source_profile, project_source_profile):
1088
+ if value is not None:
1089
+ return value
1090
+ return None
1091
+
1092
+
1057
1093
  def resolve_grid_phase_seconds(loading_start_time: str | None, interval_seconds: int) -> int:
1058
1094
  """Phase of the metric's interval grid on the epoch clock, in ``[0, interval)``.
1059
1095