detectkit 0.60.0__tar.gz → 0.61.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 (162) hide show
  1. {detectkit-0.60.0/detectkit.egg-info → detectkit-0.61.0}/PKG-INFO +2 -1
  2. {detectkit-0.60.0 → detectkit-0.61.0}/README.md +1 -0
  3. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/__init__.py +1 -1
  4. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/assets/claude/rules/metrics.md +14 -0
  5. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/assets/claude/rules/project.md +21 -0
  6. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/commands/run.py +135 -68
  7. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/config/metric_config.py +36 -0
  8. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/config/project_config.py +15 -0
  9. detectkit-0.61.0/detectkit/loaders/errors.py +29 -0
  10. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/loaders/metric_loader.py +23 -3
  11. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/orchestration/task_manager/_base.py +80 -0
  12. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/orchestration/task_manager/_load_step.py +6 -1
  13. {detectkit-0.60.0 → detectkit-0.61.0/detectkit.egg-info}/PKG-INFO +2 -1
  14. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit.egg-info/SOURCES.txt +1 -0
  15. {detectkit-0.60.0 → detectkit-0.61.0}/LICENSE +0 -0
  16. {detectkit-0.60.0 → detectkit-0.61.0}/MANIFEST.in +0 -0
  17. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/__init__.py +0 -0
  18. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/channels/__init__.py +0 -0
  19. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/channels/base.py +0 -0
  20. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/channels/branding.py +0 -0
  21. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/channels/discord.py +0 -0
  22. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/channels/email.py +0 -0
  23. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/channels/factory.py +0 -0
  24. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/channels/googlechat.py +0 -0
  25. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/channels/mattermost.py +0 -0
  26. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/channels/ntfy.py +0 -0
  27. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/channels/slack.py +0 -0
  28. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/channels/teams.py +0 -0
  29. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/channels/telegram.py +0 -0
  30. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/channels/webhook.py +0 -0
  31. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
  32. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/orchestrator/_base.py +0 -0
  33. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
  34. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
  35. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
  36. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
  37. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/orchestrator/_replay.py +0 -0
  38. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/orchestrator/_types.py +0 -0
  39. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
  40. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/autotune/__init__.py +0 -0
  41. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/autotune/_base.py +0 -0
  42. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/autotune/_types.py +0 -0
  43. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/autotune/autotuner.py +0 -0
  44. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/autotune/axis_spec.py +0 -0
  45. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/autotune/config_emitter.py +0 -0
  46. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/autotune/crossval.py +0 -0
  47. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/autotune/detector_select.py +0 -0
  48. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/autotune/distribution.py +0 -0
  49. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/autotune/grid_search.py +0 -0
  50. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/autotune/labels.py +0 -0
  51. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/autotune/result.py +0 -0
  52. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/autotune/runner.py +0 -0
  53. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/autotune/scoring.py +0 -0
  54. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/autotune/seasonality_search.py +0 -0
  55. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/autotune/settings.py +0 -0
  56. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/autotune/window_select.py +0 -0
  57. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/__init__.py +0 -0
  58. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/_output.py +0 -0
  59. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/assets/claude/CLAUDE.section.md +0 -0
  60. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/assets/claude/rules/alerting.md +0 -0
  61. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/assets/claude/rules/autotune.md +0 -0
  62. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/assets/claude/rules/cli.md +0 -0
  63. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
  64. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
  65. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +0 -0
  66. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
  67. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
  68. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
  69. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/assets/claude/skills/dtk-tune/SKILL.md +0 -0
  70. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/commands/__init__.py +0 -0
  71. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/commands/autotune.py +0 -0
  72. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/commands/clean.py +0 -0
  73. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/commands/init.py +0 -0
  74. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/commands/init_claude.py +0 -0
  75. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/commands/osi.py +0 -0
  76. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/commands/test_alert.py +0 -0
  77. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/commands/tune.py +0 -0
  78. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/commands/ui.py +0 -0
  79. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/commands/unlock.py +0 -0
  80. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/main.py +0 -0
  81. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/config/__init__.py +0 -0
  82. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/config/metric_io.py +0 -0
  83. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/config/profile.py +0 -0
  84. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/config/validator.py +0 -0
  85. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/core/__init__.py +0 -0
  86. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/core/interval.py +0 -0
  87. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/core/models.py +0 -0
  88. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/database/__init__.py +0 -0
  89. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/database/_sql_manager.py +0 -0
  90. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/database/clickhouse_manager.py +0 -0
  91. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/database/duckdb_manager.py +0 -0
  92. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/database/internal_tables/__init__.py +0 -0
  93. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
  94. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
  95. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/database/internal_tables/_base.py +0 -0
  96. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
  97. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/database/internal_tables/_detections.py +0 -0
  98. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
  99. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/database/internal_tables/_metrics.py +0 -0
  100. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/database/internal_tables/_schema.py +0 -0
  101. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/database/internal_tables/_tasks.py +0 -0
  102. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/database/internal_tables/manager.py +0 -0
  103. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/database/manager.py +0 -0
  104. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/database/mysql_manager.py +0 -0
  105. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/database/postgres_manager.py +0 -0
  106. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/database/tables.py +0 -0
  107. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/detectors/__init__.py +0 -0
  108. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/detectors/base.py +0 -0
  109. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/detectors/factory.py +0 -0
  110. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/detectors/seasonality.py +0 -0
  111. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/detectors/statistical/__init__.py +0 -0
  112. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/detectors/statistical/_windowed.py +0 -0
  113. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/detectors/statistical/autoreg.py +0 -0
  114. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/detectors/statistical/iqr.py +0 -0
  115. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/detectors/statistical/mad.py +0 -0
  116. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
  117. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/detectors/statistical/zscore.py +0 -0
  118. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/loaders/__init__.py +0 -0
  119. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/loaders/query_template.py +0 -0
  120. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/orchestration/__init__.py +0 -0
  121. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/orchestration/error_dispatch.py +0 -0
  122. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
  123. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
  124. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
  125. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/orchestration/task_manager/_types.py +0 -0
  126. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/orchestration/task_manager/manager.py +0 -0
  127. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/reporting/__init__.py +0 -0
  128. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/reporting/assets/report.js +0 -0
  129. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/reporting/builder.py +0 -0
  130. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/reporting/html_report.py +0 -0
  131. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/semantic/__init__.py +0 -0
  132. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/semantic/errors.py +0 -0
  133. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/semantic/exporter.py +0 -0
  134. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/semantic/importer.py +0 -0
  135. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/semantic/osi_model.py +0 -0
  136. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/semantic/query_gen.py +0 -0
  137. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/tuning/__init__.py +0 -0
  138. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/tuning/assets/tune.js +0 -0
  139. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/tuning/config_writer.py +0 -0
  140. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/tuning/html.py +0 -0
  141. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/tuning/payload.py +0 -0
  142. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/tuning/server.py +0 -0
  143. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/ui/__init__.py +0 -0
  144. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/ui/assets/ui.js +0 -0
  145. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/ui/html.py +0 -0
  146. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/ui/jobs.py +0 -0
  147. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/ui/metric_files.py +0 -0
  148. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/ui/overview.py +0 -0
  149. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/ui/server.py +0 -0
  150. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/utils/__init__.py +0 -0
  151. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/utils/datetime_utils.py +0 -0
  152. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/utils/env_interpolation.py +0 -0
  153. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/utils/json_utils.py +0 -0
  154. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/utils/stats.py +0 -0
  155. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit.egg-info/dependency_links.txt +0 -0
  156. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit.egg-info/entry_points.txt +0 -0
  157. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit.egg-info/requires.txt +0 -0
  158. {detectkit-0.60.0 → detectkit-0.61.0}/detectkit.egg-info/top_level.txt +0 -0
  159. {detectkit-0.60.0 → detectkit-0.61.0}/pyproject.toml +0 -0
  160. {detectkit-0.60.0 → detectkit-0.61.0}/requirements.txt +0 -0
  161. {detectkit-0.60.0 → detectkit-0.61.0}/setup.cfg +0 -0
  162. {detectkit-0.60.0 → detectkit-0.61.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.61.0
4
4
  Summary: Metric monitoring with automatic anomaly detection
5
5
  Author: detectkit team
6
6
  License: MIT
@@ -104,6 +104,7 @@ Dynamic: license-file
104
104
  - **Alert lifecycle** — consecutive anomalies, fraction-of-window rule (`anomaly_window` + `min_anomaly_share`), cooldown, recovery notifications, no-data alerts
105
105
  - **Project-level error alerts** — catch DB outages and pipeline crashes once per run
106
106
  - **Database agnostic** — ClickHouse, PostgreSQL, MySQL/MariaDB, DuckDB (no server needed)
107
+ - **Hybrid mode** — read metric SQL from a billed-per-query warehouse while all pipeline state lives in a cheap local database
107
108
  - **Idempotent** — resume from interruptions, no duplicate processing
108
109
  - **CLI** — `dtk init`, `dtk run --select`, `dtk unlock`, `dtk clean`, tag-based selectors
109
110
  - **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
@@ -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
@@ -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.61.0"
8
8
 
9
9
  from detectkit.core.interval import Interval
10
10
  from detectkit.core.models import ColumnDefinition, TableModel
@@ -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`
@@ -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:
@@ -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
 
@@ -139,6 +139,8 @@ class ProjectConfig(BaseModel):
139
139
  tables: Default table names
140
140
  timeouts: Operation timeouts
141
141
  default_profile: Default database profile to use
142
+ source_profile: Default hybrid-mode source profile (see
143
+ MetricConfig.source_profile)
142
144
 
143
145
  Example YAML:
144
146
  ```yaml
@@ -224,6 +226,19 @@ class ProjectConfig(BaseModel):
224
226
  "loading_delay overrides it; 0 on a metric opts out."
225
227
  ),
