Skip to content

Commit

Permalink
Merge branch 'main' into Matvey-Kuk/multiple-bypass-keys
Browse files Browse the repository at this point in the history
  • Loading branch information
Matvey-Kuk authored Nov 27, 2024
2 parents 1cfb223 + fb0bc90 commit 860fd21
Show file tree
Hide file tree
Showing 10 changed files with 501 additions and 41 deletions.
15 changes: 15 additions & 0 deletions examples/workflows/businesshours.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
workflow:
id: businesshours
description: demonstrate how to do smth only when it's business hours
triggers:
- type: alert
- type: manual
actions:
- name: dismiss-alert
if: "keep.is_business_hours(timezone='America/New_York')"
provider:
type: mock
with:
enrich_alert:
- key: buisnesshours
value: "true"
2 changes: 1 addition & 1 deletion keep-ui/shared/ui/utils/showErrorToast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,6 @@ export function showErrorToast(
} else if (error instanceof KeepApiError) {
toast.error(customMessage || error.message, options);
} else {
toast.error(`${customMessage + ": " || ""}Unknown error}`, options);
toast.error(`${customMessage + ": " || ""}Unknown error`, options);
}
}
41 changes: 41 additions & 0 deletions keep/api/tasks/notification_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import os
import time
from typing import Dict, Tuple

# Get polling interval from env
POLLING_INTERVAL = int(os.getenv("PUSHER_POLLING_INTERVAL", "15"))


class NotificationCache:
_instance = None
__initialized = False

def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance

def __init__(self):
if not self.__initialized:
self.cache: Dict[Tuple[str, str], float] = {}
self.__initialized = True

def should_notify(self, tenant_id: str, event_type: str) -> bool:
cache_key = (tenant_id, event_type)
current_time = time.time()

if cache_key not in self.cache:
self.cache[cache_key] = current_time
return True

last_time = self.cache[cache_key]
if current_time - last_time >= POLLING_INTERVAL:
self.cache[cache_key] = current_time
return True

return False


# Get singleton instance
def get_notification_cache() -> NotificationCache:
return NotificationCache()
16 changes: 11 additions & 5 deletions keep/api/tasks/process_event_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from keep.api.core.elastic import ElasticClient
from keep.api.models.alert import AlertDto, AlertStatus, IncidentDto
from keep.api.models.db.alert import Alert, AlertActionType, AlertAudit, AlertRaw
from keep.api.tasks.notification_cache import get_notification_cache
from keep.api.utils.enrichment_helpers import (
calculated_start_firing_time,
convert_db_alerts_to_dto_alerts,
Expand Down Expand Up @@ -410,11 +411,12 @@ def __handle_formatted_events(
},
)


pusher_client = get_pusher_client() if notify_client else None
# Get the notification cache
pusher_cache = get_notification_cache()

