Files
LorenEteval_Furious/Furious/Service/MetricsDataManager.py
T
2026-08-14 00:18:19 +08:00

338 lines
10 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.
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
#
# This file is part of Furious.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Store and aggregate timestamped application metrics independently of UI."""
from __future__ import annotations
from Furious.Service.TrafficStatsManager import TrafficStatsSample
from PySide6 import QtCore
from collections import deque
from collections.abc import Mapping
from dataclasses import dataclass
from types import MappingProxyType
import math
import time
__all__ = [
'DOWNLOAD_SPEED_METRIC',
'DOWNLOAD_USAGE_METRIC',
'MetricPoint',
'MetricSample',
'MetricsDataManager',
'UPLOAD_SPEED_METRIC',
'UPLOAD_USAGE_METRIC',
]
DOWNLOAD_SPEED_METRIC = 'network.download.speed'
DOWNLOAD_USAGE_METRIC = 'network.download.usage'
UPLOAD_SPEED_METRIC = 'network.upload.speed'
UPLOAD_USAGE_METRIC = 'network.upload.usage'
MEAN_AGGREGATION = 'mean'
LAST_AGGREGATION = 'last'
SUPPORTED_AGGREGATIONS = (MEAN_AGGREGATION, LAST_AGGREGATION)
@dataclass(frozen=True)
class MetricSample:
"""Store one timestamp and a generic set of metric values."""
sampledAt: float
values: Mapping[str, float]
@dataclass(frozen=True)
class MetricPoint:
"""Represent one graph-ready raw or aggregated metric value."""
sampledAt: float
value: float
firstSampledAt: float | None = None
lastSampledAt: float | None = None
sampleCount: int = 1
@property
def isAggregated(self) -> bool:
"""Return whether this point summarizes multiple raw samples."""
return self.sampleCount > 1
class MetricsDataManager(QtCore.QObject):
"""Maintain bounded metric history and aggregate it for consumers."""
historyChanged = QtCore.Signal()
MaximumHistorySeconds = 24 * 60 * 60
AutoBucketTarget = 120
AutoGranularities = (
1,
2,
5,
10,
15,
30,
60,
2 * 60,
5 * 60,
10 * 60,
15 * 60,
30 * 60,
60 * 60,
)
def __init__(self, parent=None, *, maximumHistorySeconds=None):
"""Initialize generic metric definitions and bounded sample storage."""
super().__init__(parent)
self._maximumHistorySeconds = max(
float(maximumHistorySeconds or self.MaximumHistorySeconds),
1.0,
)
self._samples = deque()
self._aggregations = {}
self.registerMetric(DOWNLOAD_SPEED_METRIC, MEAN_AGGREGATION)
self.registerMetric(DOWNLOAD_USAGE_METRIC, LAST_AGGREGATION)
self.registerMetric(UPLOAD_SPEED_METRIC, MEAN_AGGREGATION)
self.registerMetric(UPLOAD_USAGE_METRIC, LAST_AGGREGATION)
def registerMetric(self, metricKey: str, aggregation=MEAN_AGGREGATION):
"""Register a metric and its bucket aggregation strategy."""
if not isinstance(metricKey, str) or not metricKey:
raise ValueError('metricKey must be a non-empty string')
if aggregation not in SUPPORTED_AGGREGATIONS:
raise ValueError(f'unsupported metric aggregation: {aggregation}')
self._aggregations[metricKey] = aggregation
def metricKeys(self) -> tuple[str, ...]:
"""Return all metrics currently understood by the manager."""
return tuple(self._aggregations)
def sampleCount(self) -> int:
"""Return the number of raw samples retained in memory."""
return len(self._samples)
def rawSamples(self) -> tuple[MetricSample, ...]:
"""Return an immutable snapshot of the original recorded samples."""
return tuple(self._samples)
def clearHistory(self):
"""Discard all retained metrics and notify interested consumers."""
if not self._samples:
return
self._samples.clear()
self.historyChanged.emit()
def clearMetrics(self, metricKeys):
"""Remove selected metric values while preserving other history."""
keys = frozenset(metricKeys)
if not keys or not self._samples:
return
retainedSamples = deque()
changed = False
for sample in self._samples:
retainedValues = {
key: value for key, value in sample.values.items() if key not in keys
}
if len(retainedValues) != len(sample.values):
changed = True
if retainedValues:
retainedSamples.append(
MetricSample(
sample.sampledAt,
MappingProxyType(retainedValues),
)
)
if changed:
self._samples = retainedSamples
self.historyChanged.emit()
@QtCore.Slot()
def clearTrafficUsageHistory(self):
"""Clear usage graphs without discarding upload/download speed history."""
self.clearMetrics((DOWNLOAD_USAGE_METRIC, UPLOAD_USAGE_METRIC))
@QtCore.Slot(object)
def recordTrafficSample(self, sample):
"""Convert one session-normalized traffic sample into generic metrics.
Raw proxy-core counter lifetimes are reconciled upstream by
``TrafficStatsManager`` so this class remains provider-independent.
"""
if not isinstance(sample, TrafficStatsSample):
return
self.recordSample(
{
DOWNLOAD_SPEED_METRIC: sample.downloadSpeed,
DOWNLOAD_USAGE_METRIC: sample.downloadUsage,
UPLOAD_SPEED_METRIC: sample.uploadSpeed,
UPLOAD_USAGE_METRIC: sample.uploadUsage,
},
sample.sampledAt,
)
def recordSample(self, values: Mapping[str, float], sampledAt=None):
"""Record finite values for registered metrics at one timestamp."""
if not isinstance(values, Mapping):
raise TypeError('values must be a mapping')
timestamp = time.monotonic() if sampledAt is None else float(sampledAt)
if not math.isfinite(timestamp):
raise ValueError('sampledAt must be finite')
normalizedValues = {}
for metricKey, value in values.items():
if metricKey not in self._aggregations:
continue
try:
normalizedValue = float(value)
except (TypeError, ValueError, OverflowError):
continue
if math.isfinite(normalizedValue):
normalizedValues[metricKey] = max(normalizedValue, 0.0)
if not normalizedValues:
return
if self._samples and timestamp < self._samples[-1].sampledAt:
timestamp = self._samples[-1].sampledAt
self._samples.append(
MetricSample(
timestamp,
MappingProxyType(normalizedValues),
)
)
self._pruneHistory(timestamp)
self.historyChanged.emit()
def _pruneHistory(self, now: float):
"""Remove samples older than the configured in-memory history."""
oldestAllowed = now - self._maximumHistorySeconds
while self._samples and self._samples[0].sampledAt < oldestAllowed:
self._samples.popleft()
def effectiveGranularity(self, rangeSeconds, granularitySeconds=0) -> float:
"""Return an explicit or automatically selected bucket duration."""
rangeSeconds = max(float(rangeSeconds), 1.0)
granularitySeconds = float(granularitySeconds or 0)
if granularitySeconds > 0:
return min(granularitySeconds, rangeSeconds)
target = max(rangeSeconds / self.AutoBucketTarget, 1.0)
return min(
next(
(float(value) for value in self.AutoGranularities if value >= target),
rangeSeconds,
),
rangeSeconds,
)
def series(
self,
metricKey: str,
rangeSeconds: float,
granularitySeconds=0,
*,
now=None,
) -> tuple[MetricPoint, ...]:
"""Return graph-ready values aggregated into time buckets."""
aggregation = self._aggregations.get(metricKey)
if aggregation is None:
raise KeyError(f'unknown metric: {metricKey}')
if not self._samples:
return tuple()
rangeSeconds = max(float(rangeSeconds), 1.0)
currentTime = time.monotonic() if now is None else float(now)
granularity = self.effectiveGranularity(
rangeSeconds,
granularitySeconds,
)
startTime = currentTime - rangeSeconds
firstBucketIndex = math.floor(startTime / granularity)
firstBucketStart = firstBucketIndex * granularity
buckets = {}
for sample in self._samples:
if sample.sampledAt < firstBucketStart:
continue
if sample.sampledAt > currentTime:
continue
if metricKey not in sample.values:
continue
# Align every bucket to the process's absolute monotonic clock.
# Moving ``now`` therefore changes only the visible window; it
# never shifts boundaries and re-groups historical samples.
bucketIndex = math.floor(sample.sampledAt / granularity)
bucket = buckets.setdefault(bucketIndex, [])
bucket.append((sample.sampledAt, float(sample.values[metricKey])))
points = []
for _bucketIndex, samples in sorted(buckets.items()):
sampleTimes = tuple(sample[0] for sample in samples)
values = tuple(sample[1] for sample in samples)
if aggregation == LAST_AGGREGATION:
value = values[-1]
else:
value = sum(values) / len(values)
sampledAt = sampleTimes[-1]
if sampledAt < startTime:
continue
points.append(
MetricPoint(
sampledAt,
value,
sampleTimes[0],
sampleTimes[-1],
len(samples),
)
)
return tuple(points)