Skip to content

Commit e130f3c

Browse files
author
ci bot
committed
Merge branch 'feat/TG-1092-mcp-monitors-l2' into 'enterprise'
feat(mcp): add monitor L2 read tools — per-table events, configs, and schema-change audit (TG-1092) See merge request dkinternal/testgen/dataops-testgen!563
2 parents e473ec1 + 53310c5 commit e130f3c

12 files changed

Lines changed: 2242 additions & 157 deletions
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
from dataclasses import dataclass
2+
from datetime import 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+
*clauses,
58+
page: int = 1,
59+
limit: int | None = 20,
60+
) -> tuple[list[DataStructureLogEntry], int]:
61+
"""Paginated schema-change audit log for one table group, newest first.
62+
63+
Caller-supplied ``*clauses`` are WHERE expressions on this model — e.g.
64+
``cls.table_name == name`` (exact match; the audit log stores names
65+
case-sensitively as the source emitted them) or a ``cls.change_date``
66+
bound. ``limit=None`` skips pagination — the caller gets every matching
67+
row in one shot.
68+
"""
69+
query = select(
70+
cls.log_id,
71+
cls.table_groups_id,
72+
cls.table_name,
73+
cls.column_name,
74+
cls.change_date,
75+
cls.change,
76+
cls.old_data_type,
77+
cls.new_data_type,
78+
).where(cls.table_groups_id == table_group_id, *clauses)
79+
query = query.order_by(*cls._default_order_by)
80+
81+
if limit is None:
82+
rows = get_current_session().execute(query).mappings().all()
83+
entries = [DataStructureLogEntry(**row) for row in rows]
84+
return entries, len(entries)
85+
return cls._paginate(query, page=page, limit=limit, data_class=DataStructureLogEntry)

testgen/common/models/test_definition.py

Lines changed: 181 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,97 @@ class TestDefinitionMinimal(EntityMinimal):
270271
test_name_short: str
271272

272273

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

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

0 commit comments

Comments
 (0)