Files
mt5cli/tests/test_telemetry.py
T
Daichi Narushima 1ffac45d57 feat: add publish_grafana_copy, Grafana examples, and optional OTel metrics (#89)
* 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>
2026-06-28 17:14:17 +09:00

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)