1ffac45d57
* feat: Grafana copy publishing, dashboard examples, and optional OTel metrics 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> * fix: replace ambiguous multiplication sign in comment Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: normalize markdown formatting in grafana README Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: preserve file mode on Grafana copy and fix unsupported time macro - publish_grafana_copy: chmod temp file to match the existing target's permissions (or 0o644 when no prior target exists) before atomic replace, so Grafana running as a different OS user (e.g. UID 472 in Docker) can read the published database - mt5cli-market.json: replace unsupported \$__timeFilter(time) with the epoch-based filter supported by frser-sqlite-datasource: "time" >= \$__from / 1000 AND "time" < \$__to / 1000 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: skip Windows-incompatible mode test, rename compose file to compose.yaml - Skip test_overwrite_preserves_existing_target_mode on win32 since Windows chmod does not preserve Unix group/other permission bits - Simplify test_fresh_target_has_readable_permissions to check owner read bit only (portable across platforms) - Rename docker-compose.yml -> compose.yaml (modern Compose convention) - Update README and test reference to match new filename Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: rename *.yaml to *.yml in examples/grafana Renames compose.yaml, mt5cli-sqlite.yaml, and mt5cli.yaml to .yml; updates README and test references accordingly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: format Grafana dashboards and expand qa script to include JSON - Update qa.sh prettier pattern to format JSON files alongside markdown - Reformat Grafana dashboard JSONs with consistent spacing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address owner review comments before merge - qa.sh: fix Prettier glob from `{,d,json}` to `{md,json}` so Markdown files are actually formatted by local QA (P2) - compose.yml: add GF_INSTALL_PLUGINS env var so the frser-sqlite-datasource plugin is installed at container start (P1) - telemetry.py: replace no-op get_meter() call with a real SDK MeterProvider pipeline; add optional `readers` kwarg so callers can inject custom readers (e.g. InMemoryMetricReader in tests) without needing the OTLP package (P1) - sdk.py: aggregate profit and volume by symbol before emitting gauge values so hedging accounts with multiple same-symbol positions emit one point per symbol instead of overwriting with each row (P2) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: emit mt5_history_update_rows_total via conn.total_changes delta The counter was registered but never incremented, making the advertised history-update throughput metric permanently zero. Add add_history_rows() to _Mt5Metrics and call it in update_history() using the SQLite total_changes delta measured around write_incremental_datasets(). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address three owner review comments - compose.yml: replace soft fallback with :? error expansion so Compose refuses to start when MT5CLI_DB_PATH is unset or empty (P1) - README.md: tell native Windows users to copy only the datasource provisioning file; the dashboards yml contains a Docker-specific path that is invalid on Windows (P2) - telemetry.py / sdk.py: emit mt5_terminal_connected, mt5_terminal_trade_allowed, and mt5_terminal_trade_expert gauges via a new record_terminal_state() method called from _snapshot_terminal(), completing the connection-status metric surface from issue #84 (P2) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add snapshot freshness panel and win-rate column to dashboards - mt5cli-overview.json: add a full-width "Last Snapshot" stat panel (dateTimeFromNow unit) below the account stats, querying MAX(time)*1000 from grafana_account_snapshots so users can tell whether Grafana is reading a current published copy (#83) - mt5cli-trades.json: add win_rate_pct computed column to the Trade Statistics by Symbol table via 100.0 * winning_deals / NULLIF( total_deals, 0), with a percent unit override and "Win Rate (%)" display label (#83) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: reject same source and target path in publish_grafana_copy Adds an early same-path guard to publish_grafana_copy: resolves both paths before any I/O and raises ValueError if they are identical, preventing the function from overwriting the live source database with its own backup copy. Also adds a unit test for the rejected case. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address ruff EM102/TRY003/E501 in same-path guard Assigns the ValueError message to a variable before raising and shortens the test docstring to stay within the 88-char line limit. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: apply ruff format to publish_grafana_copy error message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: remove grafana_ticks panel from default market dashboard The Tick Bid/Ask panel queried grafana_ticks which only exists when users collect tick data (opt-in). Users following the default OHLCV-only setup path hit "no such table: grafana_ticks" on dashboard load. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: close SQLite connections before atomic replace in publish_grafana_copy Wrap both src and dst connections with contextlib.closing() so they are explicitly closed before tmp_path.replace(target_path) runs. Without this, sqlite3.Connection's context manager only commits/rolls back but leaves the file handle open, which can cause PermissionError on Windows. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: rename history.grafana.db to history.mt5cli.db in Grafana examples frser-sqlite-datasource blocks paths containing "grafana.db" via its internal blocklist. Rename the recommended published filename in the README, compose comment, and datasource provisioning comment to avoid a blocked/denied datasource for native Windows users. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: update Docker Compose quick-start to pass MT5CLI_DB_PATH The compose.yml already required MT5CLI_DB_PATH via ${MT5CLI_DB_PATH:?...}, but the README still showed bare `docker compose up -d`. Update the section to show the env-var-prefixed invocation and document the .env file alternative. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: agent <agent@localhost> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
250 lines
9.5 KiB
Python
250 lines
9.5 KiB
Python
"""Tests for mt5cli.telemetry module."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
from opentelemetry.sdk.metrics.export import InMemoryMetricReader
|
|
|
|
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_add_history_rows(self) -> None:
|
|
"""add_history_rows increments the rows-written counter."""
|
|
meter = MagicMock()
|
|
m = _Mt5Metrics()
|
|
m.configure(meter)
|
|
m.add_history_rows(42, dataset="rates")
|
|
m._history_rows.add.assert_called_once_with( # type: ignore[reportPrivateUsage]
|
|
42, {"dataset": "rates"}
|
|
)
|
|
|
|
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_terminal_state(self) -> None:
|
|
"""record_terminal_state emits connected, trade_allowed, trade_expert gauges."""
|
|
meter = MagicMock()
|
|
m = _Mt5Metrics()
|
|
m.configure(meter)
|
|
m.record_terminal_state(connected=1.0, trade_allowed=1.0, trade_expert=0.0)
|
|
# All three terminal gauges share the same mock; set is called 3 times.
|
|
assert m._terminal_connected.set.call_count == 3 # 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 times.
|
|
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_configures_sdk_pipeline_with_readers(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""enable_otel_metrics wires up an SDK MeterProvider with supplied readers."""
|
|
mock_mod = MagicMock()
|
|
monkeypatch.setattr("mt5cli.telemetry._OTEL_AVAILABLE", True)
|
|
monkeypatch.setattr("mt5cli.telemetry._otel_metrics_mod", mock_mod)
|
|
reader = InMemoryMetricReader()
|
|
enable_otel_metrics("my-service", readers=[reader])
|
|
mock_mod.set_meter_provider.assert_called_once()
|
|
provider = mock_mod.set_meter_provider.call_args[0][0]
|
|
assert provider.get_meter("my-service") is not None
|
|
|
|
def test_enable_default_readers_uses_otlp(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""enable_otel_metrics with no readers creates an OTLP pipeline by default."""
|
|
mock_mod = MagicMock()
|
|
monkeypatch.setattr("mt5cli.telemetry._OTEL_AVAILABLE", True)
|
|
monkeypatch.setattr("mt5cli.telemetry._otel_metrics_mod", mock_mod)
|
|
monkeypatch.setattr("mt5cli.telemetry._OtelOTLPExporter", MagicMock())
|
|
enable_otel_metrics("my-service")
|
|
mock_mod.set_meter_provider.assert_called_once()
|
|
|
|
def test_enable_default_readers_raises_when_otlp_missing(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""enable_otel_metrics raises ImportError when the OTLP exporter is missing."""
|
|
mock_mod = MagicMock()
|
|
monkeypatch.setattr("mt5cli.telemetry._OTEL_AVAILABLE", True)
|
|
monkeypatch.setattr("mt5cli.telemetry._otel_metrics_mod", mock_mod)
|
|
monkeypatch.setattr("mt5cli.telemetry._OtelOTLPExporter", None)
|
|
with pytest.raises(ImportError, match="opentelemetry-exporter-otlp-proto-http"):
|
|
enable_otel_metrics()
|
|
|
|
def test_otel_available_flag_is_bool(self) -> None:
|
|
"""_OTEL_AVAILABLE is a boolean."""
|
|
assert isinstance(_OTEL_AVAILABLE, bool)
|