226
228
  )
229
+ # Project-wide default source profile for hybrid mode (see
230
+ # MetricConfig.source_profile). Useful when most metrics read from the
231
+ # same source database that differs from where _dtk_* state lives. A
232
+ # per-metric source_profile overrides it; unset on both means every
233
+ # metric's SQL runs through the active state profile (today's behavior).
234
+ source_profile: str | None = Field(
235
+ default=None,
236
+ description=(
237
+ "Default profiles.yml profile whose database runs metric SQL "
238
+ "(hybrid mode); _dtk_* state stays in the active (state) "
239
+ "profile. Per-metric source_profile overrides it."
240
+ ),
241
+ )
227
242
 
228
243
  @field_validator("loading_delay")
229
244
  @classmethod
@@ -0,0 +1,29 @@
1
+ """Typed errors for the loading layer.
2
+
3
+ Kept separate (mirroring ``semantic/errors.py``) so a source-database failure
4
+ in hybrid mode can be told apart from a state-database failure at a glance —
5
+ in logs, in the project-level error alert, and by any caller inspecting
6
+ ``type(exc)``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+
12
+ class SourceDatabaseError(Exception):
13
+ """A metric's SOURCE-profile database failed (hybrid mode).
14
+
15
+ Wraps the original exception with the source profile's name. Raised only
16
+ around the two places that actually talk to a source-profile connection:
17
+ ``MetricLoader.load``'s query execution, and building a pooled
18
+ source-profile manager in ``TaskManager``. Every other failure (saving to
19
+ _dtk_* state, detect, alert, or a non-hybrid metric's query — which runs
20
+ through the same connection as state) stays a plain, unwrapped exception,
21
+ since there is nothing to disambiguate in that case.
22
+ """
23
+
24
+ def __init__(self, profile_name: str, original: BaseException) -> None:
25
+ self.profile_name = profile_name
26
+ self.original = original
27
+ super().__init__(
28
+ f"source database (profile '{profile_name}'): " f"{type(original).__name__}: {original}"
29
+ )
@@ -16,6 +16,7 @@ import numpy as np
16
16
  from detectkit.config.metric_config import MetricConfig
17
17
  from detectkit.database.internal_tables import InternalTablesManager
18
18
  from detectkit.database.manager import BaseDatabaseManager
19
+ from detectkit.loaders.errors import SourceDatabaseError
19
20
  from detectkit.loaders.query_template import QueryTemplate
20
21
  from detectkit.utils.datetime_utils import now_utc_naive, to_naive_utc
21
22
  from detectkit.utils.json_utils import json_dumps_sorted
@@ -50,18 +51,30 @@ class MetricLoader:
50
51
  config: MetricConfig,
51
52
  db_manager: BaseDatabaseManager,
52
53
  internal_manager: InternalTablesManager,
54
+ source_profile_name: str | None = None,
53
55
  ):
54
56
  """
