Files
WhisperLive/tests/test_metrics.py
T
Aaron Boxer ced4bdb737 feat: add Prometheus metrics instrumentation
- New whisper_live/metrics.py with Counter, Gauge, Histogram metrics
- Track connections (opened/closed/rejected), transcription latency,
  audio processed, segments emitted, REST requests, and errors
- All metric helpers are no-ops when prometheus_client not installed
- --metrics_port CLI flag to expose /metrics endpoint (0 = disabled)
- Metrics integrated into server.py, base.py at key instrumentation points
- 17 new tests in tests/test_metrics.py (178 total passing)
2026-05-13 10:45:40 -04:00

138 lines
5.7 KiB
Python

import unittest
from unittest.mock import patch, MagicMock
from whisper_live import metrics as wl_metrics
_skip_no_prometheus = unittest.skipUnless(
wl_metrics.is_available(), "prometheus_client not installed"
)
class TestMetricsAvailability(unittest.TestCase):
def test_is_available_returns_bool(self):
self.assertIsInstance(wl_metrics.is_available(), bool)
@_skip_no_prometheus
class TestTrackConnectionOpened(unittest.TestCase):
def test_increments_total_and_active(self):
total_before = wl_metrics.CONNECTIONS_TOTAL._value.get()
active_before = wl_metrics.CONNECTIONS_ACTIVE._value.get()
wl_metrics.track_connection_opened()
self.assertEqual(wl_metrics.CONNECTIONS_TOTAL._value.get(), total_before + 1)
self.assertEqual(wl_metrics.CONNECTIONS_ACTIVE._value.get(), active_before + 1)
@_skip_no_prometheus
class TestTrackConnectionClosed(unittest.TestCase):
def test_decrements_active(self):
wl_metrics.track_connection_opened()
active_before = wl_metrics.CONNECTIONS_ACTIVE._value.get()
wl_metrics.track_connection_closed()
self.assertEqual(wl_metrics.CONNECTIONS_ACTIVE._value.get(), active_before - 1)
@_skip_no_prometheus
class TestTrackConnectionRejected(unittest.TestCase):
def test_rejected_full(self):
before = wl_metrics.CONNECTIONS_REJECTED.labels(reason="full")._value.get()
wl_metrics.track_connection_rejected(reason="full")
self.assertEqual(wl_metrics.CONNECTIONS_REJECTED.labels(reason="full")._value.get(), before + 1)
def test_rejected_auth(self):
before = wl_metrics.CONNECTIONS_REJECTED.labels(reason="auth")._value.get()
wl_metrics.track_connection_rejected(reason="auth")
self.assertEqual(wl_metrics.CONNECTIONS_REJECTED.labels(reason="auth")._value.get(), before + 1)
@_skip_no_prometheus
class TestTrackTranscriptionLatency(unittest.TestCase):
def test_observe_records_value(self):
count_before = wl_metrics.TRANSCRIPTION_LATENCY._sum.get()
wl_metrics.track_transcription_latency(0.5)
self.assertAlmostEqual(wl_metrics.TRANSCRIPTION_LATENCY._sum.get(), count_before + 0.5, places=3)
@_skip_no_prometheus
class TestTrackAudioProcessed(unittest.TestCase):
def test_increments_by_duration(self):
before = wl_metrics.AUDIO_PROCESSED._value.get()
wl_metrics.track_audio_processed(3.5)
self.assertAlmostEqual(wl_metrics.AUDIO_PROCESSED._value.get(), before + 3.5, places=3)
@_skip_no_prometheus
class TestTrackSegmentEmitted(unittest.TestCase):
def test_completed_true(self):
before = wl_metrics.SEGMENTS_EMITTED.labels(completed="true")._value.get()
wl_metrics.track_segment_emitted(completed=True)
self.assertEqual(wl_metrics.SEGMENTS_EMITTED.labels(completed="true")._value.get(), before + 1)
def test_completed_false(self):
before = wl_metrics.SEGMENTS_EMITTED.labels(completed="false")._value.get()
wl_metrics.track_segment_emitted(completed=False)
self.assertEqual(wl_metrics.SEGMENTS_EMITTED.labels(completed="false")._value.get(), before + 1)
@_skip_no_prometheus
class TestTrackRestRequest(unittest.TestCase):
def test_tracks_200(self):
before = wl_metrics.REST_REQUESTS.labels(endpoint="transcriptions", status="200")._value.get()
wl_metrics.track_rest_request(endpoint="transcriptions", status=200)
self.assertEqual(wl_metrics.REST_REQUESTS.labels(endpoint="transcriptions", status="200")._value.get(), before + 1)
def test_tracks_500(self):
before = wl_metrics.REST_REQUESTS.labels(endpoint="transcriptions", status="500")._value.get()
wl_metrics.track_rest_request(endpoint="transcriptions", status=500)
self.assertEqual(wl_metrics.REST_REQUESTS.labels(endpoint="transcriptions", status="500")._value.get(), before + 1)
@_skip_no_prometheus
class TestTrackError(unittest.TestCase):
def test_tracks_transcription_error(self):
before = wl_metrics.ERRORS.labels(type="transcription")._value.get()
wl_metrics.track_error("transcription")
self.assertEqual(wl_metrics.ERRORS.labels(type="transcription")._value.get(), before + 1)
def test_tracks_rest_error(self):
before = wl_metrics.ERRORS.labels(type="rest_transcription")._value.get()
wl_metrics.track_error("rest_transcription")
self.assertEqual(wl_metrics.ERRORS.labels(type="rest_transcription")._value.get(), before + 1)
class TestStartMetricsServer(unittest.TestCase):
@patch("whisper_live.metrics.start_http_server")
def test_starts_on_given_port(self, mock_start):
wl_metrics.start_metrics_server(9999)
mock_start.assert_called_once_with(9999)
@patch("whisper_live.metrics.start_http_server", side_effect=OSError("port in use"))
def test_logs_error_on_failure(self, mock_start):
with self.assertLogs(level="ERROR") as cm:
wl_metrics.start_metrics_server(9999)
self.assertTrue(any("Failed to start" in msg for msg in cm.output))
class TestNoOpWhenUnavailable(unittest.TestCase):
"""Verify helper functions are no-ops when _AVAILABLE is False."""
def test_all_helpers_are_noop(self):
original = wl_metrics._AVAILABLE
try:
wl_metrics._AVAILABLE = False
# None of these should raise
wl_metrics.track_connection_opened()
wl_metrics.track_connection_closed()
wl_metrics.track_connection_rejected("full")
wl_metrics.track_transcription_latency(1.0)
wl_metrics.track_audio_processed(1.0)
wl_metrics.track_segment_emitted()
wl_metrics.track_rest_request()
wl_metrics.track_error()
finally:
wl_metrics._AVAILABLE = original
if __name__ == "__main__":
unittest.main()