detectkit 0.32.0__tar.gz → 0.34.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.32.0/detectkit.egg-info → detectkit-0.34.0}/PKG-INFO +1 -1
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/__init__.py +1 -1
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/autotune/grid_search.py +16 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/autotune/label_server.py +6 -21
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/autotune/labels.py +41 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/autotune/window_select.py +59 -1
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/cli/assets/claude/rules/cli.md +14 -3
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/cli/assets/claude/rules/detectors.md +9 -1
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/cli/commands/tune.py +25 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/detectors/statistical/_windowed.py +60 -0
- detectkit-0.34.0/detectkit/reporting/assets/report.js +80 -0
- detectkit-0.34.0/detectkit/tuning/assets/tune.js +117 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/tuning/payload.py +26 -2
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/tuning/server.py +68 -14
- {detectkit-0.32.0 → detectkit-0.34.0/detectkit.egg-info}/PKG-INFO +1 -1
- detectkit-0.32.0/detectkit/reporting/assets/report.js +0 -77
- detectkit-0.32.0/detectkit/tuning/assets/tune.js +0 -80
- {detectkit-0.32.0 → detectkit-0.34.0}/LICENSE +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/MANIFEST.in +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/README.md +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/alerting/__init__.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/alerting/channels/__init__.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/alerting/channels/base.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/alerting/channels/branding.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/alerting/channels/email.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/alerting/channels/factory.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/alerting/channels/mattermost.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/alerting/channels/slack.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/alerting/channels/telegram.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/alerting/channels/webhook.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/alerting/orchestrator/_base.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/alerting/orchestrator/_replay.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/alerting/orchestrator/_types.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/autotune/__init__.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/autotune/_base.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/autotune/_types.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/autotune/autotuner.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/autotune/config_emitter.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/autotune/crossval.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/autotune/detector_select.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/autotune/distribution.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/autotune/html_labeler.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/autotune/result.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/autotune/scoring.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/autotune/seasonality_search.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/autotune/settings.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/cli/__init__.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/cli/_output.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/cli/assets/claude/CLAUDE.section.md +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/cli/assets/claude/rules/alerting.md +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/cli/assets/claude/rules/autotune.md +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/cli/assets/claude/rules/project.md +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/cli/commands/__init__.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/cli/commands/autotune.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/cli/commands/clean.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/cli/commands/init.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/cli/commands/init_claude.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/cli/commands/run.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/cli/commands/test_alert.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/cli/commands/unlock.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/cli/main.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/config/__init__.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/config/metric_config.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/config/profile.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/config/project_config.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/config/validator.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/core/__init__.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/core/interval.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/core/models.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/database/__init__.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/database/_sql_manager.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/database/clickhouse_manager.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/database/internal_tables/__init__.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/database/internal_tables/_base.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/database/internal_tables/_detections.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/database/internal_tables/_metrics.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/database/internal_tables/_schema.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/database/internal_tables/_tasks.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/database/internal_tables/manager.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/database/manager.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/database/mysql_manager.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/database/postgres_manager.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/database/tables.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/detectors/__init__.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/detectors/base.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/detectors/factory.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/detectors/seasonality.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/detectors/statistical/__init__.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/detectors/statistical/iqr.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/detectors/statistical/mad.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/detectors/statistical/zscore.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/loaders/__init__.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/loaders/metric_loader.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/loaders/query_template.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/orchestration/__init__.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/orchestration/error_dispatch.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/orchestration/task_manager/_base.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/orchestration/task_manager/_types.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/orchestration/task_manager/manager.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/reporting/__init__.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/reporting/builder.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/reporting/html_report.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/tuning/__init__.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/tuning/config_writer.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/tuning/html.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/utils/__init__.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/utils/datetime_utils.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/utils/env_interpolation.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/utils/json_utils.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit/utils/stats.py +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit.egg-info/SOURCES.txt +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit.egg-info/dependency_links.txt +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit.egg-info/entry_points.txt +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit.egg-info/requires.txt +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/detectkit.egg-info/top_level.txt +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/pyproject.toml +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/requirements.txt +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/setup.cfg +0 -0
- {detectkit-0.32.0 → detectkit-0.34.0}/setup.py +0 -0
|
@@ -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.34.0"
|
|
8
8
|
|
|
9
9
|
from detectkit.core.interval import Interval
|
|
10
10
|
from detectkit.core.models import ColumnDefinition, TableModel
|
|
@@ -21,6 +21,7 @@ from detectkit.autotune.window_select import (
|
|
|
21
21
|
detect_level_shift,
|
|
22
22
|
half_life_grid,
|
|
23
23
|
min_samples_for,
|
|
24
|
+
seasonal_fill_window,
|
|
24
25
|
select_window,
|
|
25
26
|
trend_present,
|
|
26
27
|
)
|
|
@@ -45,6 +46,21 @@ def grid_search(
|
|
|
45
46
|
base: dict[str, Any] = {}
|
|
46
47
|
if seasonality:
|
|
47
48
|
base["seasonality_components"] = seasonality
|
|
49
|
+
# Per-group stats engage only when the window holds min_samples_per_group
|
|
50
|
+
# points of the current seasonal key (~min_samples_per_group * cardinality
|
|
51
|
+
# points). If even the largest fold-feasible window can't reach that, the
|
|
52
|
+
# chosen seasonality will silently fall back to global at runtime — flag it
|
|
53
|
+
# in the decision log so the tuned config isn't trusted to be seasonal.
|
|
54
|
+
fill = seasonal_fill_window(tuner)
|
|
55
|
+
if fill and grid and max(grid) < fill:
|
|
56
|
+
tuner.log(
|
|
57
|
+
"window",
|
|
58
|
+
f"seasonality {seasonality} needs window_size >= {fill} to engage per-group "
|
|
59
|
+
f"stats, but the fold budget caps the window at {max(grid)} on this history "
|
|
60
|
+
"— the band will use global statistics (seasonality has no effect). Tune on "
|
|
61
|
+
"more history, or use `dtk tune` to set a larger window manually.",
|
|
62
|
+
seasonal_fill_window=fill,
|
|
63
|
+
)
|
|
48
64
|
|
|
49
65
|
has_trend = trend_present(tuner)
|
|
50
66
|
if not has_trend:
|
|
@@ -10,12 +10,10 @@ off the machine, and nothing is written until the user explicitly saves.
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
12
|
import json
|
|
13
|
-
import re
|
|
14
13
|
import secrets
|
|
15
14
|
import threading
|
|
16
15
|
import webbrowser
|
|
17
16
|
from collections.abc import Callable
|
|
18
|
-
from datetime import datetime, timezone
|
|
19
17
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
20
18
|
from pathlib import Path
|
|
21
19
|
from typing import Any, cast
|
|
@@ -24,23 +22,12 @@ from urllib.parse import parse_qs, urlparse
|
|
|
24
22
|
import numpy as np
|
|
25
23
|
|
|
26
24
|
from detectkit.autotune.html_labeler import render_labeler_html
|
|
27
|
-
from detectkit.autotune.labels import parse_incident_labels
|
|
28
|
-
|
|
29
|
-
_NAME_RE = re.compile(r"[^a-z0-9_-]+")
|
|
30
|
-
_MAX_BODY = 5_000_000 # generous cap on the posted labels payload
|
|
25
|
+
from detectkit.autotune.labels import parse_incident_labels, versioned_labels_path
|
|
31
26
|
|
|
27
|
+
# Re-exported under the historical name for the labeler-server tests.
|
|
28
|
+
from detectkit.autotune.labels import sanitize_label_set_name as _sanitize # noqa: F401
|
|
32
29
|
|
|
33
|
-
|
|
34
|
-
"""Filesystem-safe slug for a label-set name; ``""`` when blank.
|
|
35
|
-
|
|
36
|
-
Used as an optional *suffix* appended to the metric-named filename, so a blank
|
|
37
|
-
name simply yields no suffix (the file is named after the metric alone).
|
|
38
|
-
"""
|
|
39
|
-
return _NAME_RE.sub("-", name.strip().lower()).strip("-")
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def _stamp() -> str:
|
|
43
|
-
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
30
|
+
_MAX_BODY = 5_000_000 # generous cap on the posted labels payload
|
|
44
31
|
|
|
45
32
|
|
|
46
33
|
class _LabelServer(ThreadingHTTPServer):
|
|
@@ -90,16 +77,14 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
90
77
|
|
|
91
78
|
payload = json.loads(self.rfile.read(length).decode("utf-8"))
|
|
92
79
|
yaml_text = str(payload.get("yaml", ""))
|
|
93
|
-
suffix = _sanitize(str(payload.get("name", "")))
|
|
94
80
|
raw = _yaml.safe_load(yaml_text)
|
|
95
81
|
# validate against the canonical schema before writing anything
|
|
96
82
|
parse_incident_labels(
|
|
97
83
|
raw, interval_seconds=srv.interval_seconds, metric_name=srv.metric
|
|
98
84
|
)
|
|
99
85
|
srv.incidents_dir.mkdir(parents=True, exist_ok=True)
|
|
100
|
-
#
|
|
101
|
-
|
|
102
|
-
out = srv.incidents_dir / f"{stem}-{_stamp()}.yml"
|
|
86
|
+
# Versioned, metric-named file (the optional set name is a suffix).
|
|
87
|
+
out = versioned_labels_path(srv.incidents_dir, srv.metric, str(payload.get("name", "")))
|
|
103
88
|
out.write_text(yaml_text, encoding="utf-8")
|
|
104
89
|
srv.saved_path = out
|
|
105
90
|
except Exception as exc:
|
|
@@ -14,6 +14,7 @@ When no labels are supplied the tuner falls back to unsupervised mode.
|
|
|
14
14
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
|
+
import re
|
|
17
18
|
from dataclasses import dataclass, field
|
|
18
19
|
from datetime import datetime, timezone
|
|
19
20
|
from pathlib import Path
|
|
@@ -279,3 +280,43 @@ def load_capture_windows(
|
|
|
279
280
|
"""Load a labels file and render its capture windows as labeler display dicts."""
|
|
280
281
|
labels = parse_labels_file(path, interval_seconds=interval_seconds, metric_name=metric_name)
|
|
281
282
|
return capture_windows_to_display(labels)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# Versioned labels store helpers (shared by the autotune labeler server and the
|
|
286
|
+
# `dtk tune` labeler so both name + discover files the same way).
|
|
287
|
+
_LABELS_GLOBS = ("*.yml", "*.yaml", "*.json")
|
|
288
|
+
_NAME_RE = re.compile(r"[^a-z0-9_-]+")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def newest_labels_file(directory: str | Path) -> Path | None:
|
|
292
|
+
"""Newest versioned labels file in *directory* (``None`` if empty/absent).
|
|
293
|
+
|
|
294
|
+
Versioned names (``<metric>[-<set>]-<UTCstamp>.yml``) sort chronologically by
|
|
295
|
+
name; mtime tie-breaks.
|
|
296
|
+
"""
|
|
297
|
+
d = Path(directory)
|
|
298
|
+
if not d.is_dir():
|
|
299
|
+
return None
|
|
300
|
+
files: list[Path] = []
|
|
301
|
+
for pattern in _LABELS_GLOBS:
|
|
302
|
+
files.extend(d.glob(pattern))
|
|
303
|
+
if not files:
|
|
304
|
+
return None
|
|
305
|
+
return sorted(files, key=lambda p: (p.name, p.stat().st_mtime))[-1]
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def sanitize_label_set_name(name: str) -> str:
|
|
309
|
+
"""Filesystem-safe slug for an optional label-set name (``""`` when blank)."""
|
|
310
|
+
return _NAME_RE.sub("-", name.strip().lower()).strip("-")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def versioned_labels_path(incidents_dir: str | Path, metric: str, name: str = "") -> Path:
|
|
314
|
+
"""Versioned labels path ``<metric>[-<slug>]-<UTCstamp>.yml`` in *incidents_dir*.
|
|
315
|
+
|
|
316
|
+
The single source of the labels filename convention: the optional set *name*
|
|
317
|
+
is sanitized to a suffix, blank → just the metric name.
|
|
318
|
+
"""
|
|
319
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
320
|
+
slug = sanitize_label_set_name(name)
|
|
321
|
+
stem = f"{metric}-{slug}" if slug else metric
|
|
322
|
+
return Path(incidents_dir) / f"{stem}-{stamp}.yml"
|
|
@@ -16,6 +16,15 @@ import numpy as np
|
|
|
16
16
|
from detectkit.autotune._base import _AutoTuneBase
|
|
17
17
|
from detectkit.autotune._types import CandidateEval
|
|
18
18
|
from detectkit.detectors.factory import DetectorFactory
|
|
19
|
+
from detectkit.detectors.seasonality import parse_seasonality_data
|
|
20
|
+
from detectkit.detectors.statistical._windowed import WindowedStatDetector
|
|
21
|
+
|
|
22
|
+
# Reference per-group floor used to size a seasonality-fill window candidate. The
|
|
23
|
+
# windowed detectors share this default (MAD 10 is the largest); using it makes
|
|
24
|
+
# the fill window cover every windowed type so cross-validation can actually
|
|
25
|
+
# discover whether conditioning on a seasonal key helps. See
|
|
26
|
+
# ``seasonal_fill_window``.
|
|
27
|
+
_MSPG_REF = int(WindowedStatDetector.MIN_SAMPLES_PER_GROUP_DEFAULT)
|
|
19
28
|
|
|
20
29
|
|
|
21
30
|
def min_samples_for(window_size: int, floor: int) -> int:
|
|
@@ -23,8 +32,54 @@ def min_samples_for(window_size: int, floor: int) -> int:
|
|
|
23
32
|
return min(window_size, max(floor, round(window_size / 4)))
|
|
24
33
|
|
|
25
34
|
|
|
35
|
+
def max_seasonal_cardinality(tuner: _AutoTuneBase) -> int:
|
|
36
|
+
"""Largest distinct-key count among the available single seasonality columns.
|
|
37
|
+
|
|
38
|
+
Per-group statistics only engage once the window holds
|
|
39
|
+
``min_samples_per_group`` points sharing the current point's key, and same-key
|
|
40
|
+
points recur every *cardinality* grid positions — so this is the recurrence
|
|
41
|
+
period the window must cover. We use the most granular single column (e.g.
|
|
42
|
+
``hour_of_day`` → 24) as the representative key; conjunctive groupings are
|
|
43
|
+
rarer and are backstopped by the detector's runtime under-fill warning.
|
|
44
|
+
Returns 0 when no seasonality columns are present.
|
|
45
|
+
"""
|
|
46
|
+
columns = [c for c in tuner.data.get("seasonality_columns", []) if c != "is_holiday"]
|
|
47
|
+
if not columns:
|
|
48
|
+
return 0
|
|
49
|
+
season = parse_seasonality_data(tuner.data.get("seasonality_data", np.array([])), columns)
|
|
50
|
+
card = 0
|
|
51
|
+
for col in columns:
|
|
52
|
+
vals = season.get(col)
|
|
53
|
+
if vals is None or len(vals) == 0:
|
|
54
|
+
continue
|
|
55
|
+
distinct = {v for v in vals.tolist() if v is not None}
|
|
56
|
+
card = max(card, len(distinct))
|
|
57
|
+
return card
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def seasonal_fill_window(tuner: _AutoTuneBase) -> int:
|
|
61
|
+
"""Smallest window that can fill a single-column seasonal group, else 0.
|
|
62
|
+
|
|
63
|
+
``min_samples_per_group * cardinality``. None of the natural-unit candidates
|
|
64
|
+
(≈1 day, ≈1 week) reach this for hourly ``hour_of_day`` data (24 keys → 240),
|
|
65
|
+
so without this the chosen seasonality silently never engages at the tuned
|
|
66
|
+
window. Returns 0 when there is no seasonality to fill.
|
|
67
|
+
"""
|
|
68
|
+
card = max_seasonal_cardinality(tuner)
|
|
69
|
+
return _MSPG_REF * card if card > 0 else 0
|
|
70
|
+
|
|
71
|
+
|
|
26
72
|
def window_grid(tuner: _AutoTuneBase) -> list[int]:
|
|
27
|
-
"""Candidate window sizes (≈1 day, ≈1 week, default 100), clamped to fit folds.
|
|
73
|
+
"""Candidate window sizes (≈1 day, ≈1 week, default 100), clamped to fit folds.
|
|
74
|
+
|
|
75
|
+
When the data carries seasonality columns, also offer a window large enough to
|
|
76
|
+
fill the most granular seasonal group (``min_samples_per_group * cardinality``)
|
|
77
|
+
so that — if seasonality is chosen downstream — cross-validation can evaluate a
|
|
78
|
+
window where the per-group band actually engages instead of silently falling
|
|
79
|
+
back to global statistics. Capped (like the other candidates) so it never
|
|
80
|
+
exceeds the fold budget; if it doesn't fit, seasonality simply can't fill on
|
|
81
|
+
this much history and the detector's runtime warning will say so.
|
|
82
|
+
"""
|
|
28
83
|
fixed = tuner.settings.fixed_params.get("window_size")
|
|
29
84
|
if isinstance(fixed, int):
|
|
30
85
|
return [fixed]
|
|
@@ -34,6 +89,9 @@ def window_grid(tuner: _AutoTuneBase) -> list[int]:
|
|
|
34
89
|
cap = max(20, n // (fold_count + 1))
|
|
35
90
|
pts_per_day = max(1, round(86400 / tuner.interval_seconds))
|
|
36
91
|
candidates = {100, pts_per_day, 7 * pts_per_day}
|
|
92
|
+
fill = seasonal_fill_window(tuner)
|
|
93
|
+
if fill:
|
|
94
|
+
candidates.add(fill)
|
|
37
95
|
grid = sorted({w for w in candidates if 20 <= w <= cap})
|
|
38
96
|
if not grid:
|
|
39
97
|
grid = [max(2, min(cap, 100))]
|
|
@@ -88,13 +88,24 @@ config back into the metric YAML **in place** (autotune, by contrast, writes a n
|
|
|
88
88
|
datapoints (run `dtk run --steps load` first if empty); the selector must resolve
|
|
89
89
|
to a single metric.
|
|
90
90
|
|
|
91
|
+
**Mark incidents + see alert quality live.** A second, **synced** chart beneath
|
|
92
|
+
the detector view lets you **mark the real incidents** (drag to create a span,
|
|
93
|
+
drag its edges/middle to adjust, ✕ or Delete to remove). As you tune, a metrics
|
|
94
|
+
bar shows two operator numbers: **incident catch rate (recall)** — how many marked
|
|
95
|
+
incidents your config catches — and **false-alert rate** — what share of alerts
|
|
96
|
+
fall outside any real incident ("≈1 in N false"). **Save incidents** writes a
|
|
97
|
+
versioned `incidents/<metric>/*.yml` — the same store `dtk autotune` reads, so the
|
|
98
|
+
same labels feed the next supervised tune (it seeds from the newest file on open).
|
|
99
|
+
Saving incidents does not end the session; only **Apply** does. A **y = 0 line**
|
|
100
|
+
toggle (shared with `dtk run --report`) shows the metric relative to zero.
|
|
101
|
+
|
|
91
102
|
Safe write-back: the config is validated before anything is written, the previous
|
|
92
103
|
YAML is archived under `metrics/.history/<metric>/`, and only then is the metric
|
|
93
104
|
overwritten. Takes **no pipeline lock** (it only edits a config file); re-run
|
|
94
105
|
`dtk run` afterward to recompute detections under the new config.
|
|
95
|
-
`--no-serve` writes a static read-only preview HTML instead (no write-back
|
|
96
|
-
`--from` / `--to` bound the window;
|
|
97
|
-
browser.
|
|
106
|
+
`--no-serve` writes a static read-only preview HTML instead (no write-back —
|
|
107
|
+
**Save incidents** downloads the labels file); `--from` / `--to` bound the window;
|
|
108
|
+
`--no-open` prints the URL without opening a browser.
|
|
98
109
|
|
|
99
110
|
## `dtk test-alert <metric>`
|
|
100
111
|
|
|
@@ -101,7 +101,15 @@ seasonality_components:
|
|
|
101
101
|
- `["hour", "day_of_week"]` — two *separate* adjustments.
|
|
102
102
|
- `[["hour", "day_of_week"]]` — one *combined* group per pair.
|
|
103
103
|
|
|
104
|
-
`min_samples_per_group` is the floor of points required per bucket.
|
|
104
|
+
`min_samples_per_group` is the floor of points required per bucket. **Size the
|
|
105
|
+
window to fill a bucket:** a group engages only when the window holds
|
|
106
|
+
`min_samples_per_group` points of the current key, and same-key points recur once
|
|
107
|
+
per *cardinality*, so `window_size ≳ min_samples_per_group × distinct_keys`. With
|
|
108
|
+
hourly `["hour"]` (24 keys, mad default 10) that's `≳ 240` — the default
|
|
109
|
+
`window_size = 100` fills no bucket, so seasonality silently falls back to the
|
|
110
|
+
global band (the detector logs a one-time warning). Raise `window_size`, lower
|
|
111
|
+
`min_samples_per_group`, or use a coarser grouping. `dtk autotune` now offers a
|
|
112
|
+
fill-sized window candidate automatically.
|
|
105
113
|
|
|
106
114
|
## Preprocessing — `input_type`
|
|
107
115
|
|
|
@@ -15,6 +15,7 @@ from __future__ import annotations
|
|
|
15
15
|
|
|
16
16
|
import click
|
|
17
17
|
|
|
18
|
+
from detectkit.autotune.labels import load_incidents_for_display, newest_labels_file
|
|
18
19
|
from detectkit.cli._output import echo_done, echo_error, echo_noop
|
|
19
20
|
from detectkit.cli.commands.autotune import _load_project
|
|
20
21
|
from detectkit.cli.commands.run import parse_date, select_metrics
|
|
@@ -56,6 +57,26 @@ def run_tune(
|
|
|
56
57
|
|
|
57
58
|
metric_path, config = metrics[0]
|
|
58
59
|
name = config.name
|
|
60
|
+
interval_seconds = config.get_interval().seconds
|
|
61
|
+
|
|
62
|
+
# Seed the synced labeler with the newest already-marked incidents from the
|
|
63
|
+
# shared store incidents/<metric>/ (the same files dtk autotune reads), so
|
|
64
|
+
# labeling round-trips across both tools. Best-effort — a missing/bad file
|
|
65
|
+
# just yields no seed.
|
|
66
|
+
incidents_dir = project_root / "incidents" / name
|
|
67
|
+
preload_incidents: list[dict[str, str]] = []
|
|
68
|
+
newest = newest_labels_file(incidents_dir)
|
|
69
|
+
if newest is not None:
|
|
70
|
+
try:
|
|
71
|
+
preload_incidents = load_incidents_for_display(
|
|
72
|
+
newest, interval_seconds=interval_seconds, metric_name=name
|
|
73
|
+
)
|
|
74
|
+
click.echo(
|
|
75
|
+
f" Seeded {len(preload_incidents)} incident(s) from "
|
|
76
|
+
f"{newest.relative_to(project_root)}"
|
|
77
|
+
)
|
|
78
|
+
except Exception as exc: # noqa: BLE001 — preload is a best-effort convenience
|
|
79
|
+
click.echo(click.style(f" Could not preload {newest}: {exc}", fg="yellow"))
|
|
59
80
|
|
|
60
81
|
from_dt = parse_date(from_date) if from_date else None
|
|
61
82
|
to_dt = parse_date(to_date) if to_date else None
|
|
@@ -68,6 +89,7 @@ def run_tune(
|
|
|
68
89
|
start=from_dt,
|
|
69
90
|
end=to_dt,
|
|
70
91
|
project_name=project_name,
|
|
92
|
+
incidents=preload_incidents,
|
|
71
93
|
)
|
|
72
94
|
n_points = len(payload["points"])
|
|
73
95
|
if n_points == 0:
|
|
@@ -97,6 +119,9 @@ def run_tune(
|
|
|
97
119
|
project_root=project_root,
|
|
98
120
|
open_browser=not no_open,
|
|
99
121
|
echo=click.echo,
|
|
122
|
+
metric_name=name,
|
|
123
|
+
incidents_dir=incidents_dir,
|
|
124
|
+
interval_seconds=interval_seconds,
|
|
100
125
|
)
|
|
101
126
|
if applied is None:
|
|
102
127
|
echo_noop(name, "tuning cancelled — metric unchanged")
|
|
@@ -26,6 +26,7 @@ Subclasses define:
|
|
|
26
26
|
|
|
27
27
|
from __future__ import annotations
|
|
28
28
|
|
|
29
|
+
import logging
|
|
29
30
|
import math
|
|
30
31
|
from abc import abstractmethod
|
|
31
32
|
from typing import Any
|
|
@@ -40,6 +41,8 @@ from detectkit.detectors.seasonality import (
|
|
|
40
41
|
)
|
|
41
42
|
from detectkit.utils.stats import effective_sample_size, weighted_median
|
|
42
43
|
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
43
46
|
_INPUT_TYPES = {"values", "changes", "absolute_changes", "log_changes"}
|
|
44
47
|
_CHANGE_INPUT_TYPES = {"changes", "absolute_changes", "log_changes"}
|
|
45
48
|
_SMOOTHING_METHODS = {None, "ema", "sma"}
|
|
@@ -141,6 +144,10 @@ class WindowedStatDetector(BaseDetector):
|
|
|
141
144
|
weight_decay=weight_decay,
|
|
142
145
|
detrend=detrend,
|
|
143
146
|
)
|
|
147
|
+
# Whether the one-time "seasonality groups can't fill this window" check
|
|
148
|
+
# has run for this instance (so the warning fires at most once per run,
|
|
149
|
+
# not once per detect() batch).
|
|
150
|
+
self._underfill_checked: bool = False
|
|
144
151
|
|
|
145
152
|
# ------------------------------------------------------------------
|
|
146
153
|
# Subclass hooks
|
|
@@ -386,6 +393,56 @@ class WindowedStatDetector(BaseDetector):
|
|
|
386
393
|
# (age 0) is values + slope * age
|
|
387
394
|
return (med_new - med_old) / (age_old - age_new)
|
|
388
395
|
|
|
396
|
+
def _warn_if_groups_cannot_fill(
|
|
397
|
+
self,
|
|
398
|
+
seasonality_components: list[str | list[str]],
|
|
399
|
+
seasonality_dict: dict[str, np.ndarray],
|
|
400
|
+
window_size: int,
|
|
401
|
+
min_samples_per_group: int,
|
|
402
|
+
) -> None:
|
|
403
|
+
"""Warn once if the window is too small to ever fill a seasonality group.
|
|
404
|
+
|
|
405
|
+
Per-group statistics only engage when the trailing window holds at least
|
|
406
|
+
``min_samples_per_group`` points sharing the current point's seasonal key.
|
|
407
|
+
Same-key points recur every *cardinality* positions on the grid, so the
|
|
408
|
+
window must hold roughly ``min_samples_per_group * cardinality`` points
|
|
409
|
+
before any group fills — otherwise EVERY point silently falls back to the
|
|
410
|
+
global (un-conditioned) band and the configured seasonality has no effect.
|
|
411
|
+
This is easy to hit by accident (e.g. the default ``window_size=100`` with
|
|
412
|
+
24-key hourly seasonality needs ``>= 240``), so surface it loudly once.
|
|
413
|
+
"""
|
|
414
|
+
if self._underfill_checked:
|
|
415
|
+
return
|
|
416
|
+
self._underfill_checked = True
|
|
417
|
+
|
|
418
|
+
for group in seasonality_components:
|
|
419
|
+
cols = [group] if isinstance(group, str) else list(group)
|
|
420
|
+
present = [c for c in cols if c in seasonality_dict]
|
|
421
|
+
if not present:
|
|
422
|
+
continue
|
|
423
|
+
# Distinct seasonal keys actually present in the data (conjunction of
|
|
424
|
+
# the group's columns) — the per-key recurrence period on the grid.
|
|
425
|
+
keys = set(zip(*[seasonality_dict[c].tolist() for c in present], strict=False))
|
|
426
|
+
cardinality = len(keys)
|
|
427
|
+
if cardinality <= 0:
|
|
428
|
+
continue
|
|
429
|
+
needed = min_samples_per_group * cardinality
|
|
430
|
+
if window_size < needed:
|
|
431
|
+
logger.warning(
|
|
432
|
+
"%s: seasonality group %s has %d distinct key(s) but window_size=%d "
|
|
433
|
+
"holds only ~%d same-key point(s) (< min_samples_per_group=%d), so this "
|
|
434
|
+
"group falls back to global statistics and the seasonality has no effect. "
|
|
435
|
+
"Increase window_size to >= %d (min_samples_per_group * distinct keys), "
|
|
436
|
+
"lower min_samples_per_group, or use a coarser grouping.",
|
|
437
|
+
self.__class__.__name__,
|
|
438
|
+
cols,
|
|
439
|
+
cardinality,
|
|
440
|
+
window_size,
|
|
441
|
+
window_size // cardinality,
|
|
442
|
+
min_samples_per_group,
|
|
443
|
+
needed,
|
|
444
|
+
)
|
|
445
|
+
|
|
389
446
|
# ------------------------------------------------------------------
|
|
390
447
|
# Detection pipeline
|
|
391
448
|
# ------------------------------------------------------------------
|
|
@@ -415,6 +472,9 @@ class WindowedStatDetector(BaseDetector):
|
|
|
415
472
|
seasonality_dict = {}
|
|
416
473
|
if seasonality_components and len(seasonality_data) > 0 and seasonality_columns:
|
|
417
474
|
seasonality_dict = parse_seasonality_data(seasonality_data, seasonality_columns)
|
|
475
|
+
self._warn_if_groups_cannot_fill(
|
|
476
|
+
seasonality_components, seasonality_dict, window_size, min_samples_per_group
|
|
477
|
+
)
|
|
418
478
|
|
|
419
479
|
weight_lut = self._build_weight_lut(timestamps)
|
|
420
480
|
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use strict";(()=>{var ye={"--term-bg":"#211e1a","--clay":"#d15b36","--st-anomaly":"#d63232","--st-recovery":"#36a64f","--st-nodata":"#f0ad4e","--st-error":"#5a7a8c","--faint":"#9a9384","--muted":"#6e675b","--border":"#332f29","--term-border":"#332f29"};function L(e){return getComputedStyle(document.documentElement).getPropertyValue(e).trim()||ye[e]||"#888"}function we(e){let t=e.replace("#","").trim();t.length===3&&(t=t[0]+t[0]+t[1]+t[1]+t[2]+t[2]);let n=parseInt(t,16);return t.length!==6||Number.isNaN(n)?[209,91,54]:[n>>16&255,n>>8&255,n&255]}function N(e,t){let[n,i,a]=we(e);return`rgba(${n},${i},${a},${t})`}function ee(e){let t=Math.max(1,window.devicePixelRatio||1),n=e.clientWidth||e.offsetWidth||0,i=e.clientHeight||e.offsetHeight||0;return e.width=Math.round(n*t),e.height=Math.round(i*t),t}function te(e,t,n,i){let a=()=>e.width-(t.l+t.r)*i,h=()=>e.height-(t.t+t.b)*i,r=()=>n.tmax-n.tmin||1,l=()=>n.vmax-n.vmin||1;return{px:m=>t.l*i+(m-n.tmin)/r()*a(),py:m=>e.height-t.b*i-(m-n.vmin)/l()*h(),tAt:m=>n.tmin+(m-t.l*i)/(a()||1)*r(),vAt:m=>n.vmin+(e.height-t.b*i-m)/(h()||1)*l(),plotW:a,plotH:h,tspan:r}}var F=Number.isFinite;function ne(e,t,n,i,a,h,r,l,p,f,b,u){let m=t.length,$=Math.max(1,Math.round(r)),C=a-i||1,T=0;for(let y=0;y<m;y++){let s=n[y];!F(s)||t[y]<i||t[y]>a||T++}if(e.strokeStyle=f,e.lineWidth=b*u,e.lineJoin="round",e.beginPath(),T<=$){let y=!1;for(let s=0;s<m;s++){let S=n[s],M=t[s];if(!F(S)||M<i||M>a){y=!1;continue}let E=l(M),R=p(S);y?e.lineTo(E,R):(e.moveTo(E,R),y=!0)}}else{let y=new Array($).fill(null),s=new Array($).fill(null);for(let M=0;M<m;M++){let E=n[M],R=t[M];if(!F(E)||R<i||R>a)continue;let A=Math.floor((R-i)/C*($-1));A=A<0?0:A>$-1?$-1:A,(y[A]===null||E<y[A])&&(y[A]=E),(s[A]===null||E>s[A])&&(s[A]=E)}let S=!1;for(let M=0;M<$;M++){if(s[M]===null){S=!1;continue}let E=h+M,R=p(s[M]),A=p(y[M]);S?e.lineTo(E,R):(e.moveTo(E,R),S=!0),e.lineTo(E,A)}}e.stroke()}function re(e){let t=[],n=-1;for(let i=0;i<e.length;i++){let a=e[i];a.lo!==null&&a.hi!==null&&F(a.lo)&&F(a.hi)?n===-1&&(n=i):n!==-1&&(t.push([n,i-1]),n=-1)}return n!==-1&&t.push([n,e.length-1]),t}function oe(e,t,n,i,a,h,r,l,p){e.fillStyle=N(h,r);for(let[f,b]of n){e.beginPath(),e.moveTo(i(t[f].t),a(t[f].hi));for(let u=f+1;u<=b;u++)e.lineTo(i(t[u].t),a(t[u].hi));for(let u=b;u>=f;u--)e.lineTo(i(t[u].t),a(t[u].lo));e.closePath(),e.fill()}e.strokeStyle=N(h,l),e.lineWidth=1*p;for(let[f,b]of n)for(let u of["hi","lo"]){e.beginPath();for(let m=f;m<=b;m++){let $=i(t[m].t),C=a(t[m][u]);m===f?e.moveTo($,C):e.lineTo($,C)}e.stroke()}}function ie(e,t,n,i,a,h,r,l){for(let p=0;p<t.length;p++){let f=t[p];if(!F(f.v)||f.t<n||f.t>i)continue;let b=a(f.t),u=h(f.v);e.fillStyle=N(r,.18),e.beginPath(),e.arc(b,u,6*l,0,Math.PI*2),e.fill(),e.fillStyle=r,e.beginPath(),e.arc(b,u,3*l,0,Math.PI*2),e.fill()}}function se(e,t,n,i,a,h,r,l,p,f,b){e.font=`${11*b}px ui-monospace, 'JetBrains Mono', monospace`,e.textBaseline="middle";for(let m=0;m<=4;m++){let $=i.vmin+(i.vmax-i.vmin)*m/4,C=h($);e.strokeStyle=N(p,.1),e.lineWidth=1*b,e.beginPath(),e.moveTo(n.l*b,C),e.lineTo(t.width-n.r*b,C),e.stroke(),e.fillStyle=f,e.textAlign="right",e.fillText(W($),(n.l-8)*b,C)}e.textBaseline="top";let u=l-r||1;for(let m=0;m<=5;m++){let $=r+u*m/5,C=a($);e.fillStyle=f,e.textAlign=m===0?"left":m===5?"right":"center",e.fillText($e($,u),C,(t.height-n.b+7)*b)}}function W(e){let t=Math.abs(e);return t>=1e3?e.toFixed(0):t>=10?e.toFixed(1):t>=1?e.toFixed(2):e.toFixed(3)}function $e(e,t){let n=new Date(e).toISOString();return t<2*864e5?n.slice(5,16).replace("T"," "):n.slice(5,10)}function X(e){return new Date(e).toISOString().slice(0,19).replace("T"," ")}function ae(e){let t=Math.round(e/6e4);if(t<60)return t+"m";let n=Math.floor(t/60),i=t%60;if(n<24)return n+"h"+(i?" "+i+"m":"");let a=Math.floor(n/24),h=n%24;return a+"d"+(h?" "+h+"h":"")}function le(e,t,n){return{left:t.l*n,top:t.t*n,right:e.width-t.r*n,bottom:e.height-t.b*n}}function ce(e,t,n,i,a,h,r){let l=le(t,n,i),p=Math.max(l.left,Math.min(a(h),l.right));p<=l.left+.5||(e.save(),e.fillStyle="rgba(17,15,13,0.42)",e.fillRect(l.left,l.top,p-l.left,l.bottom-l.top),e.strokeStyle=N(L("--faint"),.7),e.lineWidth=1*i,e.setLineDash([4*i,4*i]),e.beginPath(),e.moveTo(p,l.top),e.lineTo(p,l.bottom),e.stroke(),e.setLineDash([]),e.fillStyle=N(L("--faint"),.95),e.font=`${10*i}px ui-monospace, monospace`,e.textAlign="left",e.textBaseline="top",e.fillText(r,p+6*i,l.top+5*i),e.restore())}function de(e,t,n,i,a,h,r){let l=le(t,n,i),p=5*i;e.save();for(let f=0;f<h.length;f++){let b=h[f],u=a(b.t);if(u<l.left-1||u>l.right+1)continue;let m=r(b.kind);e.strokeStyle=N(m,.45),e.lineWidth=1*i,e.beginPath(),e.moveTo(u,l.top),e.lineTo(u,l.bottom),e.stroke(),e.fillStyle=m,e.beginPath(),e.moveTo(u-p,l.top),e.lineTo(u+p,l.top),e.lineTo(u,l.top+p*1.4),e.closePath(),e.fill()}e.restore()}var P={l:56,r:16,t:14,b:28},Me=300*1e3,d="dtk-report";function G(e){return e==="recovery"?L("--st-recovery"):e==="no_data"?L("--st-nodata"):L("--st-anomaly")}function Ae(e){return e==="recovery"?"recovery":e==="no_data"?"no-data":"anomaly"}var w=e=>String(e).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""),K=(e,t,n)=>Math.max(t,Math.min(n,e)),ue=["--clay","--st-error","--st-recovery","--st-nodata"];function Te(e,t){Re(),t.classList.add(d),t.innerHTML="";let n=document.createElement("div");n.className="dtk-report-root",t.appendChild(n);let i=new Map;for(let s of e.points)s.v!==null&&i.set(s.t,s.v);let a=e.detectors.map((s,S)=>{let M=s.effective_start,E=s.points.map(A=>M!==null&&A.t<M?{t:A.t,lo:null,hi:null}:{t:A.t,lo:A.lo,hi:A.hi}),R=[];for(let A of s.points)if(A.a===1&&(M===null||A.t>=M)){let I=i.get(A.t);I!==void 0&&R.push({t:A.t,v:I})}return{det:s,band:E,anomalies:R,effectiveStart:M,color:L(ue[S%ue.length]),shown:S===0}});a.length===1&&(a[0].shown=!0),n.appendChild(Se(e));let h=null;a.length>1&&(h=Ce(a,()=>u.repaint()),n.appendChild(h));let r=document.createElement("div");r.className="dtk-bar",n.appendChild(r);let l=document.createElement("div");l.className="dtk-presets",r.appendChild(l);let p=document.createElement("div");p.className="dtk-readout",p.textContent="hover the chart for a point readout",r.appendChild(p);let f=document.createElement("div");f.className="dtk-chart";let b=document.createElement("canvas");f.appendChild(b),n.appendChild(f);let u=Pe(b,e,a,i,s=>{p.innerHTML=s}),m=[{label:"24h",ms:24*3600*1e3},{label:"7d",ms:7*86400*1e3},{label:"30d",ms:30*86400*1e3},{label:"All",ms:null}];for(let s of m){let S=document.createElement("button");S.className="dtk-preset",S.textContent=s.label,S.onclick=()=>{s.ms===null?u.resetView():u.setView(e.period.end-s.ms,e.period.end),Le(l,S)},l.appendChild(S)}let $=document.createElement("label");$.className="dtk-zero",$.title="Draw a horizontal line at y = 0 and scale the chart to include zero.";let C=document.createElement("input");C.type="checkbox",C.onchange=()=>u.setZeroLine(C.checked),$.appendChild(C),$.appendChild(document.createTextNode(" y = 0")),l.appendChild($),n.appendChild(Ee(e,s=>u.focusAlert(s))),u.resize();let T=0,y=()=>{T||(T=requestAnimationFrame(()=>{T=0,u.resize()}))};window.addEventListener("resize",y)}function Se(e){let t=document.createElement("div");t.className="dtk-header";let n=e.interval_seconds/60,i=e.interval_seconds>=86400?e.interval_seconds/86400+"d":e.interval_seconds>=3600?e.interval_seconds/3600+"h":n>=1?n+"min":e.interval_seconds+"s",a=e.project?`${w(e.project)} \xB7 ${w(e.metric)}`:w(e.metric),h=e.summary;return t.innerHTML=`<div class="dtk-h-top"><h1 class="dtk-title">${a}</h1><div class="dtk-meta">${w(X(e.period.start))} \u2013 ${w(X(e.period.end))} \xB7 interval ${w(i)}${e.generated_at?` \xB7 generated ${w(e.generated_at)}`:""}</div></div>`+(e.description?`<p class="dtk-desc">${w(e.description)}</p>`:"")+'<div class="dtk-chips">'+Y("anomalies",h.anomalies,"--st-anomaly")+Y("alerts",h.alerts,"--clay")+Y("recoveries",h.recoveries,"--st-recovery")+Y("no-data",h.no_data,"--st-nodata")+"</div>",t}function Y(e,t,n){let i=L(n);return`<span class="dtk-chip"><span class="dtk-dot" style="background:${w(i)}"></span><span class="dtk-chip-n">${t}</span><span class="dtk-chip-l">${w(e)}</span></span>`}function Ce(e,t){let n=document.createElement("div");return n.className="dtk-legend",e.forEach(i=>{let a=document.createElement("button");a.className="dtk-legend-item"+(i.shown?"":" off"),a.innerHTML=`<span class="dtk-swatch" style="background:${w(i.color)}"></span><span class="dtk-legend-name">${w(i.det.name)}</span><span class="dtk-legend-id">${w(i.det.id.slice(0,8))}</span><span class="dtk-legend-n">${i.det.anomaly_count}</span>`,a.onclick=()=>{i.shown=!i.shown,a.classList.toggle("off",!i.shown),t()},n.appendChild(a)}),n}function Ee(e,t){let n=document.createElement("div");n.className="dtk-alerts";let i=document.createElement("div");if(i.className="dtk-alerts-head",i.textContent=`Alerts (${e.alerts.length})`,n.appendChild(i),e.alerts.length===0){let r=document.createElement("div");return r.className="dtk-alerts-empty",r.textContent="No alerts fired in this period.",n.appendChild(r),n}let a=document.createElement("div");a.className="dtk-alerts-list";let h=[...e.alerts].sort((r,l)=>l.t-r.t);for(let r of h){let l=document.createElement("button");l.className="dtk-alert-row";let p=G(r.kind),f=r.direction!=="none"?` \xB7 ${w(r.direction)}`:"",b=r.severity>0?` \xB7 sev ${r.severity.toFixed(2)}`:"",u=r.value!==null?` \xB7 value ${W(r.value)}`:"",m=r.onset!==null&&r.kind!=="no_data"?` \xB7 ${ae(Math.max(0,r.t-r.onset))} (${r.consecutive} pts)`:"";l.innerHTML=`<span class="dtk-alert-time">${w(X(r.t))}</span><span class="dtk-badge" style="background:${w(N(p,.18))};color:${w(p)};border-color:${w(N(p,.5))}">${w(Ae(r.kind))}</span><span class="dtk-alert-body"><span class="dtk-alert-rule">${w(r.rule)}</span><span class="dtk-alert-sub">${w(r.detector)}${f}${b}${u}${w(m)}</span></span>`,l.onclick=()=>t(r),a.appendChild(l)}return n.appendChild(a),n}function Le(e,t){e.querySelectorAll(".dtk-preset").forEach(n=>n.classList.remove("active")),t.classList.add("active")}function Pe(e,t,n,i,a){let h=e.getContext("2d");if(!h)throw new Error("report: 2D context unavailable");let r=h,l=t.points.map(o=>o.t),p=t.points.map(o=>o.v===null?NaN:o.v),f=t.period.start,b=t.period.end,u=b-f||1,m=Math.min(Me,u),$=0,C=1;E();let T=f,y=b,s=1,S=null,M=!1;function E(){let o=1/0,c=-1/0;for(let k of p)Number.isFinite(k)&&(k<o&&(o=k),k>c&&(c=k));for(let k of n)for(let x of k.band)x.lo!==null&&Number.isFinite(x.lo)&&x.lo<o&&(o=x.lo),x.hi!==null&&Number.isFinite(x.hi)&&x.hi>c&&(c=x.hi);(!Number.isFinite(o)||!Number.isFinite(c))&&(o=0,c=1),M&&(o>0&&(o=0),c<0&&(c=0)),c<=o&&(c=o+1);let v=(c-o)*.06;$=o-v,C=c+v}function R(o){M!==o&&(M=o,E(),V())}function A(){for(let o of n)if(o.shown&&o.effectiveStart!==null)return o.effectiveStart;for(let o of n)if(o.effectiveStart!==null)return o.effectiveStart;return null}function I(){return{tmin:T,tmax:y,vmin:$,vmax:C}}function fe(){return te(e,P,I(),s)}function O(o,c){let v=c-o;if(v<m){let k=(o+c)/2;o=k-m/2,c=k+m/2,v=m}v>=u&&(o=f,c=b),o<f&&(c+=f-o,o=f),c>b&&(o-=c-b,c=b),T=K(o,f,b),y=K(c,f,b),V()}function Z(){T=f,y=b,V()}function pe(o){let c=o.onset!==null?Math.min(o.onset,o.t):o.t,v=o.t,x=Math.max(v-c,m)*1.5+m;O(c-x,v+x)}let j=0;function q(){j===0&&(j=requestAnimationFrame(V))}function V(){if(j=0,e.width===0||e.height===0)return;let o=fe(),c=L("--faint"),v=L("--muted"),k=L("--clay");if(r.fillStyle=L("--term-bg"),r.fillRect(0,0,e.width,e.height),l.length===0)return;if(se(r,e,P,I(),o.px,o.py,T,y,c,v,s),M&&$<=0&&C>=0){let g=o.py(0);r.strokeStyle=N(v,.6),r.lineWidth=1.25*s,r.beginPath(),r.moveTo(P.l*s,g),r.lineTo(e.width-P.r*s,g),r.stroke(),r.fillStyle=v,r.textAlign="right",r.textBaseline="middle",r.fillText("0",(P.l-8)*s,g)}r.save(),r.beginPath(),r.rect(P.l*s,P.t*s,o.plotW(),o.plotH()),r.clip();let x=P.t*s,z=o.plotH();for(let g of t.alerts){if(g.onset===null||g.kind==="no_data")continue;let B=Math.min(g.onset,g.t),U=Math.max(g.onset,g.t);if(U<T||B>y)continue;let ke=G(g.kind),Q=o.px(B),ge=o.px(U);r.fillStyle=N(ke,.08),r.fillRect(Q,x,Math.max(ge-Q,1*s),z)}for(let g of n){if(!g.shown)continue;let B=re(g.band);oe(r,g.band,B,o.px,o.py,g.color,.13,.4,s)}ne(r,l,p,T,y,P.l*s,o.plotW(),o.px,o.py,k,1.5,s);for(let g of n)g.shown&&ie(r,g.anomalies,T,y,o.px,o.py,L("--st-anomaly"),s);let D=A();D!==null&&D>T&&ce(r,e,P,s,o.px,D,"detection at full power \u2192");let H=t.alerts.map(g=>({t:g.t,kind:g.kind}));de(r,e,P,s,o.px,H,g=>G(g)),S!==null&&he(o,x,z,c),r.restore()}function be(o){let c=l;if(c.length===0)return-1;let v=0,k=c.length-1;for(;v<k;){let x=v+k>>1;c[x]<o?v=x+1:k=x}return v>0&&o-c[v-1]<c[v]-o&&(v-=1),v}function he(o,c,v,k){let x=be(S);if(x<0)return;let z=l[x];if(z<T||z>y){a("hover the chart for a point readout");return}let D=o.px(z);r.strokeStyle=N(k,.45),r.lineWidth=1*s,r.setLineDash([2*s,2*s]),r.beginPath(),r.moveTo(D,c),r.lineTo(D,c+v),r.stroke(),r.setLineDash([]);let H=p[x];if(Number.isFinite(H)){let g=o.py(H);r.fillStyle=L("--term-bg"),r.beginPath(),r.arc(D,g,4*s,0,Math.PI*2),r.fill(),r.strokeStyle=L("--clay"),r.lineWidth=2*s,r.beginPath(),r.arc(D,g,4*s,0,Math.PI*2),r.stroke()}ve(x)}function ve(o){let c=l[o],v=p[o],k=`<span class="dtk-ro-t">${w(X(c))}</span>`;k+=`<span class="dtk-ro-v">value ${Number.isFinite(v)?W(v):"\u2014"}</span>`;for(let x of n){if(!x.shown)continue;let z=x.band[o],D=x.det.points[o];if(z&&z.lo!==null&&z.hi!==null){let H=D&&D.a===1,g=H&&D.sev!==null?` sev ${D.sev.toFixed(2)}`:"",B=H?`<span class="dtk-ro-anom" style="color:${w(L("--st-anomaly"))}">anomaly${w(g)}</span>`:'<span class="dtk-ro-ok">ok</span>';k+=`<span class="dtk-ro-det"><span class="dtk-swatch" style="background:${w(x.color)}"></span>${w(x.det.name)}: [${W(z.lo)}, ${W(z.hi)}] ${B}</span>`}}a(k)}function J(o){let c=e.getBoundingClientRect(),v=(o-c.left-P.l)/(c.width-(P.l+P.r)||1);return T+K(v,0,1)*(y-T)}e.addEventListener("wheel",o=>{o.preventDefault();let c=J(o.clientX),v=y-T,k=K(v*Math.pow(1.0015,o.deltaY),m,u),x=(c-T)/(v||1);O(c-x*k,c-x*k+k)},{passive:!1});let _=null;e.addEventListener("mousedown",o=>{_={x:o.clientX,vMin:T,vMax:y},e.style.cursor="grabbing"}),window.addEventListener("mousemove",o=>{if(!_)return;let c=e.getBoundingClientRect(),v=(_.vMax-_.vMin)/(c.width-(P.l+P.r)||1),k=(o.clientX-_.x)*v;O(_.vMin-k,_.vMax-k)}),window.addEventListener("mouseup",()=>{_&&(_=null,e.style.cursor="crosshair")}),e.addEventListener("mousemove",o=>{_||(S=J(o.clientX),q())}),e.addEventListener("mouseleave",()=>{S!==null&&(S=null,a("hover the chart for a point readout"),q())}),e.addEventListener("dblclick",()=>Z()),e.style.cursor="crosshair";function xe(){s=ee(e),V()}return{repaint:()=>q(),resize:xe,setView:O,resetView:Z,focusAlert:pe,setZeroLine:R}}var me=!1;function Re(){if(me)return;me=!0;let e=`
|
|
2
|
+
.${d}{--term-bg:#211e1a;--term-border:#332f29;--term-text:#c9c2b4;
|
|
3
|
+
--clay:#d15b36;--clay-700:#b4471f;--ink:#1b1916;--paper:#f5f1e8;--surface:#fbf9f3;
|
|
4
|
+
--border:#e6e0d4;--muted:#6e675b;--faint:#9a9384;
|
|
5
|
+
--anom:#d63232;--rec:#36a64f;--nod:#f0ad4e;
|
|
6
|
+
--sans:'Schibsted Grotesk',ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;
|
|
7
|
+
--mono:'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
|
8
|
+
font-family:var(--sans);color:var(--ink);}
|
|
9
|
+
.${d} *{box-sizing:border-box;}
|
|
10
|
+
.${d} .dtk-report-root{max-width:1100px;margin:0 auto;padding:20px 18px 40px;}
|
|
11
|
+
/* --- header row ----------------------------------------------------------- */
|
|
12
|
+
.${d} .dtk-header{margin-bottom:16px;padding-left:12px;
|
|
13
|
+
border-left:3px solid var(--clay);}
|
|
14
|
+
.${d} .dtk-h-top{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 14px;}
|
|
15
|
+
.${d} .dtk-title{font-size:21px;font-weight:700;margin:0;color:var(--ink);
|
|
16
|
+
font-family:var(--sans);letter-spacing:-0.01em;}
|
|
17
|
+
.${d} .dtk-meta{font-size:12px;color:var(--muted);font-family:var(--mono);}
|
|
18
|
+
.${d} .dtk-desc{margin:8px 0 0;font-size:13px;color:var(--muted);max-width:760px;
|
|
19
|
+
line-height:1.5;}
|
|
20
|
+
/* --- summary chips (surface cards) ---------------------------------------- */
|
|
21
|
+
.${d} .dtk-chips{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px;}
|
|
22
|
+
.${d} .dtk-chip{display:inline-flex;align-items:center;gap:7px;padding:5px 11px;
|
|
23
|
+
background:var(--surface);border:1px solid var(--border);border-radius:10px;font-size:12px;}
|
|
24
|
+
.${d} .dtk-dot{width:8px;height:8px;border-radius:50%;display:inline-block;}
|
|
25
|
+
.${d} .dtk-chip-n{font-weight:700;font-family:var(--mono);color:var(--ink);}
|
|
26
|
+
.${d} .dtk-chip-l{color:var(--faint);font-family:var(--mono);font-size:11px;
|
|
27
|
+
text-transform:uppercase;letter-spacing:0.05em;}
|
|
28
|
+
/* --- detector legend ------------------------------------------------------ */
|
|
29
|
+
.${d} .dtk-legend{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:10px;}
|
|
30
|
+
.${d} .dtk-legend-item{display:inline-flex;align-items:center;gap:7px;padding:5px 11px;
|
|
31
|
+
background:var(--surface);border:1px solid var(--border);border-radius:8px;cursor:pointer;
|
|
32
|
+
color:var(--ink);font-size:12px;font-family:var(--sans);transition:border-color .12s ease;}
|
|
33
|
+
.${d} .dtk-legend-item:hover{border-color:var(--clay);}
|
|
34
|
+
.${d} .dtk-legend-item.off{opacity:0.45;}
|
|
35
|
+
.${d} .dtk-legend-id{color:var(--faint);font-family:var(--mono);font-size:11px;}
|
|
36
|
+
.${d} .dtk-legend-n{color:var(--anom);font-weight:700;font-family:var(--mono);}
|
|
37
|
+
.${d} .dtk-swatch{width:10px;height:10px;border-radius:2px;display:inline-block;}
|
|
38
|
+
/* --- toolbar (presets + readout) ------------------------------------------ */
|
|
39
|
+
.${d} .dtk-bar{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;
|
|
40
|
+
gap:8px;margin-bottom:8px;}
|
|
41
|
+
.${d} .dtk-presets{display:flex;gap:5px;}
|
|
42
|
+
.${d} .dtk-preset{padding:5px 13px;background:var(--surface);
|
|
43
|
+
border:1px solid var(--border);border-radius:8px;color:var(--muted);cursor:pointer;
|
|
44
|
+
font-size:12px;font-family:var(--sans);transition:border-color .12s ease,color .12s ease;}
|
|
45
|
+
.${d} .dtk-preset:hover{border-color:var(--clay);color:var(--ink);}
|
|
46
|
+
.${d} .dtk-preset.active{background:var(--clay);color:#fff;border-color:var(--clay);}
|
|
47
|
+
.${d} .dtk-zero{display:inline-flex;align-items:center;gap:5px;margin-left:6px;
|
|
48
|
+
font-size:12px;color:var(--muted);font-family:var(--sans);cursor:pointer;user-select:none;}
|
|
49
|
+
.${d} .dtk-zero input{accent-color:var(--clay);cursor:pointer;}
|
|
50
|
+
.${d} .dtk-readout{font-size:11px;color:var(--muted);
|
|
51
|
+
font-family:var(--mono);display:flex;flex-wrap:wrap;gap:4px 12px;align-items:center;}
|
|
52
|
+
.${d} .dtk-readout .dtk-swatch{margin-right:4px;}
|
|
53
|
+
.${d} .dtk-ro-t{font-weight:700;color:var(--ink);}
|
|
54
|
+
/* --- chart panel (dark terminal surface) ---------------------------------- */
|
|
55
|
+
.${d} .dtk-chart{position:relative;width:100%;height:360px;background:var(--term-bg);
|
|
56
|
+
border:1px solid var(--term-border);border-radius:12px;overflow:hidden;
|
|
57
|
+
box-shadow:0 24px 60px -30px rgba(27,25,22,.45);}
|
|
58
|
+
.${d} .dtk-chart canvas{width:100%;height:100%;display:block;}
|
|
59
|
+
/* --- alerts list (surface cards) ------------------------------------------ */
|
|
60
|
+
.${d} .dtk-alerts{margin-top:18px;}
|
|
61
|
+
.${d} .dtk-alerts-head{font-size:12px;font-weight:600;color:var(--faint);
|
|
62
|
+
margin-bottom:9px;font-family:var(--mono);text-transform:uppercase;letter-spacing:0.06em;}
|
|
63
|
+
.${d} .dtk-alerts-empty{font-size:13px;color:var(--muted);padding:8px 0;}
|
|
64
|
+
.${d} .dtk-alerts-list{display:flex;flex-direction:column;gap:5px;
|
|
65
|
+
max-height:340px;overflow:auto;}
|
|
66
|
+
.${d} .dtk-alert-row{display:flex;align-items:center;gap:10px;width:100%;text-align:left;
|
|
67
|
+
padding:8px 11px;background:var(--surface);border:1px solid var(--border);
|
|
68
|
+
border-radius:8px;cursor:pointer;color:var(--ink);font-family:var(--sans);
|
|
69
|
+
transition:border-color .12s ease,box-shadow .12s ease;}
|
|
70
|
+
.${d} .dtk-alert-row:hover{border-color:var(--clay);
|
|
71
|
+
box-shadow:0 4px 14px -8px rgba(27,25,22,.35);}
|
|
72
|
+
.${d} .dtk-alert-time{font-size:11px;color:var(--muted);
|
|
73
|
+
font-family:var(--mono);white-space:nowrap;min-width:142px;}
|
|
74
|
+
.${d} .dtk-badge{display:inline-block;padding:1px 8px;border-radius:10px;font-size:11px;
|
|
75
|
+
font-weight:700;border:1px solid;text-transform:uppercase;letter-spacing:0.03em;white-space:nowrap;}
|
|
76
|
+
.${d} .dtk-alert-body{display:flex;flex-direction:column;gap:2px;min-width:0;}
|
|
77
|
+
.${d} .dtk-alert-rule{font-size:12px;color:var(--ink);
|
|
78
|
+
font-family:var(--mono);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
|
79
|
+
.${d} .dtk-alert-sub{font-size:11px;color:var(--muted);}
|
|
80
|
+
`,t=document.createElement("style");t.setAttribute("data-dtk-report",""),t.textContent=e,document.head.appendChild(t)}window.__DTK_REPORT__={render:Te};})();
|