Files
FabledSteward/steward/models/metrics.py
T
bvandeusenandClaude Opus 5.5 cbbf186f17
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m16s
CI / unit (push) Successful in 1m31s
CI / publish (push) Successful in 1m3s
feat(metrics): each rolled-up hour keeps the level it held, its p95 (#5738)
plugin_metrics_hourly gains value_p95 beside value_avg and value_max. A
two-minute backup spike raises the hour's peak and leaves its p95 low; an
hour spent at that level raises both. Learned normals (milestone 513) need
that difference to call a recurring spike usual without calling the same
level held at another hour usual too. Hours rolled up before this keep a
NULL p95 and read as their average.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:45:42 -04:00

85 lines
4.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import DateTime, Float, Index, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
from steward.core.ids import time_ordered_id
class PluginMetric(Base):
__tablename__ = "plugin_metrics"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=time_ordered_id)
source_module: Mapped[str] = mapped_column(String(64), nullable=False)
resource_name: Mapped[str] = mapped_column(String(255), nullable=False)
metric_name: Mapped[str] = mapped_column(String(128), nullable=False)
value: Mapped[float] = mapped_column(Float, nullable=False)
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
)
# This time-series table grows by (sources × resources × sample cadence); every
# host-detail / full-metrics / dashboard-widget read filters by
# (source_module, resource_name) over a recorded_at range. Without these it's a
# full sequential scan on every load.
__table_args__ = (
Index("ix_plugin_metrics_module_resource_recorded",
"source_module", "resource_name", "recorded_at"),
# One series over a time range, read from the index alone: `value` is
# carried in it. Built after boot by core/metric_index.py, not by a
# migration, since a build over the live table takes minutes (#5490).
Index("ix_plugin_metrics_series_value",
"source_module", "resource_name", "metric_name", "recorded_at",
postgresql_include=["value"]),
)
class PluginMetricLatest(Base):
"""The newest sample of each (source, resource, metric), kept beside
plugin_metrics by record_metrics so "what is it now" never scans history.
Postgres has no skip scan, so a newest-per-key query over plugin_metrics
reads every raw sample in the window: on a seven-day window that took a
dashboard widget a minute (#5208). This table is one row per key. Retention
drops a row once its sample has aged out of the raw window, so a resource
that stopped reporting disappears when its raw history does.
"""
__tablename__ = "plugin_metric_latest"
source_module: Mapped[str] = mapped_column(String(64), primary_key=True)
resource_name: Mapped[str] = mapped_column(String(255), primary_key=True)
metric_name: Mapped[str] = mapped_column(String(128), primary_key=True)
value: Mapped[float] = mapped_column(Float, nullable=False)
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
class PluginMetricHourly(Base):
"""Hourly rollup of plugin_metrics — the coarse series that retention rolls
raw samples into before pruning them, so multi-day/week history stays cheap.
One row per (source_module, resource_name, metric_name, hour bucket); the
unique constraint is the conflict target for the idempotent rollup upsert.
Charts read this for the part of a range older than the raw-retention window.
"""
__tablename__ = "plugin_metrics_hourly"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=time_ordered_id)
source_module: Mapped[str] = mapped_column(String(64), nullable=False)
resource_name: Mapped[str] = mapped_column(String(255), nullable=False)
metric_name: Mapped[str] = mapped_column(String(128), nullable=False)
bucket: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
value_avg: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
value_max: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
# The level the hour held for all but its briefest 5% (#5738): a spike of
# a minute or two raises value_max and leaves this low. NULL on hours
# rolled up before it was kept; read those as value_avg.
value_p95: Mapped[float | None] = mapped_column(Float, nullable=True)
sample_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
__table_args__ = (
UniqueConstraint("source_module", "resource_name", "metric_name", "bucket",
name="uq_plugin_metrics_hourly_bucket"),
Index("ix_plugin_metrics_hourly_lookup",
"source_module", "resource_name", "metric_name", "bucket"),
)