Repository: zulip/zulip

Issue:
Error: The newrelic webhook requires timestamp in milliseconds
New Relic is changing their notification system from "Classic" "incidents" and "channels" to "policies", "workflows" and "destinations" and it appears that the new format is not recognised by Zulip.

Payload template:
```mustache
{
	"id": {{ json issueId }},
	"issueUrl": {{ json issuePageUrl }},
	"title": {{ json annotations.title.[0] }},
	"priority": {{ json priority }},
	"impactedEntities": {{json entitiesData.names}},
	"totalIncidents": {{json totalIncidents}},
	"state": {{ json state }},
	"trigger": {{ json triggerEvent }},
	"isCorrelated": {{ json isCorrelated }},
	"createdAt": {{ createdAt }},
	"updatedAt": {{ updatedAt }},
	"sources": {{ json accumulations.source }},
	"alertPolicyNames": {{ json accumulations.policyName }},
	"alertConditionNames": {{ json accumulations.conditionName }},
	"workflowName": {{ json workflowName }}
}
```

Payload preview:
```json
{
	"id": "d1b1f3fd-995a-4066-88ab-8ce4f6960654",
	"issueUrl": "https://one.newrelic.com/launcher/nrai.launcher?pane=eyJpc1Bob3RvbiI6dHJ1ZSwiaWQiOiJhYWFlMGRiMS1mMDI2LTRiNTMtYWU0Ni0yMTI0MTZmYzFhMjYiLCJuZXJkbGV0SWQiOiJucmFpLmlzc3VlLXJlZGlyZWN0In0=",
	"title": "Memory Used % > 90 for at least 2 minutes on 'Some-Entity'",
	"priority": "CRITICAL",
	"impactedEntities": ["logs.itg.cloud","MonitorTTFB query"],
	"totalIncidents": 42,
	"state": "ACTIVATED",
	"trigger": "INCIDENT_ADDED",
	"isCorrelated": "FALSE",
	"createdAt": 1617881246260,
	"updatedAt": 1617881246260,
	"sources": ["newrelic"],
	"alertPolicyNames": ["Policy1","Policy2"],
	"alertConditionNames": ["condition1","condition2"],
	"workflowName": "DBA Team workflow"
}
```

Response from Zulip (shown by New Relic after clicking the "send test notification" button):
```
Client request(https://[REDACTED].zulipchat.com/api/v1/external/newrelic?api_key=[REDACTED]&stream=[REDACTED]) invalid: 400 Bad Request. Text: "{"result":"error","msg":"The newrelic webhook requires timestamp in milliseconds","code":"BAD_REQUEST"}
"
```


Discussion:
Hello @zulip/server-integrations members, this issue was labeled with the "area: integrations" label, so you may want to check it out!

<!-- areaLabelAddition -->

@yuroitaki this could be another good one for you.
@zulipbot claim
Welcome to Zulip, @Rodwan-Bakkar! We just sent you an invite to collaborate on this repository at https://github.com/zulip/zulip/invitations. Please accept this invite in order to claim this issue and begin a fun, rewarding experience contributing to Zulip!

Here's some tips to get you off to a good start:

- Join me on the [Zulip developers' server](https://chat.zulip.org), to get help, chat about this issue, and meet the other developers.
- [Unwatch this repository](https://help.github.com/articles/unwatching-repositories/), so that you don't get 100 emails a day.

As you work on this issue, you'll also want to refer to the [Zulip code contribution guide](https://zulip.readthedocs.io/en/latest/contributing/index.html), as well as the rest of the developer documentation on that site.

See you on the other side (that is, the pull request side)!


Resolving patch:
diff --git a/zerver/webhooks/newrelic/view.py b/zerver/webhooks/newrelic/view.py
--- a/zerver/webhooks/newrelic/view.py
+++ b/zerver/webhooks/newrelic/view.py
@@ -11,6 +11,12 @@
 from zerver.lib.webhooks.common import check_send_webhook_message, unix_milliseconds_to_timestamp
 from zerver.models import UserProfile
 
+# Newrelic planned to upgrade Alert Notification Channels to Workflows and Destinations
+# https://discuss.newrelic.com/t/plan-to-upgrade-alert-notification-channels-to-workflows-and-destinations/188205
+# This view will handle both old and new format but will keep it easy to delete the old code
+# once it is EOLed by the end of June, 2023
+
+# Once old is EOLed, delete the OPEN_TEMPLATE
 OPEN_TEMPLATE = """
 [Incident]({incident_url}) **opened** for condition: **{condition_name}** at <time:{iso_timestamp}>
 ``` quote
@@ -18,13 +24,23 @@
 ```
 """.strip()
 
+ACTIVE_TEMPLATE = """
+[Incident]({incident_url}) **active** for condition: **{condition_name}** at <time:{iso_timestamp}>
+``` quote
+{details}
+```
+""".strip()
+
 DEFAULT_TEMPLATE = (
     """[Incident]({incident_url}) **{status}** {owner}for condition: **{condition_name}**""".strip()
 )
 
 TOPIC_TEMPLATE = """{policy_name} ({incident_id})""".strip()
 
-ALL_EVENT_TYPES = ["closed", "acknowledged", "open"]
+# Once old is EOLed, delete old and keep new
+OLD_EVENT_TYPES = ["closed", "acknowledged", "open"]
+NEW_EVENT_TYPES = ["created", "activated", "acknowledged", "closed"]
+ALL_EVENT_TYPES = list(set(OLD_EVENT_TYPES).union(set(NEW_EVENT_TYPES)))
 
 
 @webhook_view("NewRelic", all_event_types=ALL_EVENT_TYPES)
@@ -35,45 +51,102 @@ def api_newrelic_webhook(
     payload: Dict[str, Any] = REQ(argument_type="body"),
 ) -> HttpResponse:
 
-    info = {
-        "condition_name": payload.get("condition_name", "Unknown condition"),
-        "details": payload.get("details", "No details."),
-        "incident_url": payload.get("incident_url", "https://alerts.newrelic.com"),
-        "incident_acknowledge_url": payload.get(
-            "incident_acknowledge_url", "https://alerts.newrelic.com"
-        ),
-        "status": payload.get("current_state", "None"),
-        "iso_timestamp": "",
-        "owner": payload.get("owner", ""),
-    }
-
-    unix_time = payload.get("timestamp", None)
-    if unix_time is None:
-        raise JsonableError(_("The newrelic webhook requires timestamp in milliseconds"))
-
-    info["iso_timestamp"] = unix_milliseconds_to_timestamp(unix_time, "newrelic")
-
-    # Add formatting to the owner field if owner is present
-    if info["owner"] != "":
-        info["owner"] = "by **{}** ".format(info["owner"])
-
-    # These are the three promised current_state values
-    if "open" in info["status"]:
-        content = OPEN_TEMPLATE.format(**info)
-    elif "acknowledged" in info["status"]:
-        content = DEFAULT_TEMPLATE.format(**info)
-    elif "closed" in info["status"]:
-        content = DEFAULT_TEMPLATE.format(**info)
+    # Handle old format
+    # Once old is EOLed, delete if block and keep else block
+    if not payload.get("id"):
+        info = {
+            "condition_name": payload.get("condition_name", "Unknown condition"),
+            "details": payload.get("details", "No details."),
+            "incident_url": payload.get("incident_url", "https://alerts.newrelic.com"),
+            "incident_acknowledge_url": payload.get(
+                "incident_acknowledge_url", "https://alerts.newrelic.com"
+            ),
+            "status": payload.get("current_state", "None"),
+            "iso_timestamp": "",
+            "owner": payload.get("owner", ""),
+        }
+
+        unix_time = payload.get("timestamp", None)
+        if unix_time is None:
+            raise JsonableError(_("The newrelic webhook requires timestamp in milliseconds"))
+
+        info["iso_timestamp"] = unix_milliseconds_to_timestamp(unix_time, "newrelic")
+
+        # Add formatting to the owner field if owner is present
+        if info["owner"] != "":
+            info["owner"] = "by **{}** ".format(info["owner"])
+
+        # These are the three promised current_state values
+        if info["status"].lower() == "open":
+            content = OPEN_TEMPLATE.format(**info)
+        elif info["status"].lower() == "acknowledged":
+            content = DEFAULT_TEMPLATE.format(**info)
+        elif info["status"].lower() == "closed":
+            content = DEFAULT_TEMPLATE.format(**info)
+        else:
+            raise JsonableError(
+                _("The newrelic webhook requires current_state be in [open|acknowledged|closed]")
+            )
+
+        topic_info = {
+            "policy_name": payload.get("policy_name", "Unknown Policy"),
+            "incident_id": payload.get("incident_id", "Unknown ID"),
+        }
+        topic = TOPIC_TEMPLATE.format(**topic_info)
+
+        check_send_webhook_message(request, user_profile, topic, content, info["status"])
+        return json_success(request)
+
+    # Handle new format
     else:
-        raise JsonableError(
-            _("The newrelic webhook requires current_state be in [open|acknowledged|closed]")
-        )
-
-    topic_info = {
-        "policy_name": payload.get("policy_name", "Unknown Policy"),
-        "incident_id": payload.get("incident_id", "Unknown ID"),
-    }
-    topic = TOPIC_TEMPLATE.format(**topic_info)
-
-    check_send_webhook_message(request, user_profile, topic, content, info["status"])
-    return json_success(request)
+        info = {
+            "condition_name": payload.get("condition_name", "Unknown condition"),
+            "details": payload.get("details", "No details."),
+            "incident_url": payload.get("issueUrl", "https://alerts.newrelic.com"),
+            "incident_acknowledge_url": payload.get(
+                "incident_acknowledge_url", "https://alerts.newrelic.com"
+            ),
+            "status": payload.get("state", "None"),
+            "iso_timestamp": "",
+            "owner": payload.get("owner", ""),
+        }
+
+        unix_time = payload.get("createdAt", None)
+        if unix_time is None:
+            raise JsonableError(_("The newrelic webhook requires timestamp in milliseconds"))
+
+        info["iso_timestamp"] = unix_milliseconds_to_timestamp(unix_time, "newrelic")
+
+        # Add formatting to the owner field if owner is present
+        if info["owner"] != "":
+            info["owner"] = "by **{}** ".format(info["owner"])
+
+        # These are the three promised state values
+        if info["status"].lower() == "activated":
+            content = ACTIVE_TEMPLATE.format(**info)
+        elif info["status"].lower() == "acknowledged":
+            content = DEFAULT_TEMPLATE.format(**info)
+        elif info["status"].lower() == "closed":
+            content = DEFAULT_TEMPLATE.format(**info)
+        elif info["status"].lower() == "created":
+            content = DEFAULT_TEMPLATE.format(**info)
+        else:
+            raise JsonableError(
+                _(
+                    "The newrelic webhook requires state be in [created|activated|acknowledged|closed]"
+                )
+            )
+
+        policy_names_list = payload.get("alertPolicyNames", [])
+        if policy_names_list:
+            policy_names_str = ",".join(policy_names_list)
+        else:
+            policy_names_str = "Unknown Policy"
+        topic_info = {
+            "policy_name": policy_names_str,
+            "incident_id": payload.get("id", "Unknown ID"),
+        }
+        topic = TOPIC_TEMPLATE.format(**topic_info)
+
+        check_send_webhook_message(request, user_profile, topic, content, info["status"])
+        return json_success(request)


Test patch:
diff --git a/zerver/webhooks/newrelic/tests.py b/zerver/webhooks/newrelic/tests.py
--- a/zerver/webhooks/newrelic/tests.py
+++ b/zerver/webhooks/newrelic/tests.py
@@ -6,7 +6,11 @@ class NewRelicHookTests(WebhookTestCase):
     URL_TEMPLATE = "/api/v1/external/newrelic?stream={stream}&api_key={api_key}"
     WEBHOOK_DIR_NAME = "newrelic"
 
-    def test_open(self) -> None:
+    # The following 9 unit tests are for the old format
+    # corresponding json fixtures were renamed to have the "_old" trailing
+    # These tests and fixtures are to be deleted when old notifications EOLed
+
+    def test_open_old(self) -> None:
         expected_topic = "Test policy name (1234)"
         expected_message = """
 [Incident](https://alerts.newrelic.com/accounts/2941966/incidents/1234) **opened** for condition: **Server Down** at <time:2020-11-11 22:32:11.151000+00:00>
@@ -16,42 +20,42 @@ def test_open(self) -> None:
 """.strip()
 
         self.check_webhook(
-            "incident_opened",
+            "incident_opened_old",
             expected_topic,
             expected_message,
             content_type="application/json",
         )
 
-    def test_closed(self) -> None:
+    def test_closed_old(self) -> None:
         expected_topic = "Test policy name (1234)"
         expected_message = """
 [Incident](https://alerts.newrelic.com/accounts/2941966/incidents/1234) **closed** for condition: **Server Down**
 """.strip()
 
         self.check_webhook(
-            "incident_closed",
+            "incident_closed_old",
             expected_topic,
             expected_message,
             content_type="application/json",
         )
 
-    def test_acknowledged(self) -> None:
+    def test_acknowledged_old(self) -> None:
         expected_topic = "Test policy name (1234)"
         expected_message = """
 [Incident](https://alerts.newrelic.com/accounts/2941966/incidents/1234) **acknowledged** by **Alice** for condition: **Server Down**
 """.strip()
 
         self.check_webhook(
-            "incident_acknowledged",
+            "incident_acknowledged_old",
             expected_topic,
             expected_message,
             content_type="application/json",
         )
 
-    def test_not_recognized(self) -> None:
+    def test_not_recognized_old(self) -> None:
         with self.assertRaises(AssertionError) as e:
             self.check_webhook(
-                "incident_state_not_recognized",
+                "incident_state_not_recognized_old",
                 "",
                 "",
                 content_type="application/json",
@@ -61,7 +65,7 @@ def test_not_recognized(self) -> None:
             e.exception.args[0],
         )
 
-    def test_missing_fields(self) -> None:
+    def test_missing_fields_old(self) -> None:
         expected_topic = "Unknown Policy (Unknown ID)"
         expected_message = """
 [Incident](https://alerts.newrelic.com) **opened** for condition: **Unknown condition** at <time:2020-11-11 22:32:11.151000+00:00>
@@ -71,16 +75,16 @@ def test_missing_fields(self) -> None:
 """.strip()
 
         self.check_webhook(
-            "incident_default_fields",
+            "incident_default_fields_old",
             expected_topic,
             expected_message,
             content_type="application/json",
         )
 
-    def test_missing_current_state(self) -> None:
+    def test_missing_current_state_old(self) -> None:
         with self.assertRaises(AssertionError) as e:
             self.check_webhook(
-                "incident_missing_current_state",
+                "incident_missing_current_state_old",
                 "",
                 "",
                 content_type="application/json",
@@ -90,10 +94,143 @@ def test_missing_current_state(self) -> None:
             e.exception.args[0],
         )
 
-    def test_missing_timestamp(self) -> None:
+    def test_missing_timestamp_old(self) -> None:
+        with self.assertRaises(AssertionError) as e:
+            self.check_webhook(
+                "incident_missing_timestamp_old",
+                "",
+                "",
+                content_type="application/json",
+            )
+        self.assertIn(
+            "The newrelic webhook requires timestamp in milliseconds", e.exception.args[0]
+        )
+
+    def test_malformatted_time_old(self) -> None:
+        with self.assertRaises(AssertionError) as e:
+            self.check_webhook(
+                "incident_malformatted_time_old",
+                "",
+                "",
+                content_type="application/json",
+            )
+        self.assertIn("The newrelic webhook expects time in milliseconds.", e.exception.args[0])
+
+    def test_time_too_large_old(self) -> None:
+        with self.assertRaises(AssertionError) as e:
+            self.check_webhook(
+                "incident_time_too_large_old",
+                "",
+                "",
+                content_type="application/json",
+            )
+        self.assertIn("The newrelic webhook expects time in milliseconds.", e.exception.args[0])
+
+    # The following 10 unit tests are for the new format
+    # One more test than the old format as we have 4 states instead of 3 in the old
+    # corresponding json fixtures have "_new" trailing in the name
+
+    def test_activated_new(self) -> None:
+        expected_topic = "Test policy name (1234)"
+        expected_message = """
+[Incident](https://alerts.newrelic.com/accounts/2941966/incidents/1234) **active** for condition: **Server Down** at <time:2020-11-11 22:32:11.151000+00:00>
+``` quote
+Violation description test.
+```
+""".strip()
+
+        self.check_webhook(
+            "incident_active_new",
+            expected_topic,
+            expected_message,
+            content_type="application/json",
+        )
+
+    def test_created_new(self) -> None:
+        expected_topic = "Test policy name (1234)"
+        expected_message = """
+[Incident](https://alerts.newrelic.com/accounts/2941966/incidents/1234) **created** for condition: **Server Down**
+""".strip()
+
+        self.check_webhook(
+            "incident_created_new",
+            expected_topic,
+            expected_message,
+            content_type="application/json",
+        )
+
+    def test_closed_new(self) -> None:
+        expected_topic = "Test policy name (1234)"
+        expected_message = """
+[Incident](https://alerts.newrelic.com/accounts/2941966/incidents/1234) **closed** for condition: **Server Down**
+""".strip()
+
+        self.check_webhook(
+            "incident_closed_new",
+            expected_topic,
+            expected_message,
+            content_type="application/json",
+        )
+
+    def test_acknowledged_new(self) -> None:
+        expected_topic = "Test policy name (1234)"
+        expected_message = """
+[Incident](https://alerts.newrelic.com/accounts/2941966/incidents/1234) **acknowledged** by **Alice** for condition: **Server Down**
+""".strip()
+
+        self.check_webhook(
+            "incident_acknowledged_new",
+            expected_topic,
+            expected_message,
+            content_type="application/json",
+        )
+
+    def test_not_recognized_new(self) -> None:
+        with self.assertRaises(AssertionError) as e:
+            self.check_webhook(
+                "incident_state_not_recognized_new",
+                "",
+                "",
+                content_type="application/json",
+            )
+        self.assertIn(
+            "The newrelic webhook requires state be in [created|activated|acknowledged|closed]",
+            e.exception.args[0],
+        )
+
+    def test_missing_fields_new(self) -> None:
+        expected_topic = "Unknown Policy (1234)"
+        expected_message = """
+[Incident](https://alerts.newrelic.com) **active** for condition: **Unknown condition** at <time:2020-11-11 22:32:11.151000+00:00>
+``` quote
+No details.
+```
+""".strip()
+
+        self.check_webhook(
+            "incident_default_fields_new",
+            expected_topic,
+            expected_message,
+            content_type="application/json",
+        )
+
+    def test_missing_state_new(self) -> None:
+        with self.assertRaises(AssertionError) as e:
+            self.check_webhook(
+                "incident_missing_state_new",
+                "",
+                "",
+                content_type="application/json",
+            )
+        self.assertIn(
+            "The newrelic webhook requires state be in [created|activated|acknowledged|closed]",
+            e.exception.args[0],
+        )
+
+    def test_missing_timestamp_new(self) -> None:
         with self.assertRaises(AssertionError) as e:
             self.check_webhook(
-                "incident_missing_timestamp",
+                "incident_missing_timestamp_new",
                 "",
                 "",
                 content_type="application/json",
@@ -102,20 +239,20 @@ def test_missing_timestamp(self) -> None:
             "The newrelic webhook requires timestamp in milliseconds", e.exception.args[0]
         )
 
-    def test_malformatted_time(self) -> None:
+    def test_malformatted_time_new(self) -> None:
         with self.assertRaises(AssertionError) as e:
             self.check_webhook(
-                "incident_malformatted_time",
+                "incident_malformatted_time_new",
                 "",
                 "",
                 content_type="application/json",
             )
         self.assertIn("The newrelic webhook expects time in milliseconds.", e.exception.args[0])
 
-    def test_time_too_large(self) -> None:
+    def test_time_too_large_new(self) -> None:
         with self.assertRaises(AssertionError) as e:
             self.check_webhook(
-                "incident_time_too_large",
+                "incident_time_too_large_new",
                 "",
                 "",
                 content_type="application/json",