Replaced health checker with Prometheus metrics service.
Audit / Dependencies (push) Failing after 8s
CD / Build (push) Successful in 8s
CI / Formatting (push) Successful in 6s
CI / Linting (push) Successful in 6s
CI / Tests (push) Successful in 11s
CI / Type Checking (push) Successful in 14s
CI / Spelling (push) Successful in 13s
Audit / Dependencies (push) Failing after 8s
CD / Build (push) Successful in 8s
CI / Formatting (push) Successful in 6s
CI / Linting (push) Successful in 6s
CI / Tests (push) Successful in 11s
CI / Type Checking (push) Successful in 14s
CI / Spelling (push) Successful in 13s
This commit is contained in:
+7
-2
@@ -14,13 +14,12 @@
|
||||
|
||||
"""Shared test fixtures and stubs for OwncastSentry tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
from mautrix.util.async_db import Database
|
||||
from prometheus_client import generate_latest
|
||||
|
||||
from owncastsentry import OwncastSentry
|
||||
from owncastsentry.config import Config
|
||||
@@ -31,9 +30,15 @@ if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
|
||||
from owncastsentry.metrics import MetricsService
|
||||
from owncastsentry.models import StreamConfig, StreamState
|
||||
|
||||
|
||||
def generate_metrics_output(metrics: MetricsService) -> str:
|
||||
"""Generate Prometheus text format output from a MetricsService registry."""
|
||||
return generate_latest(metrics.registry).decode("utf-8")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def database(tmp_path: Path) -> AsyncIterator[Database]:
|
||||
"""Yield a real SQLite-backed mautrix Database with migrations applied."""
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
|
||||
"""Tests for bot command handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
|
||||
"""Tests for database repository classes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for the health checking service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from aioresponses import aioresponses
|
||||
|
||||
from owncastsentry.health_checker import HealthChecker, HealthStatus
|
||||
from owncastsentry.models import UpdateResult
|
||||
from owncastsentry.owncast_client import OwncastClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from mautrix.util.async_db import Database
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def health_checker(database: Database) -> AsyncIterator[HealthChecker]:
|
||||
"""Yield a HealthChecker backed by a real OwncastClient."""
|
||||
client = OwncastClient(logger=logging.getLogger("test"), version="0.0.0")
|
||||
yield HealthChecker(database, client, logging.getLogger("test"))
|
||||
await client.close()
|
||||
|
||||
|
||||
class TestUpdateResultHttpHealthy:
|
||||
"""HTTP health derivation from update results."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("total", "successful", "failed", "expected"),
|
||||
[
|
||||
pytest.param(0, 0, 0, True, id="no-streams-is-healthy"),
|
||||
pytest.param(3, 2, 1, True, id="some-successes-is-healthy"),
|
||||
pytest.param(3, 0, 3, False, id="all-failures-is-unhealthy"),
|
||||
],
|
||||
)
|
||||
def test_http_healthy(
|
||||
self, total: int, successful: int, failed: int, expected: bool
|
||||
) -> None:
|
||||
"""Derive HTTP health from stream check results."""
|
||||
result = UpdateResult(
|
||||
total_streams=total,
|
||||
successful_checks=successful,
|
||||
failed_checks=failed,
|
||||
)
|
||||
assert result.http_healthy is expected
|
||||
|
||||
|
||||
class TestHealthStatusIsHealthy:
|
||||
"""Overall health status derivation."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("db_healthy", "http_healthy", "expected"),
|
||||
[
|
||||
pytest.param(True, True, True, id="all-healthy"),
|
||||
pytest.param(False, True, False, id="db-unhealthy"),
|
||||
pytest.param(True, False, False, id="http-unhealthy"),
|
||||
pytest.param(False, False, False, id="both-unhealthy"),
|
||||
],
|
||||
)
|
||||
def test_is_healthy(
|
||||
self, db_healthy: bool, http_healthy: bool, expected: bool
|
||||
) -> None:
|
||||
"""Derive overall health from component health."""
|
||||
status = HealthStatus(database_healthy=db_healthy, http_healthy=http_healthy)
|
||||
assert status.is_healthy is expected
|
||||
|
||||
|
||||
class TestCheckDatabase:
|
||||
"""Database health check."""
|
||||
|
||||
async def test_returns_true_for_healthy_db(
|
||||
self, health_checker: HealthChecker
|
||||
) -> None:
|
||||
"""Return True when the database responds to queries."""
|
||||
assert await health_checker.check_database() is True
|
||||
|
||||
async def test_returns_false_for_stopped_db(
|
||||
self, health_checker: HealthChecker, database: Database
|
||||
) -> None:
|
||||
"""Return False when the database connection is closed."""
|
||||
await database.stop()
|
||||
assert await health_checker.check_database() is False
|
||||
|
||||
|
||||
class TestPerformHealthCheck:
|
||||
"""Health check orchestration and endpoint reporting."""
|
||||
|
||||
async def test_skips_report_when_no_endpoint(
|
||||
self, health_checker: HealthChecker
|
||||
) -> None:
|
||||
"""Skip reporting when endpoint is empty."""
|
||||
result = UpdateResult(total_streams=0, successful_checks=0, failed_checks=0)
|
||||
with aioresponses():
|
||||
await health_checker.perform_health_check(result, "")
|
||||
|
||||
async def test_skips_report_when_unhealthy(
|
||||
self, health_checker: HealthChecker
|
||||
) -> None:
|
||||
"""Skip health report when all stream checks failed."""
|
||||
result = UpdateResult(total_streams=3, successful_checks=0, failed_checks=3)
|
||||
with aioresponses():
|
||||
await health_checker.perform_health_check(
|
||||
result, "https://health.example.com/ping"
|
||||
)
|
||||
|
||||
async def test_sends_report_when_healthy(
|
||||
self, health_checker: HealthChecker
|
||||
) -> None:
|
||||
"""Send GET to endpoint when all checks pass."""
|
||||
result = UpdateResult(total_streams=1, successful_checks=1, failed_checks=0)
|
||||
with aioresponses() as mocked:
|
||||
mocked.get("https://health.example.com/ping", status=200)
|
||||
await health_checker.perform_health_check(
|
||||
result, "https://health.example.com/ping"
|
||||
)
|
||||
|
||||
async def test_skips_report_for_whitespace_endpoint(
|
||||
self, health_checker: HealthChecker
|
||||
) -> None:
|
||||
"""Skip reporting when endpoint is whitespace."""
|
||||
result = UpdateResult(total_streams=0, successful_checks=0, failed_checks=0)
|
||||
with aioresponses():
|
||||
await health_checker.perform_health_check(result, " ")
|
||||
|
||||
|
||||
class TestSendHealthReport:
|
||||
"""Health report HTTP delivery."""
|
||||
|
||||
async def test_handles_success_response(
|
||||
self, health_checker: HealthChecker
|
||||
) -> None:
|
||||
"""Complete without error on a 2xx response."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get("https://health.example.com/ping", status=200)
|
||||
await health_checker._send_health_report("https://health.example.com/ping")
|
||||
|
||||
async def test_handles_non_success_response(
|
||||
self, health_checker: HealthChecker
|
||||
) -> None:
|
||||
"""Complete without error on a non-2xx response."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get("https://health.example.com/ping", status=500)
|
||||
await health_checker._send_health_report("https://health.example.com/ping")
|
||||
|
||||
async def test_handles_connection_error(
|
||||
self, health_checker: HealthChecker
|
||||
) -> None:
|
||||
"""Complete without error on a connection failure."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://health.example.com/ping",
|
||||
exception=ConnectionError(),
|
||||
)
|
||||
await health_checker._send_health_report("https://health.example.com/ping")
|
||||
@@ -0,0 +1,314 @@
|
||||
# Copyright 2026 Logan Fick
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Tests for the Prometheus metrics service."""
|
||||
|
||||
import pytest
|
||||
|
||||
from owncastsentry.metrics import ErrorSource, MetricsService, NotificationType
|
||||
from owncastsentry.models import StreamStatus
|
||||
from tests.conftest import generate_metrics_output
|
||||
|
||||
|
||||
class TestRecordDelivery:
|
||||
"""Notification delivery counter with type and result labels."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("notification_type", "successful", "failed", "expected_fragments"),
|
||||
[
|
||||
pytest.param(
|
||||
NotificationType.LIVE,
|
||||
3,
|
||||
0,
|
||||
['result="success",type="live"} 3.0'],
|
||||
id="live-success",
|
||||
),
|
||||
pytest.param(
|
||||
NotificationType.LIVE,
|
||||
0,
|
||||
2,
|
||||
['result="failure",type="live"} 2.0'],
|
||||
id="live-failure",
|
||||
),
|
||||
pytest.param(
|
||||
NotificationType.TITLE_CHANGE,
|
||||
1,
|
||||
0,
|
||||
['result="success",type="title_change"} 1.0'],
|
||||
id="title-change-success",
|
||||
),
|
||||
pytest.param(
|
||||
NotificationType.CLEANUP_WARNING,
|
||||
2,
|
||||
0,
|
||||
['result="success",type="cleanup_warning"} 2.0'],
|
||||
id="cleanup-warning-success",
|
||||
),
|
||||
pytest.param(
|
||||
NotificationType.CLEANUP_DELETION,
|
||||
1,
|
||||
1,
|
||||
[
|
||||
'result="success",type="cleanup_deletion"} 1.0',
|
||||
'result="failure",type="cleanup_deletion"} 1.0',
|
||||
],
|
||||
id="cleanup-deletion-mixed",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_records_delivery(
|
||||
self,
|
||||
notification_type: NotificationType,
|
||||
successful: int,
|
||||
failed: int,
|
||||
expected_fragments: list[str],
|
||||
) -> None:
|
||||
"""Record delivery results with correct type and result labels."""
|
||||
service = MetricsService()
|
||||
service.record_delivery(notification_type, successful=successful, failed=failed)
|
||||
output = generate_metrics_output(service)
|
||||
for fragment in expected_fragments:
|
||||
assert fragment in output
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("notification_type", "result"),
|
||||
[
|
||||
pytest.param(t, r, id=f"{t}-{r}")
|
||||
for t in NotificationType
|
||||
for r in ("success", "failure")
|
||||
],
|
||||
)
|
||||
def test_all_combinations_initialized(
|
||||
self, notification_type: NotificationType, result: str
|
||||
) -> None:
|
||||
"""All type/result label combinations exist at zero on init."""
|
||||
service = MetricsService()
|
||||
output = generate_metrics_output(service)
|
||||
expected = (
|
||||
f"owncastsentry_notification_delivery_total"
|
||||
f'{{result="{result}",type="{notification_type}"}} 0.0'
|
||||
)
|
||||
assert expected in output
|
||||
|
||||
|
||||
class TestSetStreamStatus:
|
||||
"""Per-stream status gauge."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("status", "expected_value"),
|
||||
[
|
||||
pytest.param(StreamStatus.ONLINE, 1.0, id="online"),
|
||||
pytest.param(StreamStatus.OFFLINE, 0.0, id="offline"),
|
||||
pytest.param(StreamStatus.UNKNOWN, -1.0, id="unknown"),
|
||||
],
|
||||
)
|
||||
def test_sets_status(self, status: StreamStatus, expected_value: float) -> None:
|
||||
"""Set gauge to the correct value for each stream status."""
|
||||
service = MetricsService()
|
||||
service.set_stream_status("test.com", status)
|
||||
output = generate_metrics_output(service)
|
||||
expected = f'owncastsentry_stream_status{{domain="test.com"}} {expected_value}'
|
||||
assert expected in output
|
||||
|
||||
|
||||
class TestSetSubscriptionCount:
|
||||
"""Per-stream subscription count gauge."""
|
||||
|
||||
def test_sets_count(self) -> None:
|
||||
"""Set the subscription count for a domain."""
|
||||
service = MetricsService()
|
||||
service.set_subscription_count("test.com", 5)
|
||||
output = generate_metrics_output(service)
|
||||
assert 'owncastsentry_stream_subscriptions{domain="test.com"} 5.0' in output
|
||||
|
||||
def test_updates_count(self) -> None:
|
||||
"""Update the subscription count for a domain."""
|
||||
service = MetricsService()
|
||||
service.set_subscription_count("test.com", 5)
|
||||
service.set_subscription_count("test.com", 3)
|
||||
output = generate_metrics_output(service)
|
||||
assert 'owncastsentry_stream_subscriptions{domain="test.com"} 3.0' in output
|
||||
|
||||
|
||||
class TestSetCheckFailures:
|
||||
"""Check failure counter gauge per domain."""
|
||||
|
||||
def test_sets_count(self) -> None:
|
||||
"""Set the failure count for a domain."""
|
||||
service = MetricsService()
|
||||
service.set_check_failures("fail.com", 3)
|
||||
output = generate_metrics_output(service)
|
||||
assert 'owncastsentry_check_failures{domain="fail.com"} 3.0' in output
|
||||
|
||||
def test_resets_to_zero(self) -> None:
|
||||
"""Reset the failure count to zero."""
|
||||
service = MetricsService()
|
||||
service.set_check_failures("fail.com", 5)
|
||||
service.set_check_failures("fail.com", 0)
|
||||
output = generate_metrics_output(service)
|
||||
assert 'owncastsentry_check_failures{domain="fail.com"} 0.0' in output
|
||||
|
||||
|
||||
class TestResponseTimer:
|
||||
"""Response time gauge via context manager."""
|
||||
|
||||
def test_records_on_success(self) -> None:
|
||||
"""Record a response time when success() is called."""
|
||||
service = MetricsService()
|
||||
with service.response_timer("example.com") as timer:
|
||||
timer.success()
|
||||
output = generate_metrics_output(service)
|
||||
assert 'owncastsentry_api_response_seconds{domain="example.com"}' in output
|
||||
|
||||
def test_does_not_record_without_success(self) -> None:
|
||||
"""Do not record when success() is never called."""
|
||||
service = MetricsService()
|
||||
with service.response_timer("example.com"):
|
||||
pass
|
||||
output = generate_metrics_output(service)
|
||||
assert 'owncastsentry_api_response_seconds{domain="example.com"}' not in output
|
||||
|
||||
def test_overwrites_previous_value(self) -> None:
|
||||
"""Overwrite previous value with the latest response time."""
|
||||
service = MetricsService()
|
||||
with service.response_timer("example.com") as timer:
|
||||
timer.success()
|
||||
with service.response_timer("example.com") as timer:
|
||||
timer.success()
|
||||
output = generate_metrics_output(service)
|
||||
# Gauge should have exactly one line for this domain, not accumulated
|
||||
matches = [
|
||||
line
|
||||
for line in output.splitlines()
|
||||
if line.startswith("owncastsentry_api_response_seconds{")
|
||||
]
|
||||
assert len(matches) == 1
|
||||
|
||||
def test_does_not_record_on_exception(self) -> None:
|
||||
"""Do not record when the block raises an exception."""
|
||||
service = MetricsService()
|
||||
with (
|
||||
pytest.raises(ValueError, match="boom"),
|
||||
service.response_timer("example.com"),
|
||||
):
|
||||
raise ValueError("boom")
|
||||
output = generate_metrics_output(service)
|
||||
assert 'owncastsentry_api_response_seconds{domain="example.com"}' not in output
|
||||
|
||||
|
||||
class TestRemoveStream:
|
||||
"""Stale stream label cleanup."""
|
||||
|
||||
def test_removes_stream_label(self) -> None:
|
||||
"""Remove a stream's gauge labels after cleanup deletion."""
|
||||
service = MetricsService()
|
||||
service.set_stream_status("gone.com", StreamStatus.OFFLINE)
|
||||
service.set_subscription_count("gone.com", 2)
|
||||
assert 'domain="gone.com"' in generate_metrics_output(service)
|
||||
service.remove_stream("gone.com")
|
||||
assert 'domain="gone.com"' not in generate_metrics_output(service)
|
||||
|
||||
def test_remove_nonexistent_is_noop(self) -> None:
|
||||
"""Removing a nonexistent stream does not raise."""
|
||||
service = MetricsService()
|
||||
service.remove_stream("never.com")
|
||||
|
||||
|
||||
class TestRegisterOpenConnectionsGauge:
|
||||
"""Callback-based open connection gauge."""
|
||||
|
||||
def test_reads_value_from_callback(self) -> None:
|
||||
"""Read the open connection count from the callback at scrape time."""
|
||||
service = MetricsService()
|
||||
counter = [3]
|
||||
service.register_open_connections_gauge(lambda: counter[0])
|
||||
output = generate_metrics_output(service)
|
||||
assert "owncastsentry_http_connections_open 3.0" in output
|
||||
|
||||
def test_reflects_updated_value(self) -> None:
|
||||
"""Reflect changes in the callback value on subsequent scrapes."""
|
||||
service = MetricsService()
|
||||
counter = [1]
|
||||
service.register_open_connections_gauge(lambda: counter[0])
|
||||
counter[0] = 5
|
||||
output = generate_metrics_output(service)
|
||||
assert "owncastsentry_http_connections_open 5.0" in output
|
||||
|
||||
|
||||
class TestSetBuildInfo:
|
||||
"""Build version info metric."""
|
||||
|
||||
def test_sets_version(self) -> None:
|
||||
"""Set the build version info."""
|
||||
service = MetricsService()
|
||||
service.set_build_info("1.2.3")
|
||||
output = generate_metrics_output(service)
|
||||
assert 'owncastsentry_info{version="1.2.3"} 1.0' in output
|
||||
|
||||
|
||||
class TestRecordError:
|
||||
"""Internal error counter."""
|
||||
|
||||
def test_increments_counter(self) -> None:
|
||||
"""Increment the error counter for a source."""
|
||||
service = MetricsService()
|
||||
service.record_error(ErrorSource.SCHEDULER_LOOP)
|
||||
output = generate_metrics_output(service)
|
||||
assert 'owncastsentry_errors_total{source="scheduler_loop"} 1.0' in output
|
||||
|
||||
def test_increments_multiple_sources(self) -> None:
|
||||
"""Increment error counters for different sources independently."""
|
||||
service = MetricsService()
|
||||
service.record_error(ErrorSource.SCHEDULER_LOOP)
|
||||
service.record_error(ErrorSource.COMMAND)
|
||||
service.record_error(ErrorSource.COMMAND)
|
||||
output = generate_metrics_output(service)
|
||||
assert 'owncastsentry_errors_total{source="scheduler_loop"} 1.0' in output
|
||||
assert 'owncastsentry_errors_total{source="command"} 2.0' in output
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"source",
|
||||
[pytest.param(s, id=s) for s in ErrorSource],
|
||||
)
|
||||
def test_all_sources_initialized(self, source: ErrorSource) -> None:
|
||||
"""All known source labels exist at zero on init."""
|
||||
service = MetricsService()
|
||||
output = generate_metrics_output(service)
|
||||
expected = f'owncastsentry_errors_total{{source="{source}"}} 0.0'
|
||||
assert expected in output
|
||||
|
||||
|
||||
class TestRegistryOutput:
|
||||
"""Prometheus registry output."""
|
||||
|
||||
def test_returns_string(self) -> None:
|
||||
"""Return a string (not bytes)."""
|
||||
service = MetricsService()
|
||||
output = generate_metrics_output(service)
|
||||
assert isinstance(output, str)
|
||||
|
||||
def test_contains_help_lines(self) -> None:
|
||||
"""Include HELP lines for registered metrics."""
|
||||
service = MetricsService()
|
||||
output = generate_metrics_output(service)
|
||||
assert "# HELP owncastsentry_notification_delivery_total" in output
|
||||
assert "# HELP owncastsentry_errors_total" in output
|
||||
assert "# HELP owncastsentry_info" in output
|
||||
|
||||
def test_uses_isolated_registry(self) -> None:
|
||||
"""Use a custom registry, not the global default."""
|
||||
service = MetricsService()
|
||||
output = generate_metrics_output(service)
|
||||
assert "python_gc" not in output
|
||||
assert "process_" not in output
|
||||
@@ -14,8 +14,6 @@
|
||||
|
||||
"""Tests for data models."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from owncastsentry.models import StreamConfig, StreamState, StreamStatus
|
||||
|
||||
@@ -14,17 +14,16 @@
|
||||
|
||||
"""Tests for the notification service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from owncastsentry.metrics import MetricsService
|
||||
from owncastsentry.notification_service import NotificationService
|
||||
from owncastsentry.utils import SECONDS_BETWEEN_NOTIFICATIONS
|
||||
from tests.conftest import _StubMatrixClient
|
||||
from tests.conftest import _StubMatrixClient, generate_metrics_output
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from owncastsentry.database import StreamRepository, SubscriptionRepository
|
||||
@@ -34,12 +33,14 @@ def _make_service(
|
||||
*,
|
||||
client: _StubMatrixClient,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
metrics: MetricsService | None = None,
|
||||
) -> NotificationService:
|
||||
"""Build a NotificationService with a stub client and real repo."""
|
||||
return NotificationService(
|
||||
client=client,
|
||||
subscription_repo=subscription_repo,
|
||||
logger=logging.getLogger("test"),
|
||||
metrics=metrics or MetricsService(),
|
||||
)
|
||||
|
||||
|
||||
@@ -346,3 +347,111 @@ class TestSendCleanupDeletion:
|
||||
"If the instance comes online again and you want to "
|
||||
"resubscribe, run `!subscribe example.com`."
|
||||
)
|
||||
|
||||
|
||||
class TestNotificationMetrics:
|
||||
"""Notification metrics recording."""
|
||||
|
||||
async def test_records_live_notification(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Record a live notification metric."""
|
||||
client = _StubMatrixClient()
|
||||
metrics = MetricsService()
|
||||
service = _make_service(
|
||||
client=client, subscription_repo=subscription_repo, metrics=metrics
|
||||
)
|
||||
await stream_repo.create("example.com")
|
||||
await subscription_repo.add("example.com", "!room:matrix.org")
|
||||
await service.notify_stream_live("example.com", "Stream", "Title", [])
|
||||
output = generate_metrics_output(metrics)
|
||||
expected = (
|
||||
"owncastsentry_notification_delivery_total"
|
||||
'{result="success",type="live"} 1.0'
|
||||
)
|
||||
assert expected in output
|
||||
|
||||
async def test_records_title_change_notification(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Record a title_change notification metric."""
|
||||
client = _StubMatrixClient()
|
||||
metrics = MetricsService()
|
||||
service = _make_service(
|
||||
client=client, subscription_repo=subscription_repo, metrics=metrics
|
||||
)
|
||||
await stream_repo.create("example.com")
|
||||
await subscription_repo.add("example.com", "!room:matrix.org")
|
||||
await service.notify_stream_live(
|
||||
"example.com", "Stream", "Title", [], title_change=True
|
||||
)
|
||||
output = generate_metrics_output(metrics)
|
||||
expected = (
|
||||
"owncastsentry_notification_delivery_total"
|
||||
'{result="success",type="title_change"} 1.0'
|
||||
)
|
||||
assert expected in output
|
||||
|
||||
async def test_records_cleanup_warning_notification(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Record a cleanup_warning notification metric."""
|
||||
client = _StubMatrixClient()
|
||||
metrics = MetricsService()
|
||||
service = _make_service(
|
||||
client=client, subscription_repo=subscription_repo, metrics=metrics
|
||||
)
|
||||
await stream_repo.create("example.com")
|
||||
await subscription_repo.add("example.com", "!room:matrix.org")
|
||||
await service.send_cleanup_warning("example.com")
|
||||
output = generate_metrics_output(metrics)
|
||||
expected = (
|
||||
"owncastsentry_notification_delivery_total"
|
||||
'{result="success",type="cleanup_warning"} 1.0'
|
||||
)
|
||||
assert expected in output
|
||||
|
||||
async def test_records_cleanup_deletion_notification(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Record a cleanup_deletion notification metric."""
|
||||
client = _StubMatrixClient()
|
||||
metrics = MetricsService()
|
||||
service = _make_service(
|
||||
client=client, subscription_repo=subscription_repo, metrics=metrics
|
||||
)
|
||||
await stream_repo.create("example.com")
|
||||
await subscription_repo.add("example.com", "!room:matrix.org")
|
||||
await service.send_cleanup_deletion("example.com")
|
||||
output = generate_metrics_output(metrics)
|
||||
expected = (
|
||||
"owncastsentry_notification_delivery_total"
|
||||
'{result="success",type="cleanup_deletion"} 1.0'
|
||||
)
|
||||
assert expected in output
|
||||
|
||||
async def test_no_metric_when_rate_limited(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Do not record metric when notification is rate-limited."""
|
||||
client = _StubMatrixClient()
|
||||
metrics = MetricsService()
|
||||
service = _make_service(
|
||||
client=client, subscription_repo=subscription_repo, metrics=metrics
|
||||
)
|
||||
service.notification_timers_cache["example.com"] = time.monotonic()
|
||||
await stream_repo.create("example.com")
|
||||
await subscription_repo.add("example.com", "!room:matrix.org")
|
||||
await service.notify_stream_live("example.com", "Stream", "Title", [])
|
||||
output = generate_metrics_output(metrics)
|
||||
assert 'result="success",type="live"} 0.0' in output
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
|
||||
"""Tests for the Owncast HTTP client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -23,8 +21,13 @@ from typing import TYPE_CHECKING
|
||||
import pytest
|
||||
from aioresponses import aioresponses
|
||||
|
||||
from owncastsentry.metrics import MetricsService
|
||||
from owncastsentry.owncast_client import OwncastClient
|
||||
from tests.conftest import VALID_CONFIG_RESPONSE, VALID_STATUS_RESPONSE
|
||||
from tests.conftest import (
|
||||
VALID_CONFIG_RESPONSE,
|
||||
VALID_STATUS_RESPONSE,
|
||||
generate_metrics_output,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
@@ -33,7 +36,11 @@ if TYPE_CHECKING:
|
||||
@pytest.fixture
|
||||
async def owncast_client() -> AsyncIterator[OwncastClient]:
|
||||
"""Create an OwncastClient and close it after the test."""
|
||||
client = OwncastClient(logger=logging.getLogger("test"), version="0.0.0")
|
||||
client = OwncastClient(
|
||||
logger=logging.getLogger("test"),
|
||||
version="0.0.0",
|
||||
metrics=MetricsService(),
|
||||
)
|
||||
yield client
|
||||
await client.close()
|
||||
|
||||
@@ -205,3 +212,69 @@ class TestValidateInstance:
|
||||
result = await owncast_client.validate_instance("invalid.com")
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestResponseTimeMetrics:
|
||||
"""Response time histogram recording."""
|
||||
|
||||
async def test_records_on_success(self) -> None:
|
||||
"""Record response time on a successful request."""
|
||||
metrics = MetricsService()
|
||||
client = OwncastClient(
|
||||
logger=logging.getLogger("test"),
|
||||
version="0.0.0",
|
||||
metrics=metrics,
|
||||
)
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://example.com/api/status",
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
await client.get_stream_state("example.com")
|
||||
output = generate_metrics_output(metrics)
|
||||
assert 'owncastsentry_api_response_seconds{domain="example.com"}' in output
|
||||
await client.close()
|
||||
|
||||
async def test_no_observation_on_failure(self) -> None:
|
||||
"""Do not record response time when request fails."""
|
||||
metrics = MetricsService()
|
||||
client = OwncastClient(
|
||||
logger=logging.getLogger("test"),
|
||||
version="0.0.0",
|
||||
metrics=metrics,
|
||||
)
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://example.com/api/status",
|
||||
status=500,
|
||||
)
|
||||
await client.get_stream_state("example.com")
|
||||
output = generate_metrics_output(metrics)
|
||||
assert 'owncastsentry_api_response_seconds{domain="example.com"}' not in output
|
||||
await client.close()
|
||||
|
||||
async def test_no_observation_on_connection_error(self) -> None:
|
||||
"""Do not record response time on connection error."""
|
||||
metrics = MetricsService()
|
||||
client = OwncastClient(
|
||||
logger=logging.getLogger("test"),
|
||||
version="0.0.0",
|
||||
metrics=metrics,
|
||||
)
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://example.com/api/status",
|
||||
exception=ConnectionError(),
|
||||
)
|
||||
await client.get_stream_state("example.com")
|
||||
output = generate_metrics_output(metrics)
|
||||
assert 'owncastsentry_api_response_seconds{domain="example.com"}' not in output
|
||||
await client.close()
|
||||
|
||||
|
||||
class TestOpenConnectionCount:
|
||||
"""Open connection count."""
|
||||
|
||||
async def test_zero_with_no_requests(self, owncast_client: OwncastClient) -> None:
|
||||
"""Return zero when no requests have been made."""
|
||||
assert owncast_client.open_connection_count == 0
|
||||
|
||||
@@ -14,13 +14,12 @@
|
||||
|
||||
"""Tests for the stream monitor."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from owncastsentry.models import StreamConfig, StreamState
|
||||
from owncastsentry.metrics import MetricsService
|
||||
from owncastsentry.models import StreamConfig, StreamState, StreamStatus
|
||||
from owncastsentry.notification_service import NotificationService
|
||||
from owncastsentry.stream_monitor import StreamMonitor
|
||||
from owncastsentry.utils import (
|
||||
@@ -28,7 +27,11 @@ from owncastsentry.utils import (
|
||||
CLEANUP_WARNING_THRESHOLD,
|
||||
SECONDS_BETWEEN_NOTIFICATIONS,
|
||||
)
|
||||
from tests.conftest import _StubMatrixClient, _StubOwncastClient
|
||||
from tests.conftest import (
|
||||
_StubMatrixClient,
|
||||
_StubOwncastClient,
|
||||
generate_metrics_output,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from owncastsentry.database import StreamRepository, SubscriptionRepository
|
||||
@@ -40,13 +43,16 @@ def _make_monitor(
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
client: _StubMatrixClient,
|
||||
metrics: MetricsService | None = None,
|
||||
) -> tuple[StreamMonitor, NotificationService]:
|
||||
"""Build a StreamMonitor with stubs and a real NotificationService."""
|
||||
logger = logging.getLogger("test")
|
||||
metrics = metrics or MetricsService()
|
||||
notification_service = NotificationService(
|
||||
client=client,
|
||||
subscription_repo=subscription_repo,
|
||||
logger=logger,
|
||||
metrics=metrics,
|
||||
)
|
||||
monitor = StreamMonitor(
|
||||
owncast_client=owncast_client,
|
||||
@@ -54,6 +60,7 @@ def _make_monitor(
|
||||
subscription_repo=subscription_repo,
|
||||
notification_service=notification_service,
|
||||
logger=logger,
|
||||
metrics=metrics,
|
||||
)
|
||||
return monitor, notification_service
|
||||
|
||||
@@ -82,6 +89,25 @@ async def _seed_stream(
|
||||
await subscription_repo.add(domain, room_id)
|
||||
|
||||
|
||||
def _make_monitor_with_metrics(
|
||||
*,
|
||||
owncast_client: _StubOwncastClient,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
client: _StubMatrixClient,
|
||||
) -> tuple[StreamMonitor, NotificationService, MetricsService]:
|
||||
"""Build a StreamMonitor with stubs, a real NotificationService, and metrics."""
|
||||
metrics = MetricsService()
|
||||
monitor, notification_service = _make_monitor(
|
||||
owncast_client=owncast_client,
|
||||
stream_repo=stream_repo,
|
||||
subscription_repo=subscription_repo,
|
||||
client=client,
|
||||
metrics=metrics,
|
||||
)
|
||||
return monitor, notification_service, metrics
|
||||
|
||||
|
||||
class TestUpdateAllStreams:
|
||||
"""Parallel stream update orchestration."""
|
||||
|
||||
@@ -837,3 +863,178 @@ class TestUpdateAllStreamsMixed:
|
||||
assert result.total_streams == 2
|
||||
assert result.successful_checks == 1
|
||||
assert result.failed_checks == 1
|
||||
|
||||
|
||||
class TestStreamMonitorMetrics:
|
||||
"""Stream monitor metrics recording."""
|
||||
|
||||
async def test_records_stream_status_online(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Record online stream status gauge."""
|
||||
owncast = _StubOwncastClient(
|
||||
stream_state=StreamState(
|
||||
domain="live.com",
|
||||
title="Title",
|
||||
last_connect_time="2026-01-01T12:00:00Z",
|
||||
last_disconnect_time="2026-01-01T10:00:00Z",
|
||||
),
|
||||
stream_config=StreamConfig(name="Live Stream"),
|
||||
)
|
||||
client = _StubMatrixClient()
|
||||
monitor, _, metrics = _make_monitor_with_metrics(
|
||||
owncast_client=owncast,
|
||||
stream_repo=stream_repo,
|
||||
subscription_repo=subscription_repo,
|
||||
client=client,
|
||||
)
|
||||
await _seed_stream(
|
||||
stream_repo,
|
||||
subscription_repo,
|
||||
domain="live.com",
|
||||
last_disconnect_time="2026-01-01T10:00:00Z",
|
||||
)
|
||||
monitor.offline_timer_cache["live.com"] = 0
|
||||
await monitor.update_stream("live.com")
|
||||
output = generate_metrics_output(metrics)
|
||||
assert 'owncastsentry_stream_status{domain="live.com"} 1.0' in output
|
||||
|
||||
async def test_records_stream_status_offline(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Record offline stream status gauge."""
|
||||
owncast = _StubOwncastClient(
|
||||
stream_state=StreamState(
|
||||
domain="off.com",
|
||||
title="Title",
|
||||
last_disconnect_time="2026-01-01T12:00:00Z",
|
||||
),
|
||||
stream_config=StreamConfig(name="Off Stream"),
|
||||
)
|
||||
client = _StubMatrixClient()
|
||||
monitor, _, metrics = _make_monitor_with_metrics(
|
||||
owncast_client=owncast,
|
||||
stream_repo=stream_repo,
|
||||
subscription_repo=subscription_repo,
|
||||
client=client,
|
||||
)
|
||||
await _seed_stream(
|
||||
stream_repo,
|
||||
subscription_repo,
|
||||
domain="off.com",
|
||||
title="Title",
|
||||
last_disconnect_time="2026-01-01T12:00:00Z",
|
||||
)
|
||||
await monitor.update_stream("off.com")
|
||||
output = generate_metrics_output(metrics)
|
||||
assert 'owncastsentry_stream_status{domain="off.com"} 0.0' in output
|
||||
|
||||
async def test_records_check_failures_on_connection_failure(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Record failure counter gauge when a stream check fails."""
|
||||
owncast = _StubOwncastClient(stream_state=None)
|
||||
client = _StubMatrixClient()
|
||||
monitor, _, metrics = _make_monitor_with_metrics(
|
||||
owncast_client=owncast,
|
||||
stream_repo=stream_repo,
|
||||
subscription_repo=subscription_repo,
|
||||
client=client,
|
||||
)
|
||||
await _seed_stream(
|
||||
stream_repo,
|
||||
subscription_repo,
|
||||
domain="fail.com",
|
||||
last_disconnect_time="2026-01-01T00:00:00Z",
|
||||
)
|
||||
await monitor.update_stream("fail.com")
|
||||
output = generate_metrics_output(metrics)
|
||||
assert 'owncastsentry_check_failures{domain="fail.com"} 1.0' in output
|
||||
|
||||
async def test_resets_check_failures_on_success(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Reset failure counter gauge to zero on successful check."""
|
||||
owncast = _StubOwncastClient(
|
||||
stream_state=StreamState(
|
||||
domain="recover.com",
|
||||
title="Title",
|
||||
last_disconnect_time="2026-01-01T12:00:00Z",
|
||||
),
|
||||
stream_config=StreamConfig(name="Recover"),
|
||||
)
|
||||
client = _StubMatrixClient()
|
||||
monitor, _, metrics = _make_monitor_with_metrics(
|
||||
owncast_client=owncast,
|
||||
stream_repo=stream_repo,
|
||||
subscription_repo=subscription_repo,
|
||||
client=client,
|
||||
)
|
||||
await _seed_stream(
|
||||
stream_repo,
|
||||
subscription_repo,
|
||||
domain="recover.com",
|
||||
last_disconnect_time="2026-01-01T12:00:00Z",
|
||||
)
|
||||
# Simulate prior failures
|
||||
for _ in range(3):
|
||||
await stream_repo.increment_failure_counter("recover.com")
|
||||
await monitor.update_stream("recover.com")
|
||||
output = generate_metrics_output(metrics)
|
||||
assert 'owncastsentry_check_failures{domain="recover.com"} 0.0' in output
|
||||
|
||||
async def test_removes_stream_on_cleanup_deletion(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Remove stream gauge label on cleanup deletion."""
|
||||
owncast = _StubOwncastClient()
|
||||
client = _StubMatrixClient()
|
||||
monitor, _, metrics = _make_monitor_with_metrics(
|
||||
owncast_client=owncast,
|
||||
stream_repo=stream_repo,
|
||||
subscription_repo=subscription_repo,
|
||||
client=client,
|
||||
)
|
||||
await _seed_stream(stream_repo, subscription_repo, domain="delete.com")
|
||||
metrics.set_stream_status("delete.com", StreamStatus.OFFLINE)
|
||||
assert 'domain="delete.com"' in generate_metrics_output(metrics)
|
||||
|
||||
await monitor._check_cleanup_thresholds("delete.com", CLEANUP_DELETE_THRESHOLD)
|
||||
assert 'domain="delete.com"' not in generate_metrics_output(metrics)
|
||||
|
||||
async def test_records_subscription_counts(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Record per-domain subscription counts after update_all_streams."""
|
||||
owncast = _StubOwncastClient(
|
||||
stream_state=StreamState(
|
||||
domain="pop.com",
|
||||
last_connect_time="2026-01-01T00:00:00Z",
|
||||
),
|
||||
stream_config=StreamConfig(name="Popular"),
|
||||
)
|
||||
client = _StubMatrixClient()
|
||||
monitor, _, metrics = _make_monitor_with_metrics(
|
||||
owncast_client=owncast,
|
||||
stream_repo=stream_repo,
|
||||
subscription_repo=subscription_repo,
|
||||
client=client,
|
||||
)
|
||||
await stream_repo.create("pop.com")
|
||||
await subscription_repo.add("pop.com", "!room1:matrix.org")
|
||||
await subscription_repo.add("pop.com", "!room2:matrix.org")
|
||||
await monitor.update_all_streams(["pop.com"])
|
||||
output = generate_metrics_output(metrics)
|
||||
assert 'owncastsentry_stream_subscriptions{domain="pop.com"} 2.0' in output
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
|
||||
"""Tests for utility functions and constants."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from owncastsentry.utils import (
|
||||
|
||||
Reference in New Issue
Block a user