From cf589d45c637251c1318a38cb5bd657a54dda13b Mon Sep 17 00:00:00 2001 From: Nick Moore Date: Wed, 9 Oct 2024 19:27:48 +0100 Subject: [PATCH] feat: add `timedeltaparse` function to the jinja template (#5142) # What this PR does If an OnCall template needs to contain a date relative to a date in the alert response, currently there is no way for the template to add or subtract time from a parsed date. This PR adds a function that allows a time-window (e.g., 1s, 5m, 6h, 7d, 2w) to be converted into a Python timedelta, which can then be added or subtracted from a datetime. An example usage might be: ``` {% set delta = alert.timeWindow | timedeltaparse %} {% set time = alert.startsAt | iso8601_to_time - delta | datetimeformat('%s') %} ``` ## Checklist - [X] Unit, integration, and e2e (if applicable) tests updated - [X] Documentation added (or `pr:no public docs` PR label added if not required) - [X] Added the relevant release notes label (see labels prefixed w/ `release:`). These labels dictate how your PR will show up in the autogenerated release notes. --- .../advanced-templates/index.md | 2 + engine/common/jinja_templater/filters.py | 31 ++++++++++++- .../jinja_templater/jinja_template_env.py | 2 + .../common/tests/test_apply_jinja_template.py | 43 ++++++++++++++++++- .../CheatSheet/CheatSheet.config.ts | 2 +- 5 files changed, 77 insertions(+), 3 deletions(-) diff --git a/docs/sources/configure/jinja2-templating/advanced-templates/index.md b/docs/sources/configure/jinja2-templating/advanced-templates/index.md index a92f43afce..d98f642253 100644 --- a/docs/sources/configure/jinja2-templating/advanced-templates/index.md +++ b/docs/sources/configure/jinja2-templating/advanced-templates/index.md @@ -92,6 +92,8 @@ Grafana OnCall enhances Jinja with additional functions: - `datetimeformat_as_timezone`: Converts datetime to string with timezone conversion (`UTC` by default) - Usage example: `{{ payload.alerts.startsAt | iso8601_to_time | datetimeformat_as_timezone('%Y-%m-%dT%H:%M:%S%z', 'America/Chicago') }}` - `datetimeparse`: Converts string to datetime according to strftime format codes (`%H:%M / %d-%m-%Y` by default) +- `timedeltaparse`: Converts a time range (e.g., `5s`, `2m`, `6h`, `3d`) to a timedelta that can be added to or subtracted from a datetime + - Usage example: `{% set delta = alert.window | timedeltaparse %}{{ alert.startsAt | iso8601_to_time - delta | datetimeformat }}` - `regex_replace`: Performs a regex find and replace - `regex_match`: Performs a regex match, returns `True` or `False` - Usage example: `{{ payload.ruleName | regex_match(".*") }}` diff --git a/engine/common/jinja_templater/filters.py b/engine/common/jinja_templater/filters.py index 430967309b..742931d725 100644 --- a/engine/common/jinja_templater/filters.py +++ b/engine/common/jinja_templater/filters.py @@ -1,6 +1,6 @@ import base64 import json -from datetime import datetime +from datetime import datetime, timedelta import regex from django.utils.dateparse import parse_datetime @@ -37,6 +37,35 @@ def iso8601_to_time(value): return None +range_duration_re = regex.compile("^(?P[-+]?)(?P\\d+)(?P[smhdwMQy])$") + + +def timedeltaparse(value): + try: + match = range_duration_re.match(value) + if match: + kw = match.groupdict() + amount = int(kw["amount"]) + if kw["sign"] == "-": + amount = -amount + if kw["unit"] == "s": + return timedelta(seconds=amount) + elif kw["unit"] == "m": + return timedelta(minutes=amount) + elif kw["unit"] == "h": + return timedelta(hours=amount) + elif kw["unit"] == "d": + return timedelta(days=amount) + elif kw["unit"] == "w": + return timedelta(weeks=amount) + # The remaining units (MQy) are not supported by timedelta + else: + return None + except (ValueError, AttributeError, TypeError): + return None + return None + + def to_pretty_json(value): try: return json.dumps(value, sort_keys=True, indent=4, separators=(",", ": "), ensure_ascii=False) diff --git a/engine/common/jinja_templater/jinja_template_env.py b/engine/common/jinja_templater/jinja_template_env.py index f24a0c5517..910c287daf 100644 --- a/engine/common/jinja_templater/jinja_template_env.py +++ b/engine/common/jinja_templater/jinja_template_env.py @@ -14,6 +14,7 @@ regex_match, regex_replace, regex_search, + timedeltaparse, to_pretty_json, ) @@ -28,6 +29,7 @@ def raise_security_exception(name): jinja_template_env.filters["datetimeformat_as_timezone"] = datetimeformat_as_timezone jinja_template_env.filters["datetimeparse"] = datetimeparse jinja_template_env.filters["iso8601_to_time"] = iso8601_to_time +jinja_template_env.filters["timedeltaparse"] = timedeltaparse jinja_template_env.filters["tojson_pretty"] = to_pretty_json jinja_template_env.globals["time"] = timezone.now jinja_template_env.globals["range"] = lambda *args: raise_security_exception("range") diff --git a/engine/common/tests/test_apply_jinja_template.py b/engine/common/tests/test_apply_jinja_template.py index 3694ffa953..03fcec1c20 100644 --- a/engine/common/tests/test_apply_jinja_template.py +++ b/engine/common/tests/test_apply_jinja_template.py @@ -1,6 +1,6 @@ import base64 import json -from datetime import datetime +from datetime import datetime, timedelta from unittest.mock import patch import pytest @@ -140,6 +140,47 @@ def test_apply_jinja_template_datetimeparse(): ) == str(datetime.strptime(payload["naive"], "%Y-%m-%dT%H:%M:%S")) +def test_apply_jinja_template_timedeltaparse(): + payload = {"seconds": "-100s", "hours": "12h", "days": "-5d", "weeks": "52w"} + + assert apply_jinja_template( + "{{ payload.seconds | timedeltaparse }}", + payload, + ) == str(timedelta(seconds=-100)) + assert apply_jinja_template( + "{{ payload.hours | timedeltaparse }}", + payload, + ) == str(timedelta(hours=12)) + assert apply_jinja_template( + "{{ payload.days | timedeltaparse }}", + payload, + ) == str(timedelta(days=-5)) + assert apply_jinja_template( + "{{ payload.weeks | timedeltaparse }}", + payload, + ) == str(timedelta(weeks=52)) + + +def test_apply_jinja_template_timedelta_arithmetic(): + payload = { + "dt": "2023-11-22T15:30:00.000000000Z", + "delta": "1h", + "before": "2023-11-22T14:30:00.000000000Z", + "after": "2023-11-22T16:30:00.000000000Z", + } + + result = apply_jinja_template( + "{% set delta = payload.delta | timedeltaparse -%}{{ payload.dt | iso8601_to_time - delta }}", + payload, + ) + assert result == str(parse_datetime(payload["before"])) + result = apply_jinja_template( + "{% set delta = payload.delta | timedeltaparse -%}{{ payload.dt | iso8601_to_time + delta }}", + payload, + ) + assert result == str(parse_datetime(payload["after"])) + + def test_apply_jinja_template_b64decode(): payload = {"name": "SGVsbG8sIHdvcmxkIQ=="} diff --git a/grafana-plugin/src/components/CheatSheet/CheatSheet.config.ts b/grafana-plugin/src/components/CheatSheet/CheatSheet.config.ts index 66846368c1..5ed0cb6774 100644 --- a/grafana-plugin/src/components/CheatSheet/CheatSheet.config.ts +++ b/grafana-plugin/src/components/CheatSheet/CheatSheet.config.ts @@ -84,7 +84,7 @@ export const genericTemplateCheatSheet: CheatSheetInterface = { { listItemName: 'labels - labels assigned to the last alert in the group' }, { listItemName: 'web_title, web_mesage, web_image_url - templates from Web' }, { listItemName: 'payload, grafana_oncall_link, grafana_oncall_incident_id, integration_name, source_link' }, - { listItemName: 'time(), datetimeformat, datetimeformat_as_timezone, datetimeparse, iso8601_to_time' }, + { listItemName: 'time(), datetimeformat, datetimeformat_as_timezone, datetimeparse, iso8601_to_time, timedeltaparse' }, { listItemName: 'to_pretty_json' }, { listItemName: 'regex_replace, regex_match, regex_search' }, { listItemName: 'b64decode' },