4c2e25af8a
Implements three observability improvements: #82 — publish_grafana_copy(): Uses SQLite online backup API (WAL-safe) to atomically publish a consistent read-only copy beside the target. Adds --publish-copy option to grafana-schema and snapshot CLI commands. #83 — examples/grafana/: Minimal working Grafana setup with docker-compose, provisioning datasource/dashboard YAML, and three dashboard JSON files (mt5cli-overview, mt5cli-trades, mt5cli-market). All queries use grafana_* views; no credentials or private paths included. #84 — mt5cli/telemetry.py: Optional OTel metrics behind mt5cli[otel] extra. Base install is unaffected. Adds _Mt5Metrics singleton (no-op until configure_metrics() is called), wraps update_history() and update_observability() with record_history_update / record_snapshot_update context managers, and emits account/position gauges from snapshots. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
205 lines
7.3 KiB
Python
205 lines
7.3 KiB
Python
"""Tests for mt5cli.telemetry module."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from unittest.mock import MagicMock
|
||
|
||
import pytest
|
||
|
||
from mt5cli.telemetry import (
|
||
_OTEL_AVAILABLE, # type: ignore[reportPrivateUsage]
|
||
_Mt5Metrics, # type: ignore[reportPrivateUsage]
|
||
_NoOp, # type: ignore[reportPrivateUsage]
|
||
configure_metrics,
|
||
enable_otel_metrics,
|
||
get_metrics,
|
||
)
|
||
|
||
|
||
class TestNoOp:
|
||
"""Tests for _NoOp no-op instrument."""
|
||
|
||
def test_add_is_noop(self) -> None:
|
||
"""_NoOp.add accepts amount and optional attributes without error."""
|
||
noop = _NoOp()
|
||
noop.add(1.0)
|
||
noop.add(1.0, {"key": "val"})
|
||
|
||
def test_set_is_noop(self) -> None:
|
||
"""_NoOp.set accepts amount and optional attributes without error."""
|
||
noop = _NoOp()
|
||
noop.set(2.0)
|
||
noop.set(2.0, {"key": "val"})
|
||
|
||
def test_record_is_noop(self) -> None:
|
||
"""_NoOp.record accepts amount and optional attributes without error."""
|
||
noop = _NoOp()
|
||
noop.record(3.0)
|
||
noop.record(3.0, {"key": "val"})
|
||
|
||
|
||
class TestMt5Metrics:
|
||
"""Tests for _Mt5Metrics."""
|
||
|
||
def test_default_instruments_are_noop(self) -> None:
|
||
"""Default _Mt5Metrics methods do not raise before configure is called."""
|
||
m = _Mt5Metrics()
|
||
m.record_account_state(
|
||
login="123",
|
||
server="demo",
|
||
balance=1000.0,
|
||
equity=1050.0,
|
||
margin=100.0,
|
||
margin_free=950.0,
|
||
margin_level=1050.0,
|
||
)
|
||
|
||
def test_configure_calls_meter(self) -> None:
|
||
"""configure() calls create_histogram, create_counter, create_gauge on meter."""
|
||
meter = MagicMock()
|
||
m = _Mt5Metrics()
|
||
m.configure(meter)
|
||
assert meter.create_histogram.called
|
||
assert meter.create_counter.called
|
||
assert meter.create_gauge.called
|
||
|
||
def test_record_history_update_success(self) -> None:
|
||
"""record_history_update records duration and timestamp on success."""
|
||
meter = MagicMock()
|
||
m = _Mt5Metrics()
|
||
m.configure(meter)
|
||
with m.record_history_update(dataset="rates"):
|
||
pass
|
||
m._history_duration.record.assert_called_once() # type: ignore[reportPrivateUsage]
|
||
m._last_successful_update.set.assert_called_once() # type: ignore[reportPrivateUsage]
|
||
m._history_failures.add.assert_not_called() # type: ignore[reportPrivateUsage]
|
||
|
||
def test_record_history_update_failure(self) -> None:
|
||
"""record_history_update increments failure counter and re-raises on error."""
|
||
meter = MagicMock()
|
||
m = _Mt5Metrics()
|
||
m.configure(meter)
|
||
exc = ValueError("boom")
|
||
with (
|
||
pytest.raises(ValueError, match="boom"),
|
||
m.record_history_update(dataset="rates"),
|
||
):
|
||
raise exc
|
||
m._history_failures.add.assert_called_once_with( # type: ignore[reportPrivateUsage]
|
||
1, {"dataset": "rates"}
|
||
)
|
||
m._history_duration.record.assert_not_called() # type: ignore[reportPrivateUsage]
|
||
|
||
def test_record_snapshot_update_success(self) -> None:
|
||
"""record_snapshot_update records duration on success."""
|
||
meter = MagicMock()
|
||
m = _Mt5Metrics()
|
||
m.configure(meter)
|
||
with m.record_snapshot_update():
|
||
pass
|
||
m._snapshot_duration.record.assert_called_once() # type: ignore[reportPrivateUsage]
|
||
m._snapshot_failures.add.assert_not_called() # type: ignore[reportPrivateUsage]
|
||
|
||
def test_record_snapshot_update_failure(self) -> None:
|
||
"""record_snapshot_update increments failure counter and re-raises on error."""
|
||
meter = MagicMock()
|
||
m = _Mt5Metrics()
|
||
m.configure(meter)
|
||
exc = RuntimeError("snap fail")
|
||
with (
|
||
pytest.raises(RuntimeError, match="snap fail"),
|
||
m.record_snapshot_update(),
|
||
):
|
||
raise exc
|
||
m._snapshot_failures.add.assert_called_once_with(1, {}) # type: ignore[reportPrivateUsage]
|
||
m._snapshot_duration.record.assert_not_called() # type: ignore[reportPrivateUsage]
|
||
|
||
def test_record_position_state(self) -> None:
|
||
"""record_position_state emits profit and volume gauges."""
|
||
meter = MagicMock()
|
||
m = _Mt5Metrics()
|
||
m.configure(meter)
|
||
m.record_position_state(
|
||
login="42",
|
||
server="demo",
|
||
symbol="EURUSD",
|
||
profit=12.5,
|
||
volume=0.01,
|
||
)
|
||
# Both profit and volume share the same gauge mock via create_gauge;
|
||
# verify that set was called exactly twice (once each).
|
||
assert m._position_profit.set.call_count == 2 # type: ignore[reportPrivateUsage]
|
||
|
||
def test_record_account_state_after_configure(self) -> None:
|
||
"""record_account_state emits all five account gauges."""
|
||
meter = MagicMock()
|
||
m = _Mt5Metrics()
|
||
m.configure(meter)
|
||
m.record_account_state(
|
||
login="99",
|
||
server="live",
|
||
balance=5000.0,
|
||
equity=5100.0,
|
||
margin=200.0,
|
||
margin_free=4800.0,
|
||
margin_level=2550.0,
|
||
)
|
||
# All five account gauges share the same gauge mock; set is called 5×.
|
||
assert m._account_balance.set.call_count == 5 # type: ignore[reportPrivateUsage]
|
||
|
||
def test_record_history_update_noop_before_configure(self) -> None:
|
||
"""record_history_update works without configure (no-op instruments)."""
|
||
m = _Mt5Metrics()
|
||
with m.record_history_update(dataset="ticks"):
|
||
pass
|
||
|
||
def test_record_snapshot_update_noop_before_configure(self) -> None:
|
||
"""record_snapshot_update works without configure (no-op instruments)."""
|
||
m = _Mt5Metrics()
|
||
with m.record_snapshot_update():
|
||
pass
|
||
|
||
|
||
class TestConfigureMetrics:
|
||
"""Tests for configure_metrics and get_metrics."""
|
||
|
||
def test_configure_metrics_updates_global(self) -> None:
|
||
"""configure_metrics wires up the global singleton."""
|
||
meter = MagicMock()
|
||
configure_metrics(meter)
|
||
assert get_metrics() is get_metrics()
|
||
|
||
def test_get_metrics_returns_mt5metrics(self) -> None:
|
||
"""get_metrics returns the global _Mt5Metrics instance."""
|
||
assert isinstance(get_metrics(), _Mt5Metrics)
|
||
|
||
|
||
class TestEnableOtelMetrics:
|
||
"""Tests for enable_otel_metrics."""
|
||
|
||
def test_enable_raises_when_unavailable(
|
||
self,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""enable_otel_metrics raises ImportError when OTel is not installed."""
|
||
monkeypatch.setattr("mt5cli.telemetry._OTEL_AVAILABLE", False)
|
||
with pytest.raises(ImportError, match="opentelemetry-api"):
|
||
enable_otel_metrics()
|
||
|
||
def test_enable_uses_global_meter_provider(
|
||
self,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""enable_otel_metrics calls get_meter on the OTel metrics module."""
|
||
mock_meter = MagicMock()
|
||
mock_mod = MagicMock()
|
||
mock_mod.get_meter.return_value = mock_meter
|
||
monkeypatch.setattr("mt5cli.telemetry._OTEL_AVAILABLE", True)
|
||
monkeypatch.setattr("mt5cli.telemetry._otel_metrics_mod", mock_mod)
|
||
enable_otel_metrics("my-service")
|
||
mock_mod.get_meter.assert_called_once_with("my-service")
|
||
|
||
def test_otel_available_flag_is_bool(self) -> None:
|
||
"""_OTEL_AVAILABLE is a boolean."""
|
||
assert isinstance(_OTEL_AVAILABLE, bool)
|