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

This commit is contained in:
2026-03-25 08:54:38 -04:00
parent 295a8c90a9
commit 9be932c0c7
22 changed files with 1136 additions and 414 deletions
+7 -2
View File
@@ -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."""
-2
View File
@@ -14,8 +14,6 @@
"""Tests for bot command handlers."""
from __future__ import annotations
import json
import logging
from datetime import UTC, datetime, timedelta
-2
View File
@@ -14,8 +14,6 @@
"""Tests for database repository classes."""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
-172
View File
@@ -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")
+314
View File
@@ -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
-2
View File
@@ -14,8 +14,6 @@
"""Tests for data models."""
from __future__ import annotations
import pytest
from owncastsentry.models import StreamConfig, StreamState, StreamStatus
+112 -3
View File
@@ -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
+77 -4
View File
@@ -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
+205 -4
View File
@@ -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
-2
View File
@@ -14,8 +14,6 @@
"""Tests for utility functions and constants."""
from __future__ import annotations
import pytest
from owncastsentry.utils import (