55
57
  Initialize metric loader.
56
58
 
57
59
  Args:
58
60
  config: Metric configuration
59
- db_manager: Database manager for executing queries
61
+ db_manager: Database manager for executing the metric's SQL. In
62
+ hybrid mode this is a SOURCE-profile manager, distinct from
63
+ the manager holding _dtk_* state; ``internal_manager`` below
64
+ always talks to state.
60
65
  internal_manager: Internal tables manager for saving data
66
+ source_profile_name: Name of the source profile *db_manager*
67
+ belongs to, when it differs from the active state profile
68
+ (hybrid mode). ``None`` (the default) means *db_manager* IS
69
+ the state manager, so a query failure is indistinguishable
70
+ from a state failure and is left unwrapped. When set, a
71
+ query failure is wrapped in :class:`SourceDatabaseError` so
72
+ it can be told apart from a state-database failure.
61
73
  """
62
74
  self.config = config
63
75
  self.db_manager = db_manager
64
76
  self.internal_manager = internal_manager
77
+ self.source_profile_name = source_profile_name
65
78
  self.query_template = QueryTemplate()
66
79
 
67
80
  def load(
@@ -121,8 +134,15 @@ class MetricLoader:
121
134
  interval_seconds=interval_seconds,
122
135
  )
123
136
 
124
- # Execute query
125
- results = self.db_manager.execute_query(rendered_query)
137
+ # Execute query. In hybrid mode (source_profile_name set) a failure
138
+ # here is wrapped so it reads as "source database down", not an
139
+ # indistinguishable state-database failure.
140
+ try:
141
+ results = self.db_manager.execute_query(rendered_query)
142
+ except Exception as exc:
143
+ if self.source_profile_name is not None:
144
+ raise SourceDatabaseError(self.source_profile_name, exc) from exc
145
+ raise
126
146
 
127
147
  if not results:
128
148
  # No data - return empty arrays
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from datetime import datetime
6
+ from typing import Any
6
7
 
7
8
  from detectkit.alerting.channels.base import BaseAlertChannel
8
9
  from detectkit.alerting.channels.factory import AlertChannelFactory
@@ -11,8 +12,10 @@ from detectkit.config.metric_config import (
11
12
  MetricConfig,
12
13
  resolve_grid_phase_seconds,
13
14
  resolve_loading_delay_seconds,
15
+ resolve_source_profile,
14
16
  )
15
17
  from detectkit.database.internal_tables import InternalTablesManager
18
+ from detectkit.loaders.errors import SourceDatabaseError
16
19
 
17
20
 
18
21
  class _TaskManagerBase:
@@ -24,11 +27,25 @@ class _TaskManagerBase:
24
27
  db_manager, # BaseDatabaseManager — typed loosely to avoid an import cycle
25
28
  profiles_config=None,
26
29
  project_config=None,
30
+ state_profile_name: str | None = None,
27
31
  ):
28
32
  self.internal = internal_manager
29
33
  self.db_manager = db_manager
30
34
  self.profiles_config = profiles_config
31
35
  self.project_config = project_config
36
+ # The active STATE profile's name (the one `db_manager` belongs to),
37
+ # so a metric's resolved `source_profile` can be compared against it —
38
+ # when they match, hybrid mode is a no-op and `db_manager` is reused
39
+ # instead of opening a duplicate connection to the same database.
40
+ # Optional: only needed to short-circuit that comparison; a caller
41
+ # that never sets it just always pools a source manager by name (fine,
42
+ # `create_manager` gives an equivalent connection either way).
43
+ self.state_profile_name = state_profile_name
44
+ # Lazy pool of SOURCE-profile managers for hybrid mode, keyed by
45
+ # profile name. A failed construction is cached too (as the raised
46
+ # SourceDatabaseError) so a bad/unreachable source profile isn't
47
+ # retried once per metric that references it.
48
+ self._source_managers: dict[str, Any] = {}
32
49
  # In-process flag: dispatch project-level error alert at most once
33
50
  # per run. Abort propagation is signalled via result["abort_run"].
34
51
  self._error_alert_sent_in_run = False
@@ -57,6 +74,69 @@ class _TaskManagerBase:
57
74
  config.get_interval().seconds,
58
75
  )
59
76
 
77
+ def _resolve_source_manager(self, config: MetricConfig) -> tuple[Any, str | None]:
78
+ """Resolve the database manager that should run *config*'s SQL.
79
+
80
+ Returns ``(manager, source_profile_name)``:
81
+
82
+ - ``source_profile_name`` is ``None`` when the metric has no hybrid
83
+ override, or its resolved ``source_profile`` names the active
84
+ STATE profile — in both cases the returned manager IS
85
+ ``self.db_manager`` (no duplicate connection, and a query failure
86
+ there is left unwrapped since it's the same connection as state).
87
+ - Otherwise a manager for the named source profile is built lazily
88
+ (once per profile — cached in ``self._source_managers``, including
89
+ a cached failure so a bad profile isn't retried per metric) and
90
+ returned alongside its name, so the LOAD step can tag it onto
91
+ :class:`MetricLoader` for :class:`SourceDatabaseError` wrapping.
92
+
93
+ Only the LOAD step calls this — detect/alert never touch a source
94
+ connection.
95
+ """
96
+ name = resolve_source_profile(
97
+ getattr(config, "source_profile", None),
98
+ getattr(self.project_config, "source_profile", None),
99
+ )
100
+ if name is None or name == self.state_profile_name:
101
+ return self.db_manager, None
102
+
103
+ cached = self._source_managers.get(name)
104
+ if cached is not None:
105
+ if isinstance(cached, SourceDatabaseError):
106
+ raise cached
107
+ return cached, name
108
+
109
+ try:
110
+ if self.profiles_config is None:
111
+ raise ValueError(
112
+ "no profiles configuration available to resolve source " f"profile '{name}'"
113
+ )
114
+ manager = self.profiles_config.create_manager(name)
115
+ except Exception as exc:
116
+ err = SourceDatabaseError(name, exc)
117
+ self._source_managers[name] = err
118
+ raise err from exc
119
+
120
+ self._source_managers[name] = manager
121
+ return manager, name
122
+
123
+ def close_sources(self) -> None:
124
+ """Close every pooled SOURCE-profile manager.
125
+
126
+ Never touches ``self.db_manager`` — the caller (``dtk run``) owns
127
+ that connection's lifecycle. Best-effort: a close failure on one
128
+ pooled manager is swallowed so it can't mask the run's real outcome
129
+ or block closing the rest.
130
+ """
131
+ for cached in self._source_managers.values():
132
+ if isinstance(cached, SourceDatabaseError):
133
+ continue
134
+ try:
135
+ cached.close()
136
+ except Exception:
137
+ pass
138
+ self._source_managers.clear()
139
+
60
140
  def _load_recent_detections(
61
141
  self,
62
142
  metric_name: str,
@@ -21,10 +21,15 @@ class _LoadStepMixin(_TaskManagerBase):
21
21
  full_refresh: bool,
22
22
  ) -> dict[str, int]:
23
23
  """Execute the LOAD step end-to-end (resume → batch → save)."""
24
+ # Hybrid mode: the metric's SQL may run against a different
25
+ # (SOURCE) profile than the one holding _dtk_* state; save() below
26
+ # always goes through self.internal (state), untouched either way.
27
+ source_manager, source_profile_name = self._resolve_source_manager(config)
24
28
  loader = MetricLoader(
25
29
  config=config,
26
- db_manager=self.db_manager,
30
+ db_manager=source_manager,
27
31
  internal_manager=self.internal,
32
+ source_profile_name=source_profile_name,
28
33
  )
29
34
 
30
35
  if full_refresh:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.60.0
3
+ Version: 0.61.0
4
4
  Summary: Metric monitoring with automatic anomaly detection
5
5
  Author: detectkit team
6
6
  License: MIT
@@ -104,6 +104,7 @@ Dynamic: license-file
104
104
  - **Alert lifecycle** — consecutive anomalies, fraction-of-window rule (`anomaly_window` + `min_anomaly_share`), cooldown, recovery notifications, no-data alerts
105
105
  - **Project-level error alerts** — catch DB outages and pipeline crashes once per run
106
106
  - **Database agnostic** — ClickHouse, PostgreSQL, MySQL/MariaDB, DuckDB (no server needed)
107
+ - **Hybrid mode** — read metric SQL from a billed-per-query warehouse while all pipeline state lives in a cheap local database
107
108
  - **Idempotent** — resume from interruptions, no duplicate processing
108
109
  - **CLI** — `dtk init`, `dtk run --select`, `dtk unlock`, `dtk clean`, tag-based selectors
109
110
  - **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