# Tell the client to poll alerts
if pusher_client:
if pusher_client and pusher_cache.should_notify(tenant_id, "poll-alerts"):
try:
pusher_client.trigger(
f"private-{tenant_id}",
Expand All @@ -425,8 +427,12 @@ def __handle_formatted_events(
except Exception:
logger.exception("Failed to tell client to poll alerts")
pass

if incidents and pusher_client:

if (
incidents
and pusher_client
and pusher_cache.should_notify(tenant_id, "incident-change")
):
try:
pusher_client.trigger(
f"private-{tenant_id}",
Expand All @@ -440,7 +446,7 @@ def __handle_formatted_events(
# send with pusher
if not pusher_client:
return

try:
presets = get_all_presets_dtos(tenant_id)
rules_engine = RulesEngine(tenant_id=tenant_id)
Expand Down
78 changes: 78 additions & 0 deletions keep/functions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -392,3 +392,81 @@ def is_first_time(fingerprint: str, since: str = None, **kwargs) -> str:
return True
else:
return False


def is_business_hours(
time_to_check=None,
start_hour=8,
end_hour=20,
business_days=(0, 1, 2, 3, 4), # Mon = 0, Sun = 6
timezone="UTC",
):
"""
Check if the given time or current time is between start_hour and end_hour
and falls on a business day
Args:
time_to_check (str | datetime.datetime, optional): Time to check.
If None, current UTC time will be used.
start_hour (int, optional): Start hour in 24-hour format. Defaults to 8 (8:00 AM)
end_hour (int, optional): End hour in 24-hour format. Defaults to 20 (8:00 PM)
business_days (tuple, optional): Days of week considered as business days.
Monday=0 through Sunday=6. Defaults to Mon-Fri (0,1,2,3,4)
timezone (str, optional): Timezone name (e.g., 'UTC', 'America/New_York', 'Europe/London').
Defaults to 'UTC'.
Returns:
bool: True if time is between start_hour and end_hour on a business day
Raises:
ValueError: If start_hour or end_hour are not between 0 and 23
ValueError: If business_days contains invalid day numbers
ValueError: If timezone string is invalid
"""
# Validate hour inputs
if not (0 <= start_hour <= 23 and 0 <= end_hour <= 23):
raise ValueError("Hours must be between 0 and 23")

# Strict validation for business_days
try:
invalid_days = [day for day in business_days if not (0 <= day <= 6)]
if invalid_days:
raise ValueError(
f"Invalid business days: {invalid_days}. Days must be between 0 (Monday) and 6 (Sunday)"
)
except TypeError:
raise ValueError(
"business_days must be an iterable of integers between 0 and 6"
)

# Validate and convert timezone string to pytz timezone
try:
tz = pytz.timezone(timezone)
except pytz.exceptions.UnknownTimeZoneError:
raise ValueError(f"Invalid timezone: {timezone}")

# If no time provided, use current UTC time
if time_to_check is None:
dt = utcnow()
else:
# Convert string to datetime if needed
dt = to_utc(time_to_check) if isinstance(time_to_check, str) else time_to_check

if not dt: # Handle case where parsing failed
return False

# Convert to specified timezone
dt = dt.astimezone(tz)

# Get weekday (Monday = 0, Sunday = 6)
weekday = dt.weekday()

# Check if it's a business day
if weekday not in business_days:
return False

# Get just the hour (in 24-hour format)
hour = dt.hour

# Check if hour is between start_hour and end_hour
return start_hour <= hour < end_hour
85 changes: 65 additions & 20 deletions keep/iohandler/iohandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,9 @@ def _parse(self, tree):
if isinstance(tree, ast.Call):
func = tree.func
args = tree.args
# if its another function
keywords = tree.keywords # Get keyword arguments

# Parse positional args
_args = []
for arg in args:
_arg = None
Expand All @@ -250,7 +252,6 @@ def _parse(self, tree):
_arg = str(arg.s)
elif isinstance(arg, ast.Dict):
_arg = ast.literal_eval(arg)
# set is basically {{ value }}
elif isinstance(arg, ast.Set) or isinstance(arg, ast.List):
_arg = astunparse.unparse(arg).strip()
if (
Expand All @@ -259,10 +260,6 @@ def _parse(self, tree):
or (_arg.startswith("(") and _arg.endswith(")"))
):
try:
# TODO(shahargl): when Keep gonna be self hosted, this will be a security issue!!!
# because the user can run any python code need to find a way to limit the functions that can be used

# https://github.com/keephq/keep/issues/138
import datetime

from dateutil.tz import tzutc
Expand All @@ -272,10 +269,8 @@ def _parse(self, tree):
for dependency in self.context_manager.dependencies:
g[dependency.__name__] = dependency

# TODO: this is a hack to tzutc in the eval, should be more robust
g["tzutc"] = tzutc
g["datetime"] = datetime
# finally, eval the expression
_arg = eval(_arg, g)
except ValueError:
pass
Expand All @@ -284,25 +279,75 @@ def _parse(self, tree):
# if the value is empty '', we still need to pass it to the function
if _arg or _arg == "":
_args.append(_arg)
# check if we need to inject tenant_id

# Parse keyword args
_kwargs = {}
for keyword in keywords:
key = keyword.arg
value = keyword.value

if isinstance(value, ast.Call):
_kwargs[key] = _parse(self, value)
elif isinstance(value, ast.Str) or isinstance(value, ast.Constant):
_kwargs[key] = str(value.s)
elif isinstance(value, ast.Dict):
_kwargs[key] = ast.literal_eval(value)
elif isinstance(value, ast.Set) or isinstance(value, ast.List):
parsed_value = astunparse.unparse(value).strip()
if (
(
parsed_value.startswith("[")
and parsed_value.endswith("]")
)
or (
parsed_value.startswith("{")
and parsed_value.endswith("}")
)
or (
parsed_value.startswith("(")
and parsed_value.endswith(")")
)
):
try:
import datetime

from dateutil.tz import tzutc

g = globals()
for dependency in self.context_manager.dependencies:
g[dependency.__name__] = dependency

g["tzutc"] = tzutc
g["datetime"] = datetime
_kwargs[key] = eval(parsed_value, g)
except ValueError:
pass
else:
_kwargs[key] = value.id

# Get the function and its signature
keep_func = getattr(keep_functions, func.attr)
func_signature = inspect.signature(keep_func)

kwargs = {}
# Add tenant_id if needed
if "kwargs" in func_signature.parameters:
kwargs["tenant_id"] = self.context_manager.tenant_id
_kwargs["tenant_id"] = self.context_manager.tenant_id

try:
val = (
keep_func(*_args) if not kwargs else keep_func(*_args, **kwargs)
)
# try again but with replacing \n with \\n
# again - best effort see test_openobserve_rows_bug test
# Call function with both positional and keyword arguments
val = keep_func(*_args, **_kwargs)
except ValueError:
_args = [arg.replace("\n", "\\n") for arg in _args]
val = (
keep_func(*_args) if not kwargs else keep_func(*_args, **kwargs)
)
# Handle newline escaping if needed
_args = [
arg.replace("\n", "\\n") if isinstance(arg, str) else arg
for arg in _args
]
_kwargs = {
k: v.replace("\n", "\\n") if isinstance(v, str) else v
for k, v in _kwargs.items()
}
val = keep_func(*_args, **_kwargs)

return val

try:
Expand Down
35 changes: 24 additions & 11 deletions keep/providers/prometheus_provider/alerts_mock.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,21 @@
},
"parameters": {
"labels.host": ["host1", "host2", "host3"],
"labels.service": ["calendar-producer-java-otel-api-dd", "kafka", "api", "queue", "db"],
"labels.service": [
"calendar-producer-java-otel-api-dd",
"kafka",
"api",
"queue",
"db",
"ftp",
],
"labels.instance": ["instance1", "instance2", "instance3"],
},
},
"mq_third_full (Message queue is over 33%)": {
"payload": {
"summary": "Message queue is over 33% capacity",
"labels": {
"severity": "warning",
"customer_id": "acme"
},
"labels": {"severity": "warning", "customer_id": "acme"},
},
"parameters": {
"labels.queue": ["queue1", "queue2", "queue3"],
Expand All @@ -32,10 +36,7 @@
"mq_full (Message queue is full)": {
"payload": {
"summary": "Message queue is over 90% capacity",
"labels": {
"severity": "critical",
"customer_id": "acme"
},
"labels": {"severity": "critical", "customer_id": "acme"},
},
"parameters": {
"labels.queue": ["queue4"],
Expand All @@ -52,7 +53,13 @@
},
"parameters": {
"labels.host": ["host1", "host2", "host3"],
"labels.service": ["calendar-producer-java-otel-api-dd", "kafka", "api", "queue", "db"],
"labels.service": [
"calendar-producer-java-otel-api-dd",
"kafka",
"api",
"queue",
"db",
],
"labels.instance": ["instance1", "instance2", "instance3"],
},
},
Expand All @@ -65,7 +72,13 @@
},
"parameters": {
"labels.host": ["host1", "host2", "host3"],
"labels.service": ["calendar-producer-java-otel-api-dd", "kafka", "api", "queue", "db"],
"labels.service": [
"calendar-producer-java-otel-api-dd",
"kafka",
"api",
"queue",
"db",
],
"labels.instance": ["instance1", "instance2", "instance3"],
},
},
Expand Down
Loading

0 comments on commit 860fd21

Please sign in to comment.