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/apps/alerts/models/alert.py b/engine/apps/alerts/models/alert.py index 14ae02e9df..844cbf6771 100644 --- a/engine/apps/alerts/models/alert.py +++ b/engine/apps/alerts/models/alert.py @@ -25,6 +25,8 @@ from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length if typing.TYPE_CHECKING: + from django.db.models.manager import RelatedManager + from apps.alerts.models import AlertGroup, AlertReceiveChannel, ChannelFilter logger = logging.getLogger(__name__) @@ -47,6 +49,7 @@ def generate_public_primary_key_for_alert(): class Alert(models.Model): group: typing.Optional["AlertGroup"] + resolved_alert_groups: "RelatedManager['AlertGroup']" public_primary_key = models.CharField( max_length=20, diff --git a/engine/apps/alerts/models/alert_group.py b/engine/apps/alerts/models/alert_group.py index 2e38272129..7a7b4e193c 100644 --- a/engine/apps/alerts/models/alert_group.py +++ b/engine/apps/alerts/models/alert_group.py @@ -12,7 +12,6 @@ from django.db.models import JSONField, Q, QuerySet from django.utils import timezone from django.utils.functional import cached_property -from django_deprecate_fields import deprecate_field from apps.alerts.constants import ActionSource, AlertGroupState from apps.alerts.escalation_snapshot import EscalationSnapshotMixin @@ -288,17 +287,20 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models. related_name="resolved_alert_groups", ) - # NOTE: see https://raintank-corp.slack.com/archives/C07RGREUH4Z/p1728494111646319 - # This field should eventually be dropped as it's no longer being set/read anywhere - resolved_by_alert = deprecate_field( - models.ForeignKey( - "alerts.Alert", - on_delete=models.SET_NULL, - null=True, - default=None, - related_name="resolved_alert_groups", - ) + resolved_by_alert = models.ForeignKey( + "alerts.Alert", + on_delete=models.SET_NULL, + null=True, + default=None, + related_name="resolved_alert_groups", ) + """ + ⚠️ This field is no longer being set/read anywhere, DON'T USE IT! ⚠️ + + TODO: We still need to figure out how to remove it safely. + + See [this conversation](https://raintank-corp.slack.com/archives/C07RGREUH4Z/p1728494111646319) for more context + """ resolved_at = models.DateTimeField(blank=True, null=True) acknowledged = models.BooleanField(default=False) diff --git a/engine/apps/alerts/tasks/notify_user.py b/engine/apps/alerts/tasks/notify_user.py index 10f62b257e..53b881294c 100644 --- a/engine/apps/alerts/tasks/notify_user.py +++ b/engine/apps/alerts/tasks/notify_user.py @@ -419,6 +419,20 @@ def perform_notification(log_record_pk, use_default_notification_policy_fallback ).save() return + if alert_group.resolved: + # skip notification if alert group was resolved + UserNotificationPolicyLogRecord( + author=user, + type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED, + notification_policy=notification_policy, + reason="Skipped notification because alert group is resolved", + alert_group=alert_group, + notification_step=notification_policy.step if notification_policy else None, + notification_channel=notification_channel, + notification_error_code=None, + ).save() + return + if notification_channel == UserNotificationPolicy.NotificationChannel.SMS: phone_backend = PhoneBackend() phone_backend.notify_by_sms(user, alert_group, notification_policy) diff --git a/engine/apps/alerts/tests/test_notify_user.py b/engine/apps/alerts/tests/test_notify_user.py index 5c0d638178..7124f957d1 100644 --- a/engine/apps/alerts/tests/test_notify_user.py +++ b/engine/apps/alerts/tests/test_notify_user.py @@ -186,6 +186,38 @@ def test_notify_user_error_if_viewer( assert error_log_record.notification_error_code == UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_FORBIDDEN +@pytest.mark.django_db +def test_notify_user_perform_notification_skip_if_resolved( + make_organization, + make_user, + make_user_notification_policy, + make_alert_receive_channel, + make_alert_group, + make_user_notification_policy_log_record, +): + organization = make_organization() + user_1 = make_user(organization=organization, _verified_phone_number="1234567890") + user_notification_policy = make_user_notification_policy( + user=user_1, + step=UserNotificationPolicy.Step.NOTIFY, + notify_by=UserNotificationPolicy.NotificationChannel.SMS, + ) + alert_receive_channel = make_alert_receive_channel(organization=organization) + alert_group = make_alert_group(alert_receive_channel=alert_receive_channel, resolved=True) + log_record = make_user_notification_policy_log_record( + author=user_1, + alert_group=alert_group, + notification_policy=user_notification_policy, + type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_TRIGGERED, + ) + + perform_notification(log_record.pk, False) + + error_log_record = UserNotificationPolicyLogRecord.objects.last() + assert error_log_record.type == UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED + assert error_log_record.reason == "Skipped notification because alert group is resolved" + + @pytest.mark.django_db @pytest.mark.parametrize( "reason_to_skip_escalation,error_code", 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/engine/requirements-dev.txt b/engine/requirements-dev.txt index a49c02b3dc..6a7b88bf3d 100644 --- a/engine/requirements-dev.txt +++ b/engine/requirements-dev.txt @@ -18,21 +18,21 @@ charset-normalizer==3.3.2 # requests distlib==0.3.8 # via virtualenv -django==4.2.15 +django==4.2.16 # via # -c requirements.txt # django-stubs # django-stubs-ext django-filter-stubs==0.1.3 # via -r requirements-dev.in -django-stubs==4.2.2 +django-stubs[compatible-mypy]==4.2.2 # via # -r requirements-dev.in # django-filter-stubs # djangorestframework-stubs django-stubs-ext==4.2.7 # via django-stubs -djangorestframework-stubs==3.14.2 +djangorestframework-stubs[compatible-mypy]==3.14.2 # via # -r requirements-dev.in # django-filter-stubs @@ -96,7 +96,7 @@ pytest-django==4.8.0 # via -r requirements-dev.in pytest-factoryboy==2.7.0 # via -r requirements-dev.in -pytest-xdist==3.6.1 +pytest-xdist[psutil]==3.6.1 # via -r requirements-dev.in python-dateutil==2.8.2 # via @@ -110,10 +110,6 @@ requests==2.32.3 # via # -c requirements.txt # djangorestframework-stubs -setuptools==73.0.0 - # via - # -c requirements.txt - # nodeenv six==1.16.0 # via # -c requirements.txt diff --git a/engine/requirements.in b/engine/requirements.in index 323847ab4f..5df7561312 100644 --- a/engine/requirements.in +++ b/engine/requirements.in @@ -2,7 +2,7 @@ babel==2.12.1 beautifulsoup4==4.12.2 celery[redis]==5.3.1 cryptography==43.0.1 -django==4.2.15 +django==4.2.16 django-add-default-value==0.10.0 django-amazon-ses==4.0.1 django-anymail==11.1 diff --git a/engine/requirements.txt b/engine/requirements.txt index ce4d1e6b99..1ded95f66c 100644 --- a/engine/requirements.txt +++ b/engine/requirements.txt @@ -15,9 +15,9 @@ attrs==23.2.0 autopep8==2.0.4 # via django-silk babel==2.12.1 - # via -r engine/requirements.in + # via -r requirements.in beautifulsoup4==4.12.2 - # via -r engine/requirements.in + # via -r requirements.in billiard==4.2.0 # via celery blinker==1.7.0 @@ -34,8 +34,8 @@ cachetools==4.2.2 # via # google-auth # python-telegram-bot -celery==5.3.1 - # via -r engine/requirements.in +celery[redis]==5.3.1 + # via -r requirements.in certifi==2024.7.4 # via # python-telegram-bot @@ -62,7 +62,7 @@ click-repl==0.3.0 # via celery cryptography==43.0.1 # via - # -r engine/requirements.in + # -r requirements.in # django-mirage-field # pyopenssl # social-auth-core @@ -75,9 +75,9 @@ deprecated==1.2.14 # opentelemetry-api # opentelemetry-exporter-otlp-proto-grpc # opentelemetry-semantic-conventions -django==4.2.15 +django==4.2.16 # via - # -r engine/requirements.in + # -r requirements.in # django-add-default-value # django-amazon-ses # django-anymail @@ -98,67 +98,67 @@ django==4.2.15 # fcm-django # social-auth-app-django django-add-default-value==0.10.0 - # via -r engine/requirements.in + # via -r requirements.in django-amazon-ses==4.0.1 - # via -r engine/requirements.in + # via -r requirements.in django-anymail==11.1 - # via -r engine/requirements.in + # via -r requirements.in django-cors-headers==3.7.0 - # via -r engine/requirements.in + # via -r requirements.in django-dbconn-retry==0.1.7 - # via -r engine/requirements.in + # via -r requirements.in django-debug-toolbar==4.1.0 - # via -r engine/requirements.in + # via -r requirements.in django-deprecate-fields==0.1.1 - # via -r engine/requirements.in + # via -r requirements.in django-filter==2.4.0 - # via -r engine/requirements.in + # via -r requirements.in django-ipware==4.0.2 - # via -r engine/requirements.in + # via -r requirements.in django-log-request-id==1.6.0 - # via -r engine/requirements.in + # via -r requirements.in django-migration-linter==4.1.0 - # via -r engine/requirements.in + # via -r requirements.in django-mirage-field==1.3.0 - # via -r engine/requirements.in + # via -r requirements.in django-mysql==4.6.0 - # via -r engine/requirements.in + # via -r requirements.in django-polymorphic==3.1.0 # via - # -r engine/requirements.in + # -r requirements.in # django-rest-polymorphic django-ratelimit==2.0.0 - # via -r engine/requirements.in + # via -r requirements.in django-redis==5.4.0 - # via -r engine/requirements.in + # via -r requirements.in django-rest-polymorphic==0.1.10 - # via -r engine/requirements.in + # via -r requirements.in django-silk==5.0.3 - # via -r engine/requirements.in + # via -r requirements.in django-sns-view==0.1.2 - # via -r engine/requirements.in + # via -r requirements.in djangorestframework==3.15.2 # via - # -r engine/requirements.in + # -r requirements.in # django-rest-polymorphic # drf-spectacular drf-spectacular==0.26.5 - # via -r engine/requirements.in + # via -r requirements.in emoji==2.4.0 # via - # -r engine/requirements.in + # -r requirements.in # slack-export-viewer factory-boy==2.12.0 - # via -r engine/requirements.in + # via -r requirements.in faker==23.1.0 # via factory-boy fcm-django @ https://github.com/grafana/fcm-django/archive/refs/tags/v1.0.12r1.tar.gz#sha256=7ec7cd9d353fc9edf19a4acd4fa14090a31d83d02ac986c5e5e081dea29f564f - # via -r engine/requirements.in + # via -r requirements.in firebase-admin==5.4.0 # via fcm-django flask==3.0.2 # via slack-export-viewer -google-api-core==2.17.0 +google-api-core[grpc]==2.17.0 # via # firebase-admin # google-api-python-client @@ -167,7 +167,7 @@ google-api-core==2.17.0 # google-cloud-storage google-api-python-client==2.122.0 # via - # -r engine/requirements.in + # -r requirements.in # firebase-admin google-auth==2.27.0 # via @@ -179,10 +179,10 @@ google-auth==2.27.0 # google-cloud-storage google-auth-httplib2==0.2.0 # via - # -r engine/requirements.in + # -r requirements.in # google-api-python-client google-auth-oauthlib==1.2.0 - # via -r engine/requirements.in + # via -r requirements.in google-cloud-core==2.4.1 # via # google-cloud-firestore @@ -206,28 +206,28 @@ gprof2dot==2022.7.29 # via django-silk grpcio==1.64.1 # via - # -r engine/requirements.in + # -r requirements.in # google-api-core # grpcio-status # opentelemetry-exporter-otlp-proto-grpc grpcio-status==1.57.0 # via google-api-core hiredis==2.2.3 - # via -r engine/requirements.in + # via -r requirements.in httplib2==0.22.0 # via # google-api-python-client # google-auth-httplib2 humanize==4.10.0 - # via -r engine/requirements.in + # via -r requirements.in icalendar==5.0.10 # via - # -r engine/requirements.in + # -r requirements.in # recurring-ical-events # x-wr-timezone idna==3.7 # via - # -r engine/requirements.in + # -r requirements.in # requests importlib-metadata==6.11.0 # via opentelemetry-api @@ -248,12 +248,12 @@ jsonschema-specifications==2023.12.1 kombu==5.3.5 # via celery lxml==5.2.2 - # via -r engine/requirements.in + # via -r requirements.in markdown==3.5.2 # via pymdown-extensions markdown2==2.4.10 # via - # -r engine/requirements.in + # -r requirements.in # slack-export-viewer markupsafe==2.1.5 # via @@ -267,7 +267,7 @@ oauthlib==3.2.2 # social-auth-core opentelemetry-api==1.26.0 # via - # -r engine/requirements.in + # -r requirements.in # opentelemetry-exporter-otlp-proto-grpc # opentelemetry-instrumentation # opentelemetry-instrumentation-django @@ -279,7 +279,7 @@ opentelemetry-api==1.26.0 opentelemetry-exporter-otlp-proto-common==1.26.0 # via opentelemetry-exporter-otlp-proto-grpc opentelemetry-exporter-otlp-proto-grpc==1.26.0 - # via -r engine/requirements.in + # via -r requirements.in opentelemetry-instrumentation==0.47b0 # via # opentelemetry-instrumentation-django @@ -287,14 +287,14 @@ opentelemetry-instrumentation==0.47b0 # opentelemetry-instrumentation-requests # opentelemetry-instrumentation-wsgi opentelemetry-instrumentation-django==0.47b0 - # via -r engine/requirements.in + # via -r requirements.in opentelemetry-instrumentation-logging==0.47b0 - # via -r engine/requirements.in + # via -r requirements.in opentelemetry-instrumentation-requests==0.47b0 - # via -r engine/requirements.in + # via -r requirements.in opentelemetry-instrumentation-wsgi==0.47b0 # via - # -r engine/requirements.in + # -r requirements.in # opentelemetry-instrumentation-django opentelemetry-proto==1.26.0 # via @@ -302,7 +302,7 @@ opentelemetry-proto==1.26.0 # opentelemetry-exporter-otlp-proto-grpc opentelemetry-sdk==1.26.0 # via - # -r engine/requirements.in + # -r requirements.in # opentelemetry-exporter-otlp-proto-grpc opentelemetry-semantic-conventions==0.47b0 # via @@ -318,9 +318,9 @@ opentelemetry-util-http==0.47b0 pem==23.1.0 # via django-sns-view phonenumbers==8.10.0 - # via -r engine/requirements.in + # via -r requirements.in prometheus-client==0.16.0 - # via -r engine/requirements.in + # via -r requirements.in prompt-toolkit==3.0.43 # via click-repl proto-plus==1.23.0 @@ -334,9 +334,9 @@ protobuf==4.25.2 # opentelemetry-proto # proto-plus psutil==5.9.4 - # via -r engine/requirements.in + # via -r requirements.in psycopg2==2.9.3 - # via -r engine/requirements.in + # via -r requirements.in pyasn1==0.5.1 # via # pyasn1-modules @@ -352,9 +352,9 @@ pyjwt==2.8.0 # social-auth-core # twilio pymdown-extensions==10.0 - # via -r engine/requirements.in + # via -r requirements.in pymysql==1.1.1 - # via -r engine/requirements.in + # via -r requirements.in pyopenssl==24.2.1 # via django-sns-view pyparsing==3.1.1 @@ -367,7 +367,7 @@ python-dateutil==2.8.2 # icalendar # recurring-ical-events python-telegram-bot==13.13 - # via -r engine/requirements.in + # via -r requirements.in python3-openid==3.2.0 # via social-auth-core pytz==2024.1 @@ -383,10 +383,10 @@ pyyaml==6.0.1 # drf-spectacular # pymdown-extensions recurring-ical-events==2.1.0 - # via -r engine/requirements.in + # via -r requirements.in redis==5.0.1 # via - # -r engine/requirements.in + # -r requirements.in # celery # django-redis referencing==0.33.0 @@ -394,10 +394,10 @@ referencing==0.33.0 # jsonschema # jsonschema-specifications regex==2024.7.24 - # via -r engine/requirements.in + # via -r requirements.in requests==2.32.3 # via - # -r engine/requirements.in + # -r requirements.in # cachecontrol # django-anymail # django-sns-view @@ -418,10 +418,6 @@ rsa==4.9 # via google-auth s3transfer==0.10.0 # via boto3 -setuptools==73.0.0 - # via - # apscheduler - # opentelemetry-instrumentation six==1.16.0 # via # apscheduler @@ -429,11 +425,11 @@ six==1.16.0 # python-dateutil # twilio slack-export-viewer==1.1.4 - # via -r engine/requirements.in + # via -r requirements.in slack-sdk==3.21.3 - # via -r engine/requirements.in + # via -r requirements.in social-auth-app-django==5.4.1 - # via -r engine/requirements.in + # via -r requirements.in social-auth-core==4.5.2 # via social-auth-app-django soupsieve==2.5 @@ -450,7 +446,7 @@ tornado==6.4.1 tqdm==4.66.3 # via django-mirage-field twilio==6.37.0 - # via -r engine/requirements.in + # via -r requirements.in typing-extensions==4.9.0 # via opentelemetry-sdk tzdata==2024.1 @@ -463,12 +459,12 @@ uritemplate==4.1.1 # google-api-python-client urllib3==1.26.19 # via - # -r engine/requirements.in + # -r requirements.in # botocore # django-anymail # requests uwsgi==2.0.26 - # via -r engine/requirements.in + # via -r requirements.in vine==5.1.0 # via # amqp @@ -479,7 +475,7 @@ wcwidth==0.2.13 werkzeug==3.0.3 # via flask whitenoise==5.3.0 - # via -r engine/requirements.in + # via -r requirements.in wrapt==1.16.0 # via # deprecated 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' },