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.
- {detectkit-0.60.0/detectkit.egg-info → detectkit-0.61.0}/PKG-INFO +2 -1
- {detectkit-0.60.0 → detectkit-0.61.0}/README.md +1 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/__init__.py +1 -1
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/assets/claude/rules/metrics.md +14 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/assets/claude/rules/project.md +21 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/commands/run.py +135 -68
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/config/metric_config.py +36 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/config/project_config.py +15 -0
- detectkit-0.61.0/detectkit/loaders/errors.py +29 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/loaders/metric_loader.py +23 -3
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/orchestration/task_manager/_base.py +80 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/orchestration/task_manager/_load_step.py +6 -1
- {detectkit-0.60.0 → detectkit-0.61.0/detectkit.egg-info}/PKG-INFO +2 -1
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit.egg-info/SOURCES.txt +1 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/LICENSE +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/MANIFEST.in +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/__init__.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/channels/__init__.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/channels/base.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/channels/branding.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/channels/discord.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/channels/email.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/channels/factory.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/channels/googlechat.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/channels/mattermost.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/channels/ntfy.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/channels/slack.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/channels/teams.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/channels/telegram.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/channels/webhook.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/orchestrator/_base.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/orchestrator/_replay.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/orchestrator/_types.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/autotune/__init__.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/autotune/_base.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/autotune/_types.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/autotune/autotuner.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/autotune/axis_spec.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/autotune/config_emitter.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/autotune/crossval.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/autotune/detector_select.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/autotune/distribution.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/autotune/grid_search.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/autotune/labels.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/autotune/result.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/autotune/runner.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/autotune/scoring.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/autotune/seasonality_search.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/autotune/settings.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/autotune/window_select.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/__init__.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/_output.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/assets/claude/CLAUDE.section.md +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/assets/claude/rules/alerting.md +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/assets/claude/rules/autotune.md +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/assets/claude/rules/cli.md +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/assets/claude/skills/dtk-tune/SKILL.md +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/commands/__init__.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/commands/autotune.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/commands/clean.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/commands/init.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/commands/init_claude.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/commands/osi.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/commands/test_alert.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/commands/tune.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/commands/ui.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/commands/unlock.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/cli/main.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/config/__init__.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/config/metric_io.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/config/profile.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/config/validator.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/core/__init__.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/core/interval.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/core/models.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/database/__init__.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/database/_sql_manager.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/database/clickhouse_manager.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/database/duckdb_manager.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/database/internal_tables/__init__.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/database/internal_tables/_base.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/database/internal_tables/_detections.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/database/internal_tables/_metrics.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/database/internal_tables/_schema.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/database/internal_tables/_tasks.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/database/internal_tables/manager.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/database/manager.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/database/mysql_manager.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/database/postgres_manager.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/database/tables.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/detectors/__init__.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/detectors/base.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/detectors/factory.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/detectors/seasonality.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/detectors/statistical/__init__.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/detectors/statistical/_windowed.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/detectors/statistical/autoreg.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/detectors/statistical/iqr.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/detectors/statistical/mad.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/detectors/statistical/zscore.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/loaders/__init__.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/loaders/query_template.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/orchestration/__init__.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/orchestration/error_dispatch.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/orchestration/task_manager/_types.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/orchestration/task_manager/manager.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/reporting/__init__.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/reporting/assets/report.js +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/reporting/builder.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/reporting/html_report.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/semantic/__init__.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/semantic/errors.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/semantic/exporter.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/semantic/importer.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/semantic/osi_model.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/semantic/query_gen.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/tuning/__init__.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/tuning/assets/tune.js +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/tuning/config_writer.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/tuning/html.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/tuning/payload.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/tuning/server.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/ui/__init__.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/ui/assets/ui.js +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/ui/html.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/ui/jobs.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/ui/metric_files.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/ui/overview.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/ui/server.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/utils/__init__.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/utils/datetime_utils.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/utils/env_interpolation.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/utils/json_utils.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit/utils/stats.py +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit.egg-info/dependency_links.txt +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit.egg-info/entry_points.txt +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit.egg-info/requires.txt +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/detectkit.egg-info/top_level.txt +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/pyproject.toml +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/requirements.txt +0 -0
- {detectkit-0.60.0 → detectkit-0.61.0}/setup.cfg +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
|
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
|
-
|
|
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=
|
|
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.
|
|
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
|