Skip to content

Commit 6abdfa7

Browse files
authored
Merge pull request #76 from GabrielSalla/add-monitor-variables
Add monitor variables
2 parents 3e3e63d + 154f33c commit 6abdfa7

File tree

15 files changed

+548
-10
lines changed

15 files changed

+548
-10
lines changed

docs/monitor.md

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,10 @@ To create a monitor, import specific dependencies from `monitor_utils`. Availabl
5656
- [`ValueRule`](#value-rule)
5757
- [`PriorityLevels`](#priority-levels)
5858

59-
**Functions**
59+
**Available functions and modules**
6060
- [`query`](#query)
6161
- [`read_file`](#read-file)
62+
- [`variables`](#variables)
6263

6364
To get started with a simple monitor, use the following imports:
6465

@@ -360,5 +361,38 @@ The function takes 2 parameters:
360361
content = read_file("search_query.sql")
361362
```
362363

364+
## Variables
365+
The `variables` module allows storing and retrieving variables that can be used across executions of the monitor. This is useful for maintaining state or configuration information.
366+
367+
Available functions are:
368+
369+
**`get_variable`**
370+
371+
The function takes one parameter:
372+
- `name`: The name of the variable to retrieve.
373+
374+
Return the value of a variable. If the variable does not exist, returns `None`.
375+
376+
**`set_variable`**
377+
378+
The function takes two parameters:
379+
- `name`: The name of the variable to set.
380+
- `value`: The value to assign to the variable.
381+
382+
Sets the value of a variable. If the variable does not exist yet, it's created.
383+
384+
Both functions must be called from functions defined in the monitor base code. If they're called from any other Python file, they will raise an error as they won't be able to identify the monitor that's calling it.
385+
386+
```python
387+
from monitor_utils import variables
388+
389+
async def search() -> list[IssueDataType] | None:
390+
# Set a variable
391+
await variables.set_variable("my_var", "some_value")
392+
393+
async def update(issues_data: list[IssueDataType]) -> list[IssueDataType] | None:
394+
value = await variables.get_variable("my_var")
395+
```
396+
363397
# Registering
364398
After creating the monitor, the next step is to register it on Sentinela. Check the [Registering a monitor](monitor_registering.md) documentation for more information.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""create variables table
2+
3+
Revision ID: ce56a57ff560
4+
Revises: 247390255aee
5+
Create Date: 2025-05-23 21:08:54.608578
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
import sqlalchemy as sa
11+
from alembic import op
12+
13+
# revision identifiers, used by Alembic.
14+
revision: str = "ce56a57ff560"
15+
down_revision: Union[str, None] = "247390255aee"
16+
branch_labels: Union[str, Sequence[str], None] = None
17+
depends_on: Union[str, Sequence[str], None] = None
18+
19+
20+
def upgrade() -> None:
21+
op.create_table(
22+
"Variables",
23+
sa.Column("id", sa.Integer(), primary_key=True),
24+
sa.Column("monitor_id", sa.Integer()),
25+
sa.Column("name", sa.String()),
26+
sa.Column("value", sa.String(), nullable=True),
27+
sa.Column("updated_at", sa.DateTime(timezone=True)),
28+
29+
sa.ForeignKeyConstraint(("monitor_id",), ["Monitors.id"]),
30+
)
31+
op.create_index(
32+
"ix_Variables_monitor_id_name",
33+
"Variables",
34+
["monitor_id", "name"],
35+
unique=True,
36+
)
37+
38+
39+
def downgrade() -> None:
40+
op.drop_index("ix_Variables_monitor_id_name")
41+
op.drop_table("Variables")

src/components/monitors_loader/monitor_module_type.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
1+
from pathlib import Path
12
from typing import Protocol, TypedDict
23

34
from data_models.monitor_options import AlertOptions, IssueOptions, MonitorOptions, ReactionOptions
45
from notifications import BaseNotification
56

67

78
class MonitorModule(Protocol):
8-
"""Class that represents a base monitor module structure to have a better code completion"""
9+
"""Class that represents a base monitor module structure"""
10+
11+
SENTINELA_MONITOR_ID: int
12+
SENTINELA_MONITOR_NAME: str
13+
SENTINELA_MONITOR_PATH: Path
914

1015
monitor_options: MonitorOptions
1116
issue_options: IssueOptions

src/components/monitors_loader/monitors_loader.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,8 +179,15 @@ async def _register_monitors() -> None:
179179
await _register_monitors_from_path(configs.sample_monitors_path)
180180

181181

182-
def _configure_monitor(monitor_module: MonitorModule) -> None:
182+
def _configure_monitor(
183+
monitor_module: MonitorModule, monitor_id: int, monitor_name: str, monitor_path: Path
184+
) -> None:
183185
"""Make the necessary configurations to the monitor's attributes"""
186+
# Set the monitor's identification attributes
187+
monitor_module.SENTINELA_MONITOR_ID = monitor_id
188+
monitor_module.SENTINELA_MONITOR_NAME = monitor_name
189+
monitor_module.SENTINELA_MONITOR_PATH = monitor_path
190+
184191
# Add an empty reaction option if it was not configured
185192
if getattr(monitor_module, "reaction_options", None) is None:
186193
monitor_module.reaction_options = ReactionOptions()
@@ -274,7 +281,7 @@ async def _load_monitors(last_load_time: datetime | None) -> None:
274281

275282
monitor_path = monitors_paths[monitor.id]
276283
monitor_module = cast(MonitorModule, module_loader.load_module_from_file(monitor_path))
277-
_configure_monitor(monitor_module)
284+
_configure_monitor(monitor_module, monitor.id, monitor.name, monitor_path)
278285

279286
registry.add_monitor(monitor.id, monitor.name, monitor_module)
280287

src/models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from .monitor import Monitor
66
from .notification import Notification, NotificationStatus
77
from .utils.priority import AlertPriority
8+
from .variable import Variable
89

910
__all__ = [
1011
"Alert",
@@ -17,4 +18,5 @@
1718
"Monitor",
1819
"Notification",
1920
"NotificationStatus",
21+
"Variable",
2022
]

src/models/variable.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from datetime import datetime
2+
3+
from sqlalchemy import DateTime, ForeignKey, Integer, String
4+
from sqlalchemy.orm import Mapped, mapped_column
5+
6+
from utils.time import now
7+
8+
from .base import Base
9+
10+
11+
class Variable(Base):
12+
__tablename__ = "Variables"
13+
14+
id: Mapped[int] = mapped_column(Integer(), primary_key=True)
15+
monitor_id: Mapped[int] = mapped_column(ForeignKey("Monitors.id"))
16+
name: Mapped[str] = mapped_column(String())
17+
value: Mapped[str | None] = mapped_column(String(), nullable=True)
18+
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), insert_default=now)
19+
20+
async def set(self, value: str | None) -> None:
21+
"""Set the variable value"""
22+
self.value = value
23+
self.updated_at = now()
24+
await self.save()

src/monitor_utils/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from databases import query
1717
from models.utils.priority import AlertPriority
1818

19+
from . import variables
1920
from .read_file import read_file
2021

2122
__all__ = [
@@ -31,4 +32,5 @@
3132
"ReactionOptions",
3233
"read_file",
3334
"ValueRule",
35+
"variables",
3436
]

src/monitor_utils/variables.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from types import ModuleType
2+
from typing import cast
3+
4+
from models import Variable
5+
from utils.stack import get_caller
6+
7+
"""The functions 'set_variable' and 'get_variable' must be called from inside a monitor to be able
8+
to identify the monitor ID. If called outside a monitor, they will raise an error."""
9+
10+
11+
def _get_monitor_id(monitor_module: ModuleType) -> int:
12+
"""Get the monitor ID from the monitor module"""
13+
monitor_id = getattr(monitor_module, "SENTINELA_MONITOR_ID", None)
14+
if monitor_id is None:
15+
raise ValueError("Function called outside a monitor or the monitor was not loaded properly")
16+
17+
return cast(int, monitor_id)
18+
19+
20+
async def set_variable(name: str, value: str | None) -> None:
21+
"""Set a variable for the monitor. Must be called from inside a monitor or an error will be
22+
raised."""
23+
monitor_module, _ = get_caller()
24+
monitor_id = _get_monitor_id(monitor_module)
25+
26+
variable = await Variable.get_or_create(monitor_id=monitor_id, name=name)
27+
await variable.set(value)
28+
29+
30+
async def get_variable(name: str) -> str | None:
31+
"""Get a variable for the monitor, or None if it does not exist. Must be called from inside a
32+
monitor or an error will be raised."""
33+
monitor_module, _ = get_caller()
34+
monitor_id = _get_monitor_id(monitor_module)
35+
36+
variable = await Variable.get(Variable.monitor_id == monitor_id, Variable.name == name)
37+
if variable is None:
38+
return None
39+
40+
return variable.value

src/utils/stack.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import inspect
2+
from types import ModuleType
3+
4+
5+
def get_caller(previous: int = 0) -> tuple[ModuleType, str]:
6+
"""Get the module of the caller function"""
7+
stack_position = 2 + previous
8+
9+
stack = inspect.stack()
10+
11+
if len(stack) < stack_position:
12+
raise IndexError(f"Could not access position {stack_position} in the stack")
13+
14+
caller_frame = stack[stack_position][0]
15+
module = inspect.getmodule(caller_frame)
16+
if module is None:
17+
raise ValueError("Could not determine caller module")
18+
19+
return module, caller_frame.f_code.co_name

tests/components/monitors_loader/test_monitors_loader.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -496,20 +496,39 @@ async def test_register_monitors_no_sample_monitors(monkeypatch, clear_database)
496496
assert code_modules_dict[monitor.id].additional_files == {}
497497

498498

499-
async def test_configure_monitor(monkeypatch, sample_monitor: Monitor):
500-
"""'_configure_monitor' should populate the 'reaction_options' and 'notification_options' with
501-
the default values"""
499+
@pytest.mark.parametrize(
500+
"monitor_id, monitor_name, monitor_path",
501+
[
502+
(11, "abc", Path("/path/to/monitor")),
503+
(22, "def", Path("/other_path/to/monitor2")),
504+
(33, "ghi", Path("/one_more_path/monitor3")),
505+
],
506+
)
507+
async def test_configure_monitor(
508+
monkeypatch, sample_monitor: Monitor, monitor_id, monitor_name, monitor_path
509+
):
510+
"""'_configure_monitor' should populate the monitor identification attributes,
511+
'reaction_options' and 'notification_options' with the default values"""
502512
monitor_module = sample_monitor.code
503513
monkeypatch.setattr(monitor_module, "reaction_options", None, raising=False)
504514
monkeypatch.setattr(monitor_module, "notification_options", None, raising=False)
515+
monkeypatch.setattr(monitor_module, "SENTINELA_MONITOR_ID", None)
516+
monkeypatch.setattr(monitor_module, "SENTINELA_MONITOR_NAME", None)
517+
monkeypatch.setattr(monitor_module, "SENTINELA_MONITOR_PATH", None)
505518

506519
assert getattr(monitor_module, "reaction_options", None) is None
507520
assert getattr(monitor_module, "notification_options", None) is None
521+
assert getattr(monitor_module, "SENTINELA_MONITOR_ID", None) is None
522+
assert getattr(monitor_module, "SENTINELA_MONITOR_NAME", None) is None
523+
assert getattr(monitor_module, "SENTINELA_MONITOR_PATH", None) is None
508524

509-
monitors_loader._configure_monitor(monitor_module)
525+
monitors_loader._configure_monitor(monitor_module, monitor_id, monitor_name, monitor_path)
510526

511527
assert isinstance(monitor_module.reaction_options, ReactionOptions)
512528
assert monitor_module.notification_options == []
529+
assert monitor_module.SENTINELA_MONITOR_ID == monitor_id
530+
assert monitor_module.SENTINELA_MONITOR_NAME == monitor_name
531+
assert monitor_module.SENTINELA_MONITOR_PATH == monitor_path
513532

514533

515534
async def test_configure_monitor_notifications_setup(monkeypatch, sample_monitor: Monitor):
@@ -538,7 +557,7 @@ def reactions_list(self):
538557

539558
monkeypatch.setattr(monitor_module, "notification_options", [MockNotification()], raising=False)
540559

541-
monitors_loader._configure_monitor(monitor_module)
560+
monitors_loader._configure_monitor(monitor_module, 1, "monitor_1", Path(""))
542561

543562
assert monitor_module.reaction_options.alert_updated == [do_something, do_nothing]
544563
assert monitor_module.reaction_options.alert_solved == [do_nothing, "do_nothing"]

0 commit comments

Comments
 (0)