Skip to content

Commit 78d1c29

Browse files
diogodkclaude
authored andcommitted
feat(mcp): add monitor L2 read tools — per-table events, configs, and schema-change audit (TG-1092)
Three drill-down tools on top of the L1 group / table inventory: - list_monitor_events(table_group_id, table_name, monitor_type, monitor_id?, include_predictions?, limit?, page?) — per-table event history scoped to one monitor type. monitor_id is required when monitor_type="metric" (Metric is the only multi-instance type — singletons reject monitor_id with a clear pointer). Per-type column shapes: * Volume: Time | Status | Row count | Lower bound | Upper bound * Freshness: Time | Status | Update detected | Detail (parsed from the SQL template's structured message) * Schema: Time | Status | Table change | Columns added | Columns dropped | Columns modified * Metric: Time | Status | Value | Lower bound | Upper bound (metric name in the heading, not as a column) include_predictions=True appends a separate `## Forecast` section listing future timestamps with predicted bounds — never interleaved into the historical events. Schema short-circuits to "not applicable"; non- Prediction-Model monitors render a "not available" note. - list_monitors(table_group_id, table_name) — configured monitors for a table. Per row: monitor_id, type (Title Case, reuses _MONITOR_LABEL), metric_name, threshold mode (derived from history_calculation: "PREDICT" → Prediction; non-empty otherwise and not Freshness → Historical; empty → Static), bounds, and the metric expression for Metric monitors. Sensitivity is surfaced as a top-level "Prediction model sensitivity" field. - list_monitor_schema_changes(table_group_id, table_name?, since?, limit?, page?) — newest-first audit log of column-level schema events (added / dropped / modified), independent of monitor-run results. All three follow the L1 contract: gated on view, resolve_monitored_table_group returns the literal "This table group is not monitored." when the group has no linked monitor suite, and never expose internal test_type codes, test_definition_id, or test_suite_id on the surface. Backing model methods: - DataStructureLog ORM wraps the existing data_structure_log audit table (no ORM mapping until now) and exposes list_for_table_group(*, table_name, since, until, page, limit). The dashboard's get_data_structure_logs delegates here. - TestDefinition.list_monitor_configs_for_table returns MonitorConfig with threshold_mode derived per row. - TestDefinition.get_singleton_monitor(suite, table, type) for the non- metric forecast lookup path. - forecast_points_from_prediction(prediction, sensitivity) — standalone helper that reads forecast rows from the prediction JSONB using epoch-ms keys with numeric comparison (matches the dashboard's reader). - TestResult.list_monitor_events_for_table runs the dashboard's CTE under the ORM with optional monitor_type filtering. ORDER BY includes results.id NULLS LAST, active_runs.id as a stable tiebreaker so the Python-side pagination doesn't duplicate or skip rows on test_time ties. - TestResult.list_metric_monitor_events — separate, simpler query path for Metric (no run-by-type CROSS JOIN, no synthesized pending rows). The dashboard's per-type events transform stays in monitors_dashboard.py because its shape is bespoke to the chart payload; centralizing just the SQL would still require a non-trivial adapter on the dashboard side. monitor_id joins the Followable IDs table as an alias for the underlying test_definition.id UUID — the abstraction stays stable if monitors move to a dedicated table later. Status helpers (_summary_status, _format_monitor_cell, _format_schema_cell, _event_status) return Title Case ("Error", "Pending", "Training", "Anomaly", "Ok"), matching the rest of the MCP layer. parse_monitor_type accepts either case so values round-trip. _parse_kv_pairs splits input_parameters on ";" (matches the writer side at execute_tests_query.py:321 and the dashboard's dict_from_kv). Closes TG-1092 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent e473ec1 commit 78d1c29

10 files changed

Lines changed: 1785 additions & 56 deletions

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
from dataclasses import dataclass
2+
from datetime import date, datetime
3+
from uuid import UUID, uuid4
4+
5+
from sqlalchemy import Column, String, asc, desc, select
6+
from sqlalchemy.dialects import postgresql
7+
8+
from testgen.common.models import get_current_session
9+
from testgen.common.models.entity import Entity, EntityMinimal
10+
11+
# Schema-change codes stored in ``data_structure_log.change``. ``M`` only ever
12+
# appears on column-level rows (column-name set, old/new data type populated).
13+
SCHEMA_CHANGE_ADDED = "A"
14+
SCHEMA_CHANGE_DROPPED = "D"
15+
SCHEMA_CHANGE_MODIFIED = "M"
16+
17+
18+
@dataclass
19+
class DataStructureLogEntry(EntityMinimal):
20+
"""One schema-change event for a table or column in a table group.
21+
22+
``column_name`` is ``None`` for table-level events (add / drop of an entire
23+
table); set for column-level events. ``change`` is the stored single-letter
24+
code (``A`` / ``D`` / ``M``); callers translate to user-facing words.
25+
"""
26+
log_id: UUID
27+
table_groups_id: UUID
28+
table_name: str
29+
column_name: str | None
30+
change_date: datetime
31+
change: str
32+
old_data_type: str | None
33+
new_data_type: str | None
34+
35+
36+
class DataStructureLog(Entity):
37+
__tablename__ = "data_structure_log"
38+
39+
log_id: UUID = Column(postgresql.UUID(as_uuid=True), primary_key=True, default=uuid4)
40+
table_groups_id: UUID = Column(postgresql.UUID(as_uuid=True))
41+
table_id: UUID = Column(postgresql.UUID(as_uuid=True))
42+
column_id: UUID = Column(postgresql.UUID(as_uuid=True))
43+
table_name: str = Column(String)
44+
column_name: str = Column(String)
45+
change_date: datetime = Column(postgresql.TIMESTAMP)
46+
change: str = Column(String)
47+
old_data_type: str = Column(String)
48+
new_data_type: str = Column(String)
49+
50+
_get_by = "log_id"
51+
_default_order_by = (desc(change_date), asc(table_name), asc(column_name))
52+
53+
@classmethod
54+
def list_for_table_group(
55+
cls,
56+
table_group_id: str | UUID,
57+
*,
58+
table_name: str | None = None,
59+
since: date | datetime | None = None,
60+
until: date | datetime | None = None,
61+
page: int = 1,
62+
limit: int | None = 20,
63+
) -> tuple[list[DataStructureLogEntry], int]:
64+
"""Paginated schema-change audit log for one table group, newest first.
65+
66+
Filters by ``table_name`` (exact match; the audit log stores names
67+
case-sensitively as the source emitted them), ``since`` (lower-bound on
68+
``change_date``), and ``until`` (upper-bound). ``limit=None`` skips
69+
pagination — the caller gets every matching row in one shot.
70+
"""
71+
query = select(
72+
cls.log_id,
73+
cls.table_groups_id,
74+
cls.table_name,
75+
cls.column_name,
76+
cls.change_date,
77+
cls.change,
78+
cls.old_data_type,
79+
cls.new_data_type,
80+
).where(cls.table_groups_id == table_group_id)
81+
if table_name is not None:
82+
query = query.where(cls.table_name == table_name)
83+
if since is not None:
84+
query = query.where(cls.change_date >= since)
85+
if until is not None:
86+
query = query.where(cls.change_date <= until)
87+
query = query.order_by(*cls._default_order_by)
88+
89+
if limit is None:
90+
rows = get_current_session().execute(query).mappings().all()
91+
entries = [DataStructureLogEntry(**row) for row in rows]
92+
return entries, len(entries)
93+
return cls._paginate(query, page=page, limit=limit, data_class=DataStructureLogEntry)

testgen/common/models/test_definition.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from sqlalchemy.orm import InstrumentedAttribute
2828
from sqlalchemy.sql.expression import case, literal
2929

30+
from testgen.common.enums import MonitorType
3031
from testgen.common.models import Base, get_current_session
3132
from testgen.common.models.custom_types import NullIfEmptyString, YNString, ZeroIfEmptyInteger
3233
from testgen.common.models.entity import Entity, EntityMinimal
@@ -270,6 +271,96 @@ class TestDefinitionMinimal(EntityMinimal):
270271
test_name_short: str
271272

272273

274+
# Threshold-mode labels for monitor TestDefinitions — derived from which fields
275+
# on the definition are populated. See ``TestDefinition._derive_threshold_mode``.
276+
THRESHOLD_MODE_PREDICTION = "Prediction Model"
277+
THRESHOLD_MODE_HISTORICAL = "Historical Calculation"
278+
THRESHOLD_MODE_STATIC = "Static"
279+
THRESHOLD_MODE_NONE = "N/A"
280+
281+
282+
@dataclass
283+
class MonitorForecastPoint(EntityMinimal):
284+
"""One future forecast point read from a Prediction-Model monitor's stored
285+
``prediction`` JSONB. Each point is a ``(test_time, lower_bound, upper_bound)``
286+
triple at a specific upcoming timestamp; collectively they extend the
287+
historical event series forward under the suite's active prediction
288+
sensitivity. Surfaced as a separate forecast section on
289+
``list_monitor_events``, never as an event."""
290+
test_time: datetime
291+
lower_bound: float | None
292+
upper_bound: float | None
293+
294+
295+
def forecast_points_from_prediction(
296+
prediction: dict | None,
297+
sensitivity: str,
298+
) -> list[MonitorForecastPoint]:
299+
"""Extract forecast points for a sensitivity from a monitor's stored
300+
``prediction`` JSONB, sorted by time ascending (nearest future point
301+
first).
302+
303+
The JSONB is keyed as ``"lower_tolerance|<sensitivity>" → {epoch_ms: value}``
304+
and ``"upper_tolerance|<sensitivity>" → {epoch_ms: value}`` — matches the
305+
format the dashboard reads at ``monitors_dashboard.py`` via
306+
``datetime.fromtimestamp(int(timestamp) / 1000.0, UTC)``. Returns ``[]``
307+
when the monitor isn't in Prediction Model mode (no JSONB) or when the
308+
sensitivity has no stored series. Standalone so it works against either
309+
the ``TestDefinition`` ORM row or the ``TestDefinitionSummary`` dataclass
310+
— both carry the same ``prediction`` field shape.
311+
"""
312+
if not prediction:
313+
return []
314+
lower_series = prediction.get(f"lower_tolerance|{sensitivity}") or {}
315+
upper_series = prediction.get(f"upper_tolerance|{sensitivity}") or {}
316+
if not lower_series and not upper_series:
317+
return []
318+
319+
all_keys = sorted(set(lower_series) | set(upper_series), key=int)
320+
points: list[MonitorForecastPoint] = []
321+
for k in all_keys:
322+
ts = datetime.fromtimestamp(int(k) / 1000.0, UTC)
323+
lower = lower_series.get(k)
324+
upper = upper_series.get(k)
325+
points.append(MonitorForecastPoint(
326+
test_time=ts,
327+
lower_bound=float(lower) if lower is not None else None,
328+
upper_bound=float(upper) if upper is not None else None,
329+
))
330+
return points
331+
332+
333+
@dataclass
334+
class MonitorConfig(EntityMinimal):
335+
"""One configured monitor — produced by ``TestDefinition.list_monitor_configs_for_table``.
336+
337+
``threshold_mode`` is derived from the underlying definition's
338+
``history_calculation``: ``"PREDICT"`` flags Prediction Model; any other
339+
non-empty value flags Historical Calculation (not available for Freshness);
340+
empty falls through to Static — the default for Freshness, Volume, and
341+
Metric. Schema_Drift is presence-only and reports N/A.
342+
343+
``threshold_lower`` / ``threshold_upper`` carry the bounds active under
344+
the current mode — static tolerances for Static, history-calc expressions
345+
(e.g. ``"Minimum"`` / ``"Maximum"``) for Historical, ``None`` for
346+
Prediction (the runtime bands live on each event, not the configuration).
347+
For Freshness in Static mode only the upper bound applies.
348+
349+
``metric_name`` is the user-defined name for a Metric monitor (stored on
350+
the underlying definition's ``column_name`` column, but it is the metric's
351+
name rather than a column reference). ``custom_query`` is the metric's SQL
352+
expression. Both are only set for ``Metric_Trend``.
353+
"""
354+
monitor_id: UUID
355+
test_type: str
356+
table_name: str
357+
metric_name: str | None
358+
threshold_mode: str
359+
threshold_lower: str | None
360+
threshold_upper: str | None
361+
custom_query: str | None
362+
363+
273364
class QueryString(TypeDecorator):
274365
impl = String
275366
cache_ok = True
@@ -557,6 +648,95 @@ def list_for_suite(
557648
query = query.order_by(*cls._default_order_by)
558649
return cls._paginate(query, page=page, limit=limit, data_class=TestDefinitionSummary)
559650

651+
@classmethod
652+
def get_singleton_monitor(
653+
cls,
654+
test_suite_id: str | UUID,
655+
table_name: str,
656+
test_type: str,
657+
) -> "TestDefinition | None":
658+
"""Return the single ``TestDefinition`` row for a singleton monitor
659+
type (Freshness / Volume / Schema) on a given table. Metric is
660+
multi-instance and should be looked up by ``id`` instead — this
661+
helper would silently pick the first match. Returns ``None`` when no
662+
monitor is configured."""
663+
session = get_current_session()
664+
return session.execute(
665+
select(cls)
666+
.where(cls.test_suite_id == test_suite_id)
667+
.where(cls.table_name == table_name)
668+
.where(cls.test_type == test_type)
669+
.limit(1)
670+
).scalars().first()
671+
672+
@classmethod
673+
def list_monitor_configs_for_table(
674+
cls,
675+
test_suite_id: str | UUID,
676+
table_name: str,
677+
) -> list[MonitorConfig]:
678+
"""List configured monitors for a single table within a monitor suite.
679+
680+
Returns one entry per ``test_definition`` row whose ``test_type`` is one
681+
of the four monitor types. Typically 3-4 rows per table; Metric_Trend
682+
contributes one entry per metric, so a table may have more.
683+
"""
684+
monitor_codes = [m.value for m in MonitorType]
685+
defs = cls.select_where(
686+
cls.test_suite_id == test_suite_id,
687+
cls.table_name == table_name,
688+
cls.test_type.in_(monitor_codes),
689+
)
690+
return [cls._build_monitor_config(td) for td in defs]
691+
692+
@classmethod
693+
def _build_monitor_config(cls, td: "TestDefinition") -> MonitorConfig:
694+
mode, threshold_lower, threshold_upper = cls._derive_threshold_mode(td)
695+
return MonitorConfig(
696+
monitor_id=td.id,
697+
test_type=td.test_type,
698+
table_name=td.table_name,
699+
metric_name=td.column_name or None if td.test_type == MonitorType.METRIC.value else None,
700+
threshold_mode=mode,
701+
threshold_lower=threshold_lower,
702+
threshold_upper=threshold_upper,
703+
custom_query=td.custom_query if td.test_type == MonitorType.METRIC.value else None,
704+
)
705+
706+
@classmethod
707+
def _derive_threshold_mode(
708+
cls, td: "TestDefinition",
709+
) -> tuple[str, str | None, str | None]:
710+
"""Pick a mode and the bounds tuple that applies under that mode.
711+
712+
Detection mirrors the UI form (``test_definition_form.js``): a
713+
``history_calculation`` of exactly ``"PREDICT"`` flags Prediction
714+
mode; any other non-empty value flags Historical (not available for
715+
Freshness); empty falls through to Static — the default for
716+
Freshness / Volume / Metric. Schema never has thresholds.
717+
718+
Bounds returned per mode:
719+
720+
* Prediction: ``None, None``. Runtime bounds live in the per-run
721+
prediction JSONB; they are not configuration and do not surface
722+
here.
723+
* Historical: ``(history_calculation, history_calculation_upper)`` —
724+
stored as expressions like ``"Minimum"`` / ``"Maximum"`` that the
725+
execution layer evaluates against the lookback window.
726+
* Static for Freshness: ``(None, upper_tolerance)``. Only an upper
727+
bound applies.
728+
* Static (Volume / Metric): ``(lower_tolerance, upper_tolerance)``.
729+
"""
730+
if td.test_type == MonitorType.SCHEMA.value:
731+
return THRESHOLD_MODE_NONE, None, None
732+
if td.history_calculation == "PREDICT":
733+
return THRESHOLD_MODE_PREDICTION, None, None
734+
if td.history_calculation and td.test_type != MonitorType.FRESHNESS.value:
735+
return THRESHOLD_MODE_HISTORICAL, td.history_calculation, td.history_calculation_upper
736+
if td.test_type == MonitorType.FRESHNESS.value:
737+
return THRESHOLD_MODE_STATIC, None, td.upper_tolerance
738+
return THRESHOLD_MODE_STATIC, td.lower_tolerance, td.upper_tolerance
739+
560740
@classmethod
561741
def select_page(
562742
cls,

0 commit comments

Comments
 (0)