Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,9 @@ logs/*.log
config/*.local.yaml
.idea
*.db

# Dachshund's event store (SQLite in WAL mode)
dachshund.db
dachshund.db-wal
dachshund.db-shm
settings.json
21 changes: 21 additions & 0 deletions bot/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from pelican import get_runner, get_registry

from bot import ada
from bot.dashboard import dashboard
from bot.config import BotConfig

logger = logging.getLogger(__name__)
Expand All @@ -26,8 +27,11 @@ def start(config_file: str) -> None:
config = BotConfig(config_file)

_load_logging(config)
# Before extensions, whose on_load emits to the dashboard.
_load_dashboard(config)
_load_extensions(config)
_load_database(config)
_start_dashboard(config)
_show_app_info(config)

ada.bot.start(config=config)
Expand All @@ -38,6 +42,23 @@ def _load_extensions(config: BotConfig) -> None:
ada.bot.load_extension(extension)


def _load_dashboard(config: BotConfig) -> None:
dashboard.init_dachshund(_event_store(config))
dashboard.build_dashboard()


def _start_dashboard(config: BotConfig) -> None:
dashboard.start_dashboard(
path=_event_store(config),
host=config.get("host", section="dashboard", default="127.0.0.1"),
port=int(config.get("port", section="dashboard", default=1212)),
)


def _event_store(config: BotConfig) -> str:
return str(config.get("event_store", section="dashboard", default="dachshund.db"))


def _load_logging(config: BotConfig) -> None:
file_handler: RotatingFileHandler = RotatingFileHandler(
f"logs/{config.environment}.log", maxBytes=10000, backupCount=5
Expand Down
Empty file added bot/dashboard/__init__.py
Empty file.
28 changes: 28 additions & 0 deletions bot/dashboard/dashboard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from typing import Any

import dachshund

from bot.dashboard.pages.activity import activity_page
from bot.dashboard.pages.overview import overview_page
from bot.dashboard.storage import SQLModelStorage


def init_dachshund(event_store: str) -> None:
"""Stop any previous run and (re)open the dashboard's storage."""
dachshund.shutdown()
dachshund.default(path=event_store, storage=SQLModelStorage())


def build_dashboard() -> None:
for page in (overview_page, activity_page):
dachshund.include(page)


def start_dashboard(**options: Any) -> None:
"""Start dachshund, with `options` (`host`, `port`, ...) passed to `init`."""
dachshund.init(
"Ada",
description="Code Society's Matrix bot",
timezone="America/New_York",
**options,
)
36 changes: 36 additions & 0 deletions bot/dashboard/latency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from dataclasses import dataclass

import dachshund

EMA_ALPHA = 0.2


@dataclass
class LatencyHealth:
"""A running average of latency, weighting recent readings most (EMA)."""

average_ms: float | None = None

async def load(self) -> None:
"""Rebuild the average from the stored latency readings."""
self.average_ms = None

for event in await dachshund.default().events.events("latency"):
value = event.payload.get("value")

if value is not None:
self._accumulate(value)

if self.average_ms is not None:
dachshund.emit("average_latency", value=self.average_ms)

def record(self, latency_ms: int) -> None:
dachshund.emit("latency", value=latency_ms)
self._accumulate(latency_ms)
dachshund.emit("average_latency", value=self.average_ms)

def _accumulate(self, latency_ms: float) -> None:
if self.average_ms is None:
self.average_ms = latency_ms
else:
self.average_ms += EMA_ALPHA * (latency_ms - self.average_ms)
Empty file added bot/dashboard/pages/__init__.py
Empty file.
94 changes: 94 additions & 0 deletions bot/dashboard/pages/activity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
from datetime import timedelta

from dachshund import Page
from dachshund.widgets import BarChart, Number, TimeSeriesChart

activity_page = Page("activity", title="Activity", public=True)

with activity_page.row():
activity_page.add(
Number(
"active_members",
title="Active Today",
source="message",
y="author",
aggregate="distinct",
over="day",
)
)
activity_page.add(
Number(
"daily_message_rate",
title="Messages (last 24h)",
source="message",
aggregate="count",
over=timedelta(hours=24),
)
)
activity_page.add(
Number(
"commands_24h",
title="Commands (last 24h)",
source="on_command",
aggregate="count",
over=timedelta(hours=24),
)
)
activity_page.add(
Number(
"recent_joins",
title="Joins (last 24h)",
source="member_flow",
y="joined",
aggregate="sum",
over=timedelta(hours=24),
)
)

with activity_page.row():
activity_page.add(
TimeSeriesChart(
"member_flow",
title="Joins vs Leaves",
y=["joined", "left"],
aggregate="sum",
every=timedelta(days=1),
over=timedelta(days=30),
)
)

with activity_page.row():
activity_page.add(
BarChart(
"messages_per_day",
title="Messages / day",
source="message",
x="date",
persist=True,
)
)
activity_page.add(
BarChart(
"room_messages",
title="Messages per Room",
source="message",
x="room",
persist=True,
)
)

with activity_page.row():
activity_page.add(
BarChart("commands_per_day", title="Commands / day", x="date", persist=True)
)
activity_page.add(
BarChart("on_command", title="Calls per Command", x="name", persist=True)
)

with activity_page.row():
activity_page.add(
BarChart("reactions_per_day", title="Reactions / day", x="date", persist=True)
)
activity_page.add(
BarChart("reaction_emojis", title="Reactions", x="emoji", persist=True)
)
40 changes: 40 additions & 0 deletions bot/dashboard/pages/overview.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from datetime import timedelta

from dachshund import Page
from dachshund.widgets import Number, Text, TimeSeriesChart

from bot.dashboard.widgets.gauge import Gauge

overview_page = Page("overview", title="Overview", public=True)

with overview_page.row():
overview_page.add(Text("summary"))

with overview_page.column(span=4):
overview_page.add(Number("member_count", title="Members"))

with overview_page.row():
with overview_page.column(span=4):
overview_page.add(
Gauge(
"average_latency_gauge",
title="Latency Health",
source="average_latency",
min=0,
max=500,
warning_at=150,
critical_at=300,
unit="ms",
)
)
overview_page.add(
Number(
"worst_latency",
title="Worst Latency (24h)",
source="latency",
aggregate="max",
over=timedelta(hours=24),
unit="ms",
)
)
overview_page.add(TimeSeriesChart("latency", title="Live Latency"))
20 changes: 20 additions & 0 deletions bot/dashboard/storage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import json
from typing import Any

from dachshund.storage import SyncStorage

from bot.dashboard.widget_state import WidgetState


class SQLModelStorage(SyncStorage):
def _save(self, key: str, value: dict[str, Any]) -> None:
record = WidgetState.find(key)

if record is None:
WidgetState.create(key=key, value=json.dumps(value))
else:
record.update(value=json.dumps(value))

def _load(self, key: str) -> dict[str, Any] | None:
record = WidgetState.find(key)
return json.loads(record.value) if record else None
Empty file added bot/dashboard/tests/__init__.py
Empty file.
38 changes: 38 additions & 0 deletions bot/dashboard/tests/test_gauge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from datetime import timedelta

import pytest

from bot.dashboard.widgets.gauge import Gauge


def test_zones_default_to_share_of_range() -> None:
gauge = Gauge("latency", min=0, max=200)

assert gauge.warning_at == 120
assert gauge.critical_at == 170


def test_rejects_empty_range() -> None:
with pytest.raises(ValueError):
Gauge("latency", min=10, max=10)


def test_render_value_carries_zone_config() -> None:
gauge = Gauge(
"latency",
aggregate="average",
over=timedelta(hours=1),
max=500,
warning_at=150,
critical_at=300,
unit="ms",
)

assert gauge.render_value(42.5) == {
"value": 42.5,
"min": 0,
"max": 500,
"warning_at": 150,
"critical_at": 300,
"unit": "ms",
}
57 changes: 57 additions & 0 deletions bot/dashboard/tests/test_latency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from collections.abc import Iterator
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, call, patch

import pytest

from bot.dashboard import latency
from bot.dashboard.latency import LatencyHealth


@pytest.fixture
def emit() -> Iterator[MagicMock]:
with patch.object(latency.dachshund, "emit") as mock:
yield mock


def test_first_reading_is_the_average(emit: MagicMock) -> None:
health = LatencyHealth()
health.record(100)

assert health.average_ms == 100
emit.assert_has_calls(
[call("latency", value=100), call("average_latency", value=100)]
)


def test_later_readings_move_average_by_alpha(emit: MagicMock) -> None:
health = LatencyHealth()
health.record(100)
health.record(200)

assert health.average_ms == pytest.approx(120)


async def test_load_replays_stored_readings(emit: MagicMock) -> None:
stored = [SimpleNamespace(payload={"value": v}) for v in (100, None, 200)]
store = SimpleNamespace(
events=SimpleNamespace(events=AsyncMock(return_value=stored))
)

with patch.object(latency.dachshund, "default", return_value=store):
health = LatencyHealth(average_ms=999)
await health.load()

assert health.average_ms == pytest.approx(120)
emit.assert_called_once_with("average_latency", value=pytest.approx(120))


async def test_load_without_readings_emits_nothing(emit: MagicMock) -> None:
store = SimpleNamespace(events=SimpleNamespace(events=AsyncMock(return_value=[])))

with patch.object(latency.dachshund, "default", return_value=store):
health = LatencyHealth()
await health.load()

assert health.average_ms is None
emit.assert_not_called()
9 changes: 9 additions & 0 deletions bot/dashboard/widget_state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from sqlmodel import Field
from sqlmodel_toolkit import Model


class WidgetState(Model):
__tablename__ = "widget_states"

key: str = Field(primary_key=True)
value: str = Field(nullable=False)
5 changes: 5 additions & 0 deletions bot/dashboard/widgets/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Ada's own dachshund widgets, a package each with its script and style."""

from bot.dashboard.widgets.gauge import Gauge

__all__ = ["Gauge"]
3 changes: 3 additions & 0 deletions bot/dashboard/widgets/gauge/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from bot.dashboard.widgets.gauge.gauge import Gauge

__all__ = ["Gauge"]
Loading
Loading